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.
@@ -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