sapristi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +12 -0
- data/.reek.yml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +11 -0
- data/.travis.yml +7 -0
- data/COMM-LICENSE.txt +1 -0
- data/Gemfile +8 -0
- data/Guardfile +28 -0
- data/LICENSE.txt +2 -0
- data/README.md +39 -0
- data/Rakefile +8 -0
- data/bin/console +15 -0
- data/bin/sapristi +49 -0
- data/bin/setup +8 -0
- data/lib/sapristi.rb +33 -0
- data/lib/sapristi/adapters/linux/monitor_manager.rb +73 -0
- data/lib/sapristi/adapters/linux/process_manager.rb +36 -0
- data/lib/sapristi/adapters/linux/window_manager.rb +52 -0
- data/lib/sapristi/arguments_parser.rb +34 -0
- data/lib/sapristi/attribute_normalizer.rb +77 -0
- data/lib/sapristi/configuration_loader.rb +66 -0
- data/lib/sapristi/definition.rb +77 -0
- data/lib/sapristi/definition_parser.rb +66 -0
- data/lib/sapristi/definition_processor.rb +36 -0
- data/lib/sapristi/monitor.rb +33 -0
- data/lib/sapristi/monitor_manager.rb +39 -0
- data/lib/sapristi/new_process_window_detector.rb +74 -0
- data/lib/sapristi/sapristi.rb +61 -0
- data/lib/sapristi/version.rb +5 -0
- data/lib/sapristi/window_manager.rb +83 -0
- data/sapristi.gemspec +60 -0
- metadata +315 -0
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sapristi
|
4
|
+
class AttributeNormalizer
|
5
|
+
def initialize(key, raw, monitor)
|
6
|
+
@key = key
|
7
|
+
@raw = raw
|
8
|
+
@monitor = monitor
|
9
|
+
end
|
10
|
+
|
11
|
+
def normalize
|
12
|
+
if percentage?
|
13
|
+
apply_percentage
|
14
|
+
elsif not_a_percentage_but_includes_symbol?
|
15
|
+
raise Error, "key=#{key}, invalid percentage=#{raw}"
|
16
|
+
elsif numeric_field?
|
17
|
+
raw&.to_i
|
18
|
+
else
|
19
|
+
raw
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
attr_reader :key, :raw, :monitor
|
26
|
+
|
27
|
+
def percentage?
|
28
|
+
raw&.to_s&.match(/^([0-9]+)%$/)
|
29
|
+
end
|
30
|
+
|
31
|
+
def not_a_percentage_but_includes_symbol?
|
32
|
+
raw.to_s.include?('%')
|
33
|
+
end
|
34
|
+
|
35
|
+
def numeric_field?
|
36
|
+
Definition::NUMERIC_FIELDS.include?(key)
|
37
|
+
end
|
38
|
+
|
39
|
+
def apply_percentage
|
40
|
+
validate_percentage_field
|
41
|
+
|
42
|
+
(monitor_absolute * percentage).to_i + offset
|
43
|
+
end
|
44
|
+
|
45
|
+
def offset
|
46
|
+
work_area = monitor['work_area']
|
47
|
+
|
48
|
+
case key
|
49
|
+
when 'X-position'
|
50
|
+
work_area[0]
|
51
|
+
when 'Y-position'
|
52
|
+
work_area[1]
|
53
|
+
else
|
54
|
+
0
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def monitor_absolute
|
59
|
+
translated_key = Definition::TRANSLATIONS[key]
|
60
|
+
monitor[translated_key]
|
61
|
+
end
|
62
|
+
|
63
|
+
def percentage
|
64
|
+
value = raw.to_s.match(/^([0-9]+)%$/)[1].to_i
|
65
|
+
value / 100.0
|
66
|
+
end
|
67
|
+
|
68
|
+
def validate_percentage_field
|
69
|
+
min_percentage = { 'V-size' => 0.05, 'H-size' => 0.05 }.fetch(key, 0)
|
70
|
+
unless Definition::TRANSLATIONS.include? key
|
71
|
+
raise "#{key}=#{raw}, using percentage in invalid field, valid=#{Definition::TRANSLATIONS.keys.join(', ')}"
|
72
|
+
end
|
73
|
+
|
74
|
+
raise Error, "#{key} percentage is invalid=#{raw}, valid=5%-100%" if percentage < min_percentage || percentage > 1
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'csv'
|
4
|
+
|
5
|
+
module Sapristi
|
6
|
+
class ConfigurationLoader
|
7
|
+
SEPARATOR = ','
|
8
|
+
def initialize
|
9
|
+
@definition_parser = DefinitionParser.new
|
10
|
+
end
|
11
|
+
|
12
|
+
def load(file_path)
|
13
|
+
csv_rows = load_csv(file_path)
|
14
|
+
|
15
|
+
parse_rows(csv_rows, file_path)
|
16
|
+
end
|
17
|
+
|
18
|
+
def create_empty_configuration(conf_file)
|
19
|
+
raise Error, "Trying to write empty configuration on existing file #{conf_file}" if File.exist? conf_file
|
20
|
+
|
21
|
+
File.write(conf_file, Definition::HEADERS.join(SEPARATOR))
|
22
|
+
end
|
23
|
+
|
24
|
+
def save(conf_file, definitions)
|
25
|
+
raise Error, "Trying to write configuration on existing file #{conf_file}" if File.exist? conf_file
|
26
|
+
|
27
|
+
serialized = definitions.map { |definition| serialize definition }
|
28
|
+
|
29
|
+
write_to_csv conf_file, serialized
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def write_to_csv(conf_file, serialized)
|
35
|
+
CSV.open(conf_file, 'wb', write_headers: true, headers: Definition::HEADERS, col_sep: SEPARATOR) do |csv|
|
36
|
+
serialized.each { |definition| csv << definition }
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def serialize(definition)
|
41
|
+
Definition::HEADERS.map do |field|
|
42
|
+
definition.raw_definition[field]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def parse_rows(csv_rows, file)
|
47
|
+
csv_rows.each_with_index.map do |definition, line|
|
48
|
+
@definition_parser.parse(definition)
|
49
|
+
rescue Error => e
|
50
|
+
raise Error, "Invalid configuration file: #{e.message}, line=#{line}, file=#{file}"
|
51
|
+
rescue StandardError => e
|
52
|
+
raise Error, "Unable to process configuration file: #{file}, line=#{line}, error=#{e.message}"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def load_csv(csv_file)
|
57
|
+
table = CSV.read(csv_file, headers: true, col_sep: SEPARATOR)
|
58
|
+
rescue Errno::ENOENT
|
59
|
+
raise Error, "Configuration file not found: #{csv_file}"
|
60
|
+
else
|
61
|
+
raise Error, "Invalid configuration file: Empty file #{csv_file}" if table.eql? []
|
62
|
+
|
63
|
+
table.map(&:to_h)
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sapristi
|
4
|
+
class Definition
|
5
|
+
TRANSLATIONS = {
|
6
|
+
'H-size' => 'work_area_width', 'V-size' => 'work_area_height',
|
7
|
+
'X-position' => 'work_area_width', 'Y-position' => 'work_area_height'
|
8
|
+
}.freeze
|
9
|
+
NUMERIC_FIELDS = (TRANSLATIONS.keys + %w[Workspace]).freeze
|
10
|
+
|
11
|
+
def initialize(definition_hash)
|
12
|
+
validate_raw definition_hash
|
13
|
+
@raw_definition = definition_hash.to_h.clone
|
14
|
+
|
15
|
+
@monitor = MonitorManager.new.get_monitor_or_main definition_hash['Monitor']
|
16
|
+
@workspace = WindowManager.new.find_workspace_or_current definition_hash['Workspace']&.to_i
|
17
|
+
normalize_variables
|
18
|
+
end
|
19
|
+
|
20
|
+
def to_s
|
21
|
+
HEADERS.map { |key| "#{key}: #{raw_definition[key]}" }.join(', ')
|
22
|
+
end
|
23
|
+
|
24
|
+
attr_reader :raw_definition, :monitor, :x_position, :y_position, :v_size, :h_size, :workspace, :command, :title
|
25
|
+
|
26
|
+
def hash
|
27
|
+
state.hash
|
28
|
+
end
|
29
|
+
|
30
|
+
def ==(other)
|
31
|
+
other.class == self.class && state == other.state
|
32
|
+
end
|
33
|
+
|
34
|
+
alias eql? ==
|
35
|
+
|
36
|
+
protected
|
37
|
+
|
38
|
+
def state
|
39
|
+
raw_definition
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def normalize_variables
|
45
|
+
%w[Title Command X-position Y-position H-size V-size].each do |key|
|
46
|
+
name = key.downcase.gsub(/-/, '_')
|
47
|
+
value = AttributeNormalizer.new(key, @raw_definition[key], @monitor).normalize
|
48
|
+
instance_variable_set "@#{name}".to_sym, value
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def validate_raw(definition)
|
53
|
+
validate_headers(definition)
|
54
|
+
raise Error, 'No command or window title specified' unless definition['Command'] || definition['Title']
|
55
|
+
|
56
|
+
validate_geometry(definition)
|
57
|
+
|
58
|
+
raise Error, "Invalid monitor=#{definition['Monitor']}" if definition['Monitor']&.to_i&.negative?
|
59
|
+
end
|
60
|
+
|
61
|
+
def validate_geometry(definition)
|
62
|
+
geometry_field_nil = %w[H-size V-size X-position Y-position].find { |key| definition[key].nil? }
|
63
|
+
raise Error, "No #{geometry_field_nil} specified" if geometry_field_nil
|
64
|
+
end
|
65
|
+
|
66
|
+
HEADERS = %w[Title Command Monitor Workspace X-position Y-position H-size V-size].freeze
|
67
|
+
|
68
|
+
def validate_headers(definition)
|
69
|
+
headers = definition.keys
|
70
|
+
return if Set.new(HEADERS).superset?(Set.new(headers))
|
71
|
+
|
72
|
+
actual_headers = headers.join(', ')
|
73
|
+
expected_headers = HEADERS.join(', ')
|
74
|
+
raise Error, "Invalid configuration file: invalid headers=#{actual_headers}, valid=#{expected_headers}"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sapristi
|
4
|
+
class DefinitionParser
|
5
|
+
def initialize
|
6
|
+
@monitor_manager = MonitorManager.new
|
7
|
+
@window_manager = WindowManager.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def parse(definition_hash)
|
11
|
+
definition = Definition.new(definition_hash)
|
12
|
+
|
13
|
+
validate definition
|
14
|
+
definition
|
15
|
+
end
|
16
|
+
|
17
|
+
private
|
18
|
+
|
19
|
+
def validate(definition)
|
20
|
+
validate_monitor(definition)
|
21
|
+
validate_window_min_size(definition)
|
22
|
+
end
|
23
|
+
|
24
|
+
def validate_monitor(definition)
|
25
|
+
monitor = definition.monitor
|
26
|
+
monitor_width = monitor['x']
|
27
|
+
monitor_height = monitor['y']
|
28
|
+
|
29
|
+
validate_monitor_dimensions(definition, monitor_width, monitor_height)
|
30
|
+
validate_work_area(definition, monitor_width, monitor_height)
|
31
|
+
end
|
32
|
+
|
33
|
+
def validate_monitor_dimensions(normalized, monitor_width, monitor_height)
|
34
|
+
x_pos = normalized.x_position
|
35
|
+
y_pos = normalized.y_position
|
36
|
+
unless (0...monitor_width).include? x_pos
|
37
|
+
raise Error, "x=#{x_pos} is outside of monitor width dimension=0..#{monitor_width - 1}"
|
38
|
+
end
|
39
|
+
unless (0...monitor_height).include? y_pos
|
40
|
+
raise Error, "y=#{y_pos} is outside of monitor height dimension=0..#{monitor_height - 1}"
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def validate_work_area(normalized, monitor_width, monitor_height)
|
45
|
+
x_pos = normalized.x_position
|
46
|
+
y_pos = normalized.y_position
|
47
|
+
x_end = x_pos + normalized.h_size
|
48
|
+
y_end = y_pos + normalized.v_size
|
49
|
+
if x_end >= monitor_width
|
50
|
+
raise Error, "window x dimensions: [#{x_pos}, #{x_end}] exceeds monitor width [0..#{monitor_width - 1}]"
|
51
|
+
end
|
52
|
+
if y_end >= monitor_height
|
53
|
+
raise Error, "window y dimensions: [#{y_pos}, #{y_end}] exceeds monitor height [0..#{monitor_height - 1}]"
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
MIN_X_SIZE = 50
|
58
|
+
MIN_Y_SIZE = 50
|
59
|
+
def validate_window_min_size(normalized)
|
60
|
+
window_width = normalized.h_size
|
61
|
+
window_height = normalized.v_size
|
62
|
+
raise Error, "window x size=#{window_width} less than #{MIN_X_SIZE}" if window_width < MIN_X_SIZE
|
63
|
+
raise Error, "window y size=#{window_height} less than #{MIN_Y_SIZE}" if window_height < MIN_Y_SIZE
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sapristi
|
4
|
+
class DefinitionProcessor
|
5
|
+
def initialize(window_manager = WindowManager.new, process_manager = NewProcessWindowDetector.new)
|
6
|
+
@window_manager = window_manager
|
7
|
+
@process_manager = process_manager
|
8
|
+
end
|
9
|
+
|
10
|
+
def process_definition(definition)
|
11
|
+
window = get_window definition.title, definition.command
|
12
|
+
|
13
|
+
@window_manager.move_resize(window,
|
14
|
+
definition.x_position, definition.y_position,
|
15
|
+
definition.h_size, definition.v_size)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def get_window(title, command)
|
21
|
+
(title && find_one_by_title(title)) ||
|
22
|
+
(command && @process_manager.detect_window_for_process(command)) ||
|
23
|
+
raise(Error, "Couldn't produce a window for this definition")
|
24
|
+
end
|
25
|
+
|
26
|
+
def find_one_by_title(title)
|
27
|
+
windows = @window_manager.find_window(/#{title}/)
|
28
|
+
raise Error, "#{windows.size} windows have the same title: #{title}" if windows.size > 1
|
29
|
+
|
30
|
+
if windows.size.eql? 1
|
31
|
+
::Sapristi.logger.info "Found existing window pid=#{windows[0].pid} title=#{windows[0].title}"
|
32
|
+
end
|
33
|
+
windows[0]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sapristi
|
4
|
+
class Monitor
|
5
|
+
def initialize(data)
|
6
|
+
data.each { |key, value| instance_variable_set "@#{key}".to_sym, value }
|
7
|
+
end
|
8
|
+
|
9
|
+
def [](key)
|
10
|
+
instance_variable_get "@#{key}"
|
11
|
+
end
|
12
|
+
|
13
|
+
ATTRIBUTES = %i[id main name x y offset_x offset_y work_area work_area_width work_area_height].freeze
|
14
|
+
|
15
|
+
attr_reader(*ATTRIBUTES)
|
16
|
+
|
17
|
+
def hash
|
18
|
+
state.hash
|
19
|
+
end
|
20
|
+
|
21
|
+
def ==(other)
|
22
|
+
other.class == self.class && state == other.state
|
23
|
+
end
|
24
|
+
|
25
|
+
alias eql? ==
|
26
|
+
|
27
|
+
protected
|
28
|
+
|
29
|
+
def state
|
30
|
+
ATTRIBUTES.map { |attribute| send attribute }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'gtk3'
|
4
|
+
|
5
|
+
# https://specifications.freedesktop.org/wm-spec/1.4/ar01s03.html
|
6
|
+
# https://linux.die.net/man/1/xprop
|
7
|
+
|
8
|
+
module Sapristi
|
9
|
+
class MonitorManager
|
10
|
+
def initialize
|
11
|
+
@os_manager = Linux::MonitorManager.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def get_monitor_or_main(name)
|
15
|
+
return monitors[name] if monitor_present?(name)
|
16
|
+
|
17
|
+
use_main_monitor name
|
18
|
+
end
|
19
|
+
|
20
|
+
def monitors
|
21
|
+
@os_manager.monitors
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def monitor_present?(name)
|
27
|
+
monitors.key? name
|
28
|
+
end
|
29
|
+
|
30
|
+
def use_main_monitor(name)
|
31
|
+
main = monitors.values.find { |monitor| monitor['main'] }
|
32
|
+
if name
|
33
|
+
aval_names = monitors.keys.join(', ')
|
34
|
+
::Sapristi.logger.warn "Monitor #{name} not found. Using #{main['name']}, available=#{aval_names}"
|
35
|
+
end
|
36
|
+
main
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Sapristi
|
4
|
+
class NewProcessWindowDetector
|
5
|
+
def initialize
|
6
|
+
@display = WMCtrl.display
|
7
|
+
@process_manager = Linux::ProcessManager.new
|
8
|
+
end
|
9
|
+
|
10
|
+
def detect_window_for_process(command, timeout_in_seconds = 30)
|
11
|
+
save_pids_and_windows
|
12
|
+
|
13
|
+
process_window = wait_for_window(command, timeout_in_seconds)
|
14
|
+
|
15
|
+
if process_window
|
16
|
+
::Sapristi.logger.info " Found window title=#{process_window.title} for process=#{process_window.pid}!"
|
17
|
+
end
|
18
|
+
|
19
|
+
process_window
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
attr_reader :previous_windows_ids, :previous_pids, :process_manager
|
25
|
+
|
26
|
+
def save_pids_and_windows
|
27
|
+
@previous_windows_ids = @display.windows.map { |window| window[:id] }
|
28
|
+
@previous_pids = process_manager.class.user_pids
|
29
|
+
end
|
30
|
+
|
31
|
+
def wait_for_window(command, timeout_in_seconds)
|
32
|
+
program = command.split[0]
|
33
|
+
waiter = process_manager.execute_and_detach command
|
34
|
+
|
35
|
+
window = discover_window(waiter, program, timeout_in_seconds)
|
36
|
+
return window if window
|
37
|
+
|
38
|
+
raise Error, 'Error executing process, is dead' unless waiter.alive?
|
39
|
+
|
40
|
+
process_manager.kill waiter
|
41
|
+
end
|
42
|
+
|
43
|
+
def discover_window(waiter, program, timeout_in_seconds)
|
44
|
+
start_time = Time.now
|
45
|
+
while Time.now - start_time < timeout_in_seconds # && waiter.alive?
|
46
|
+
process_window = detect_new_windows.find do |window|
|
47
|
+
window_for_waiter?(waiter, window) || window_for_command?(waiter, window, program)
|
48
|
+
end
|
49
|
+
|
50
|
+
return process_window if process_window
|
51
|
+
|
52
|
+
sleep 0.5
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
def window_for_waiter?(waiter, window)
|
57
|
+
waiter.alive? && window.pid.eql?(waiter.pid)
|
58
|
+
end
|
59
|
+
|
60
|
+
def window_for_command?(waiter, window, program)
|
61
|
+
!waiter.alive? && process_manager.cmd_for_pid(window.pid).start_with?(program)
|
62
|
+
end
|
63
|
+
|
64
|
+
def detect_new_windows
|
65
|
+
new_windows = @display.windows.filter { |window| new_window?(window) }
|
66
|
+
|
67
|
+
new_windows.each { |window| ::Sapristi.logger.debug " Found new window=#{window.pid}: #{window.title}" }
|
68
|
+
end
|
69
|
+
|
70
|
+
def new_window?(window)
|
71
|
+
!previous_windows_ids.include?(window.id)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|