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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +158 -0
- data/CHANGELOG.md +349 -0
- data/exe/mui +1 -2
- data/lib/mui/autocmd.rb +66 -0
- data/lib/mui/buffer.rb +275 -0
- data/lib/mui/buffer_word_cache.rb +131 -0
- data/lib/mui/buffer_word_completer.rb +77 -0
- data/lib/mui/color_manager.rb +136 -0
- data/lib/mui/color_scheme.rb +63 -0
- data/lib/mui/command_completer.rb +21 -0
- data/lib/mui/command_context.rb +90 -0
- data/lib/mui/command_line.rb +137 -0
- data/lib/mui/command_registry.rb +25 -0
- data/lib/mui/completion_renderer.rb +84 -0
- data/lib/mui/completion_state.rb +58 -0
- data/lib/mui/config.rb +56 -0
- data/lib/mui/editor.rb +319 -0
- data/lib/mui/error.rb +29 -0
- data/lib/mui/file_completer.rb +51 -0
- data/lib/mui/floating_window.rb +161 -0
- data/lib/mui/handler_result.rb +101 -0
- data/lib/mui/highlight.rb +22 -0
- data/lib/mui/highlighters/base.rb +23 -0
- data/lib/mui/highlighters/search_highlighter.rb +26 -0
- data/lib/mui/highlighters/selection_highlighter.rb +48 -0
- data/lib/mui/highlighters/syntax_highlighter.rb +105 -0
- data/lib/mui/input.rb +17 -0
- data/lib/mui/insert_completion_renderer.rb +92 -0
- data/lib/mui/insert_completion_state.rb +77 -0
- data/lib/mui/job.rb +81 -0
- data/lib/mui/job_manager.rb +113 -0
- data/lib/mui/key_code.rb +30 -0
- data/lib/mui/key_handler/base.rb +100 -0
- data/lib/mui/key_handler/command_mode.rb +443 -0
- data/lib/mui/key_handler/insert_mode.rb +354 -0
- data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
- data/lib/mui/key_handler/normal_mode.rb +579 -0
- data/lib/mui/key_handler/operators/base_operator.rb +134 -0
- data/lib/mui/key_handler/operators/change_operator.rb +179 -0
- data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
- data/lib/mui/key_handler/operators/paste_operator.rb +113 -0
- data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
- data/lib/mui/key_handler/search_mode.rb +188 -0
- data/lib/mui/key_handler/visual_line_mode.rb +20 -0
- data/lib/mui/key_handler/visual_mode.rb +397 -0
- data/lib/mui/key_handler/window_command.rb +112 -0
- data/lib/mui/key_handler.rb +16 -0
- data/lib/mui/layout/calculator.rb +15 -0
- data/lib/mui/layout/leaf_node.rb +33 -0
- data/lib/mui/layout/node.rb +29 -0
- data/lib/mui/layout/split_node.rb +132 -0
- data/lib/mui/line_renderer.rb +122 -0
- data/lib/mui/mode.rb +13 -0
- data/lib/mui/mode_manager.rb +185 -0
- data/lib/mui/motion.rb +139 -0
- data/lib/mui/plugin.rb +35 -0
- data/lib/mui/plugin_manager.rb +106 -0
- data/lib/mui/register.rb +110 -0
- data/lib/mui/screen.rb +85 -0
- data/lib/mui/search_completer.rb +50 -0
- data/lib/mui/search_input.rb +40 -0
- data/lib/mui/search_state.rb +88 -0
- data/lib/mui/selection.rb +55 -0
- data/lib/mui/status_line_renderer.rb +40 -0
- data/lib/mui/syntax/language_detector.rb +74 -0
- data/lib/mui/syntax/lexer_base.rb +106 -0
- data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
- data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
- data/lib/mui/syntax/token.rb +42 -0
- data/lib/mui/syntax/token_cache.rb +91 -0
- data/lib/mui/tab_bar_renderer.rb +87 -0
- data/lib/mui/tab_manager.rb +96 -0
- data/lib/mui/tab_page.rb +35 -0
- data/lib/mui/terminal_adapter/base.rb +92 -0
- data/lib/mui/terminal_adapter/curses.rb +162 -0
- data/lib/mui/terminal_adapter.rb +4 -0
- data/lib/mui/themes/default.rb +315 -0
- data/lib/mui/undo_manager.rb +83 -0
- data/lib/mui/undoable_action.rb +175 -0
- data/lib/mui/unicode_width.rb +100 -0
- data/lib/mui/version.rb +1 -1
- data/lib/mui/window.rb +158 -0
- data/lib/mui/window_manager.rb +249 -0
- data/lib/mui.rb +156 -2
- 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
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
|