mui 0.1.0 → 0.2.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.
Files changed (86) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +158 -0
  3. data/CHANGELOG.md +349 -0
  4. data/exe/mui +1 -2
  5. data/lib/mui/autocmd.rb +66 -0
  6. data/lib/mui/buffer.rb +275 -0
  7. data/lib/mui/buffer_word_cache.rb +131 -0
  8. data/lib/mui/buffer_word_completer.rb +77 -0
  9. data/lib/mui/color_manager.rb +136 -0
  10. data/lib/mui/color_scheme.rb +63 -0
  11. data/lib/mui/command_completer.rb +21 -0
  12. data/lib/mui/command_context.rb +90 -0
  13. data/lib/mui/command_line.rb +137 -0
  14. data/lib/mui/command_registry.rb +25 -0
  15. data/lib/mui/completion_renderer.rb +84 -0
  16. data/lib/mui/completion_state.rb +58 -0
  17. data/lib/mui/config.rb +56 -0
  18. data/lib/mui/editor.rb +319 -0
  19. data/lib/mui/error.rb +29 -0
  20. data/lib/mui/file_completer.rb +51 -0
  21. data/lib/mui/floating_window.rb +161 -0
  22. data/lib/mui/handler_result.rb +101 -0
  23. data/lib/mui/highlight.rb +22 -0
  24. data/lib/mui/highlighters/base.rb +23 -0
  25. data/lib/mui/highlighters/search_highlighter.rb +26 -0
  26. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  27. data/lib/mui/highlighters/syntax_highlighter.rb +105 -0
  28. data/lib/mui/input.rb +17 -0
  29. data/lib/mui/insert_completion_renderer.rb +92 -0
  30. data/lib/mui/insert_completion_state.rb +77 -0
  31. data/lib/mui/job.rb +81 -0
  32. data/lib/mui/job_manager.rb +113 -0
  33. data/lib/mui/key_code.rb +30 -0
  34. data/lib/mui/key_handler/base.rb +100 -0
  35. data/lib/mui/key_handler/command_mode.rb +443 -0
  36. data/lib/mui/key_handler/insert_mode.rb +354 -0
  37. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  38. data/lib/mui/key_handler/normal_mode.rb +579 -0
  39. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  40. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  41. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  42. data/lib/mui/key_handler/operators/paste_operator.rb +113 -0
  43. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  44. data/lib/mui/key_handler/search_mode.rb +188 -0
  45. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  46. data/lib/mui/key_handler/visual_mode.rb +397 -0
  47. data/lib/mui/key_handler/window_command.rb +112 -0
  48. data/lib/mui/key_handler.rb +16 -0
  49. data/lib/mui/layout/calculator.rb +15 -0
  50. data/lib/mui/layout/leaf_node.rb +33 -0
  51. data/lib/mui/layout/node.rb +29 -0
  52. data/lib/mui/layout/split_node.rb +132 -0
  53. data/lib/mui/line_renderer.rb +122 -0
  54. data/lib/mui/mode.rb +13 -0
  55. data/lib/mui/mode_manager.rb +185 -0
  56. data/lib/mui/motion.rb +139 -0
  57. data/lib/mui/plugin.rb +35 -0
  58. data/lib/mui/plugin_manager.rb +106 -0
  59. data/lib/mui/register.rb +110 -0
  60. data/lib/mui/screen.rb +85 -0
  61. data/lib/mui/search_completer.rb +50 -0
  62. data/lib/mui/search_input.rb +40 -0
  63. data/lib/mui/search_state.rb +88 -0
  64. data/lib/mui/selection.rb +55 -0
  65. data/lib/mui/status_line_renderer.rb +40 -0
  66. data/lib/mui/syntax/language_detector.rb +74 -0
  67. data/lib/mui/syntax/lexer_base.rb +106 -0
  68. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  69. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  70. data/lib/mui/syntax/token.rb +42 -0
  71. data/lib/mui/syntax/token_cache.rb +91 -0
  72. data/lib/mui/tab_bar_renderer.rb +87 -0
  73. data/lib/mui/tab_manager.rb +96 -0
  74. data/lib/mui/tab_page.rb +35 -0
  75. data/lib/mui/terminal_adapter/base.rb +92 -0
  76. data/lib/mui/terminal_adapter/curses.rb +162 -0
  77. data/lib/mui/terminal_adapter.rb +4 -0
  78. data/lib/mui/themes/default.rb +315 -0
  79. data/lib/mui/undo_manager.rb +83 -0
  80. data/lib/mui/undoable_action.rb +175 -0
  81. data/lib/mui/unicode_width.rb +100 -0
  82. data/lib/mui/version.rb +1 -1
  83. data/lib/mui/window.rb +158 -0
  84. data/lib/mui/window_manager.rb +249 -0
  85. data/lib/mui.rb +156 -2
  86. metadata +98 -3
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Base class for undoable actions
5
+ class UndoableAction
6
+ def execute(buffer); end
7
+ def undo(buffer); end
8
+ end
9
+
10
+ # Insert a single character
11
+ class InsertCharAction < UndoableAction
12
+ def initialize(row, col, char)
13
+ super()
14
+ @row = row
15
+ @col = col
16
+ @char = char
17
+ end
18
+
19
+ def execute(buffer)
20
+ buffer.insert_char_without_record(@row, @col, @char)
21
+ end
22
+
23
+ def undo(buffer)
24
+ buffer.delete_char_without_record(@row, @col)
25
+ end
26
+ end
27
+
28
+ # Delete a single character
29
+ class DeleteCharAction < UndoableAction
30
+ def initialize(row, col, char)
31
+ super()
32
+ @row = row
33
+ @col = col
34
+ @char = char
35
+ end
36
+
37
+ def execute(buffer)
38
+ buffer.delete_char_without_record(@row, @col)
39
+ end
40
+
41
+ def undo(buffer)
42
+ buffer.insert_char_without_record(@row, @col, @char)
43
+ end
44
+ end
45
+
46
+ # Insert a line
47
+ class InsertLineAction < UndoableAction
48
+ def initialize(row, text)
49
+ super()
50
+ @row = row
51
+ @text = text
52
+ end
53
+
54
+ def execute(buffer)
55
+ buffer.insert_line_without_record(@row, @text)
56
+ end
57
+
58
+ def undo(buffer)
59
+ buffer.delete_line_without_record(@row)
60
+ end
61
+ end
62
+
63
+ # Delete a line
64
+ class DeleteLineAction < UndoableAction
65
+ def initialize(row, text)
66
+ super()
67
+ @row = row
68
+ @text = text
69
+ end
70
+
71
+ def execute(buffer)
72
+ buffer.delete_line_without_record(@row)
73
+ end
74
+
75
+ def undo(buffer)
76
+ buffer.insert_line_without_record(@row, @text)
77
+ end
78
+ end
79
+
80
+ # Split a line (Enter key)
81
+ class SplitLineAction < UndoableAction
82
+ def initialize(row, col)
83
+ super()
84
+ @row = row
85
+ @col = col
86
+ end
87
+
88
+ def execute(buffer)
89
+ buffer.split_line_without_record(@row, @col)
90
+ end
91
+
92
+ def undo(buffer)
93
+ buffer.join_lines_without_record(@row)
94
+ end
95
+ end
96
+
97
+ # Join lines (Backspace at line start)
98
+ class JoinLinesAction < UndoableAction
99
+ def initialize(row, col)
100
+ super()
101
+ @row = row
102
+ @col = col
103
+ end
104
+
105
+ def execute(buffer)
106
+ buffer.join_lines_without_record(@row)
107
+ end
108
+
109
+ def undo(buffer)
110
+ buffer.split_line_without_record(@row, @col)
111
+ end
112
+ end
113
+
114
+ # Delete a range of text
115
+ class DeleteRangeAction < UndoableAction
116
+ def initialize(start_row, start_col, end_row, end_col, deleted_lines)
117
+ super()
118
+ @start_row = start_row
119
+ @start_col = start_col
120
+ @end_row = end_row
121
+ @end_col = end_col
122
+ @deleted_lines = deleted_lines
123
+ end
124
+
125
+ def execute(buffer)
126
+ buffer.delete_range_without_record(@start_row, @start_col, @end_row, @end_col)
127
+ end
128
+
129
+ def undo(buffer)
130
+ buffer.restore_range(@start_row, @start_col, @deleted_lines)
131
+ end
132
+ end
133
+
134
+ # Replace line content (for cc command)
135
+ class ReplaceLineAction < UndoableAction
136
+ def initialize(row, old_text, new_text)
137
+ super()
138
+ @row = row
139
+ @old_text = old_text
140
+ @new_text = new_text
141
+ end
142
+
143
+ def execute(buffer)
144
+ buffer.replace_line_without_record(@row, @new_text)
145
+ end
146
+
147
+ def undo(buffer)
148
+ buffer.replace_line_without_record(@row, @old_text)
149
+ end
150
+ end
151
+
152
+ # Group multiple actions into one undo unit
153
+ class GroupAction < UndoableAction
154
+ def initialize(actions)
155
+ super()
156
+ @actions = actions
157
+ end
158
+
159
+ def execute(buffer)
160
+ @actions.each { |action| action.execute(buffer) }
161
+ end
162
+
163
+ def undo(buffer)
164
+ @actions.reverse_each { |action| action.undo(buffer) }
165
+ end
166
+
167
+ def empty?
168
+ @actions.empty?
169
+ end
170
+
171
+ def size
172
+ @actions.size
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ # Utility module for calculating display width of Unicode characters
5
+ # CJK characters and some other characters are "wide" (2 cells)
6
+ module UnicodeWidth
7
+ class << self
8
+ # Returns the display width of a single character
9
+ def char_width(char)
10
+ return 0 if char.nil? || char.empty?
11
+
12
+ ord = char.ord
13
+
14
+ # Control characters
15
+ return 0 if ord < 32
16
+
17
+ # ASCII printable characters
18
+ return 1 if ord < 127
19
+
20
+ # Non-printable
21
+ return 0 if ord == 127
22
+
23
+ # Wide characters (East Asian Wide and Fullwidth)
24
+ return 2 if wide_char?(ord)
25
+
26
+ # Default to 1 for other characters
27
+ 1
28
+ end
29
+
30
+ # Returns the display width of a string
31
+ def string_width(str)
32
+ return 0 if str.nil?
33
+
34
+ str.chars.sum { |c| char_width(c) }
35
+ end
36
+
37
+ # Returns the display width of a substring from index 0 to col (exclusive)
38
+ def width_to_col(str, col)
39
+ return 0 if str.nil? || col <= 0
40
+
41
+ str.chars.take(col).sum { |c| char_width(c) }
42
+ end
43
+
44
+ # Returns the character index for a given display width position
45
+ def col_at_width(str, target_width)
46
+ return 0 if str.nil? || target_width <= 0
47
+
48
+ current_width = 0
49
+ str.chars.each_with_index do |char, index|
50
+ return index if current_width >= target_width
51
+
52
+ current_width += char_width(char)
53
+ end
54
+ str.length
55
+ end
56
+
57
+ private
58
+
59
+ def wide_char?(ord)
60
+ # CJK ranges (simplified, covers most common cases)
61
+ # Full implementation would use Unicode East Asian Width property
62
+
63
+ # Hangul Jamo
64
+ return true if ord.between?(0x1100, 0x115F)
65
+
66
+ # CJK Radicals Supplement to Enclosed CJK Letters
67
+ return true if ord.between?(0x2E80, 0x4DBF)
68
+
69
+ # CJK Unified Ideographs
70
+ return true if ord.between?(0x4E00, 0x9FFF)
71
+
72
+ # Hangul Syllables
73
+ return true if ord.between?(0xAC00, 0xD7AF)
74
+
75
+ # CJK Compatibility Ideographs
76
+ return true if ord.between?(0xF900, 0xFAFF)
77
+
78
+ # Fullwidth Forms
79
+ return true if ord.between?(0xFF00, 0xFF60)
80
+
81
+ # CJK Unified Ideographs Extension B-F
82
+ return true if ord.between?(0x20000, 0x2FA1F)
83
+
84
+ # Halfwidth Katakana (narrow, actually 1)
85
+ return false if ord.between?(0xFF61, 0xFFDC)
86
+
87
+ # Japanese Hiragana
88
+ return true if ord.between?(0x3040, 0x309F)
89
+
90
+ # Japanese Katakana
91
+ return true if ord.between?(0x30A0, 0x30FF)
92
+
93
+ # CJK Symbols and Punctuation
94
+ return true if ord.between?(0x3000, 0x303F)
95
+
96
+ false
97
+ end
98
+ end
99
+ end
100
+ end
data/lib/mui/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mui
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/mui/window.rb ADDED
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class Window
5
+ attr_accessor :x, :y, :width, :height, :cursor_row, :cursor_col, :scroll_row, :scroll_col
6
+ attr_reader :buffer
7
+
8
+ def initialize(buffer, x: 0, y: 0, width: 80, height: 24, color_scheme: nil)
9
+ @buffer = buffer
10
+ @x = x
11
+ @y = y
12
+ @width = width
13
+ @height = height
14
+ @cursor_row = 0
15
+ @cursor_col = 0
16
+ @scroll_row = 0
17
+ @scroll_col = 0
18
+ @color_scheme = color_scheme
19
+ @syntax_highlighter = Highlighters::SyntaxHighlighter.new(color_scheme, buffer:)
20
+ @line_renderer = create_line_renderer
21
+ @status_line_renderer = StatusLineRenderer.new(buffer, self, color_scheme)
22
+ end
23
+
24
+ def buffer=(new_buffer)
25
+ @buffer = new_buffer
26
+ @cursor_row = 0
27
+ @cursor_col = 0
28
+ @scroll_row = 0
29
+ @scroll_col = 0
30
+ @syntax_highlighter.buffer = new_buffer
31
+ @line_renderer = create_line_renderer
32
+ @status_line_renderer = StatusLineRenderer.new(new_buffer, self, @color_scheme)
33
+ end
34
+
35
+ def visible_height
36
+ @height - 1 # Status line only (command line is shared by all windows)
37
+ end
38
+
39
+ def visible_width
40
+ @width
41
+ end
42
+
43
+ def ensure_cursor_visible
44
+ # 縦スクロール
45
+ if @cursor_row < @scroll_row
46
+ @scroll_row = @cursor_row
47
+ elsif @cursor_row >= @scroll_row + visible_height
48
+ @scroll_row = @cursor_row - visible_height + 1
49
+ end
50
+
51
+ # 横スクロール
52
+ if @cursor_col < @scroll_col
53
+ @scroll_col = @cursor_col
54
+ elsif @cursor_col >= @scroll_col + visible_width
55
+ @scroll_col = @cursor_col - visible_width + 1
56
+ end
57
+ end
58
+
59
+ def render(screen, selection: nil, search_state: nil)
60
+ options = build_render_options(selection, search_state)
61
+
62
+ visible_height.times do |i|
63
+ row = @scroll_row + i
64
+ render_line(screen, row, i, options)
65
+ end
66
+
67
+ @status_line_renderer.render(screen, @y + visible_height)
68
+ end
69
+
70
+ def render_line(screen, row, screen_row, options)
71
+ line = prepare_visible_line(row)
72
+ adjusted_options = adjust_options_for_scroll(options)
73
+ @line_renderer.render(screen, line, row, @x, @y + screen_row, adjusted_options)
74
+ end
75
+
76
+ def screen_cursor_x
77
+ line = @buffer.line(@cursor_row) || ""
78
+ # Calculate display width from scroll_col to cursor_col
79
+ visible_text = line[@scroll_col...@cursor_col] || ""
80
+ @x + UnicodeWidth.string_width(visible_text)
81
+ end
82
+
83
+ def screen_cursor_y
84
+ @y + @cursor_row - @scroll_row
85
+ end
86
+
87
+ # カーソル移動
88
+ def move_left
89
+ @cursor_col -= 1 if @cursor_col.positive?
90
+ end
91
+
92
+ def move_right
93
+ @cursor_col += 1 if @cursor_col < max_cursor_col
94
+ end
95
+
96
+ def move_up
97
+ @cursor_row -= 1 if @cursor_row.positive?
98
+ clamp_cursor_col
99
+ end
100
+
101
+ def move_down
102
+ @cursor_row += 1 if @cursor_row < @buffer.line_count - 1
103
+ clamp_cursor_col
104
+ end
105
+
106
+ def clamp_cursor_to_line(buffer)
107
+ max_col = [buffer.line(@cursor_row).length - 1, 0].max
108
+ @cursor_col = max_col if @cursor_col > max_col
109
+ end
110
+
111
+ # Refresh highlighters (call when custom highlighters change)
112
+ def refresh_highlighters
113
+ @line_renderer = create_line_renderer
114
+ end
115
+
116
+ private
117
+
118
+ def create_line_renderer
119
+ renderer = LineRenderer.new(@color_scheme)
120
+ renderer.add_highlighter(@syntax_highlighter)
121
+ renderer.add_highlighter(Highlighters::SelectionHighlighter.new(@color_scheme))
122
+ renderer.add_highlighter(Highlighters::SearchHighlighter.new(@color_scheme))
123
+
124
+ # Add buffer-specific custom highlighters
125
+ @buffer.custom_highlighters(@color_scheme).each do |highlighter|
126
+ renderer.add_highlighter(highlighter)
127
+ end
128
+
129
+ renderer
130
+ end
131
+
132
+ def prepare_visible_line(row)
133
+ line = @buffer.line(row)
134
+ visible_line = @scroll_col < line.length ? line[@scroll_col, visible_width] || "" : ""
135
+ visible_line.ljust(visible_width)
136
+ end
137
+
138
+ def build_render_options(selection, search_state)
139
+ { selection:, search_state:, scroll_col: @scroll_col, buffer: @buffer }
140
+ end
141
+
142
+ def adjust_options_for_scroll(options)
143
+ return options unless options[:selection] || options[:search_state]
144
+
145
+ adjusted = options.dup
146
+ adjusted[:scroll_col] = @scroll_col
147
+ adjusted
148
+ end
149
+
150
+ def max_cursor_col
151
+ [@buffer.line(@cursor_row).length - 1, 0].max
152
+ end
153
+
154
+ def clamp_cursor_col
155
+ @cursor_col = max_cursor_col if @cursor_col > max_cursor_col
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ class WindowManager
5
+ # Predicates for determining if a window is in a given direction from another
6
+ DIRECTION_PREDICATES = {
7
+ left: ->(w, active) { w.x + w.width <= active.x },
8
+ right: ->(w, active) { w.x >= active.x + active.width },
9
+ up: ->(w, active) { w.y + w.height <= active.y },
10
+ down: ->(w, active) { w.y >= active.y + active.height }
11
+ }.freeze
12
+
13
+ attr_reader :active_window, :layout_root
14
+
15
+ def initialize(screen, color_scheme: nil)
16
+ @screen = screen
17
+ @color_scheme = color_scheme
18
+ @active_window = nil
19
+ @layout_root = nil
20
+ @layout_calculator = Layout::Calculator.new
21
+ end
22
+
23
+ def add_window(buffer)
24
+ window = create_window(buffer)
25
+
26
+ @layout_root = Layout::LeafNode.new(window) if @layout_root.nil?
27
+
28
+ @active_window ||= window
29
+ update_layout
30
+ window
31
+ end
32
+
33
+ def remove_window(window)
34
+ return false if single_window?
35
+
36
+ node = @layout_root.find_window_node(window)
37
+ return false unless node
38
+
39
+ remove_node(node)
40
+
41
+ @active_window = windows.first if @active_window == window
42
+ update_layout
43
+ true
44
+ end
45
+
46
+ def split_horizontal(buffer = nil)
47
+ split(:horizontal, buffer)
48
+ end
49
+
50
+ def split_vertical(buffer = nil)
51
+ split(:vertical, buffer)
52
+ end
53
+
54
+ def close_current_window
55
+ return false if single_window?
56
+
57
+ current_node = @layout_root.find_window_node(@active_window)
58
+ return false unless current_node
59
+
60
+ remove_node(current_node)
61
+
62
+ @active_window = windows.first
63
+ update_layout
64
+ true
65
+ end
66
+
67
+ def close_all_except_current
68
+ return if single_window?
69
+
70
+ @layout_root = Layout::LeafNode.new(@active_window)
71
+ @layout_root.parent = nil
72
+ update_layout
73
+ end
74
+
75
+ def render_all(screen, selection: nil, search_state: nil)
76
+ windows.each do |window|
77
+ window_selection = window == @active_window ? selection : nil
78
+ window.render(screen, selection: window_selection, search_state:)
79
+ end
80
+ render_separators(screen)
81
+ end
82
+
83
+ def separators
84
+ return [] unless @layout_root.respond_to?(:separators)
85
+
86
+ @layout_root.separators
87
+ end
88
+
89
+ def update_sizes
90
+ update_layout
91
+ end
92
+
93
+ def focus_next
94
+ focus_cycle(1)
95
+ end
96
+
97
+ def focus_previous
98
+ focus_cycle(-1)
99
+ end
100
+
101
+ def focus_direction(direction)
102
+ return unless windows.size > 1
103
+
104
+ target = find_window_in_direction(direction)
105
+ @active_window = target if target
106
+ end
107
+
108
+ def windows
109
+ return [] unless @layout_root
110
+
111
+ @layout_root.windows
112
+ end
113
+
114
+ def window_count
115
+ windows.size
116
+ end
117
+
118
+ def single_window?
119
+ windows.size <= 1
120
+ end
121
+
122
+ def update_layout(y_offset: 0)
123
+ return unless @layout_root
124
+
125
+ @layout_calculator.calculate(
126
+ @layout_root, 0, y_offset, @screen.width, @screen.height - 1 - y_offset
127
+ )
128
+ end
129
+
130
+ private
131
+
132
+ def focus_cycle(offset)
133
+ all_windows = windows
134
+ return if all_windows.size <= 1
135
+
136
+ current_index = all_windows.index(@active_window) || 0
137
+ @active_window = all_windows[(current_index + offset) % all_windows.size]
138
+ end
139
+
140
+ def split(direction, buffer)
141
+ return nil unless @active_window
142
+
143
+ target_buffer = buffer || @active_window.buffer
144
+ new_window = create_window(target_buffer)
145
+
146
+ current_node = @layout_root.find_window_node(@active_window)
147
+ return nil unless current_node
148
+
149
+ parent = current_node.parent
150
+
151
+ new_leaf = Layout::LeafNode.new(new_window)
152
+ split_node = Layout::SplitNode.new(
153
+ direction:,
154
+ children: [current_node, new_leaf],
155
+ ratio: 0.5
156
+ )
157
+
158
+ if parent
159
+ parent.replace_child(current_node, split_node)
160
+ else
161
+ @layout_root = split_node
162
+ end
163
+
164
+ @active_window = new_window
165
+ update_layout
166
+ new_window
167
+ end
168
+
169
+ def remove_node(node)
170
+ parent = node.parent
171
+ return false unless parent
172
+
173
+ parent.remove_child(node)
174
+
175
+ if parent.children.size == 1
176
+ remaining_child = parent.children.first
177
+ grandparent = parent.parent
178
+
179
+ if grandparent
180
+ grandparent.replace_child(parent, remaining_child)
181
+ else
182
+ @layout_root = remaining_child
183
+ remaining_child.parent = nil
184
+ end
185
+ end
186
+
187
+ true
188
+ end
189
+
190
+ def find_window_in_direction(direction)
191
+ return nil unless @active_window
192
+
193
+ predicate = DIRECTION_PREDICATES[direction]
194
+ return nil unless predicate
195
+
196
+ current_x = @active_window.x + (@active_window.width / 2)
197
+ current_y = @active_window.y + (@active_window.height / 2)
198
+
199
+ candidates = windows.reject { |w| w == @active_window }
200
+ .select { |w| predicate.call(w, @active_window) }
201
+
202
+ candidates.min_by do |w|
203
+ wx = w.x + (w.width / 2)
204
+ wy = w.y + (w.height / 2)
205
+ Math.sqrt(((wx - current_x)**2) + ((wy - current_y)**2))
206
+ end
207
+ end
208
+
209
+ def create_window(buffer)
210
+ Window.new(
211
+ buffer,
212
+ x: 0,
213
+ y: 0,
214
+ width: @screen.width,
215
+ height: @screen.height,
216
+ color_scheme: @color_scheme
217
+ )
218
+ end
219
+
220
+ def render_separators(screen)
221
+ style = separator_style
222
+ separators.each do |sep|
223
+ case sep[:type]
224
+ when :horizontal
225
+ render_horizontal_separator(screen, sep, style)
226
+ when :vertical
227
+ render_vertical_separator(screen, sep, style)
228
+ end
229
+ end
230
+ end
231
+
232
+ def render_horizontal_separator(screen, sep, style)
233
+ line = "─" * sep[:length]
234
+ screen.put_with_style(sep[:y], sep[:x], line, style)
235
+ end
236
+
237
+ def render_vertical_separator(screen, sep, style)
238
+ sep[:length].times do |i|
239
+ screen.put_with_style(sep[:y] + i, sep[:x], "│", style)
240
+ end
241
+ end
242
+
243
+ def separator_style
244
+ return nil unless @color_scheme
245
+
246
+ @color_scheme[:separator]
247
+ end
248
+ end
249
+ end