mui 0.1.0 → 0.3.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 (110) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +163 -0
  3. data/CHANGELOG.md +448 -0
  4. data/README.md +309 -6
  5. data/docs/_config.yml +56 -0
  6. data/docs/configuration.md +301 -0
  7. data/docs/getting-started.md +140 -0
  8. data/docs/index.md +55 -0
  9. data/docs/jobs.md +297 -0
  10. data/docs/keybindings.md +229 -0
  11. data/docs/plugins.md +285 -0
  12. data/docs/syntax-highlighting.md +149 -0
  13. data/exe/mui +1 -2
  14. data/lib/mui/autocmd.rb +66 -0
  15. data/lib/mui/buffer.rb +275 -0
  16. data/lib/mui/buffer_word_cache.rb +131 -0
  17. data/lib/mui/buffer_word_completer.rb +77 -0
  18. data/lib/mui/color_manager.rb +136 -0
  19. data/lib/mui/color_scheme.rb +63 -0
  20. data/lib/mui/command_completer.rb +30 -0
  21. data/lib/mui/command_context.rb +90 -0
  22. data/lib/mui/command_history.rb +89 -0
  23. data/lib/mui/command_line.rb +167 -0
  24. data/lib/mui/command_registry.rb +44 -0
  25. data/lib/mui/completion_renderer.rb +84 -0
  26. data/lib/mui/completion_state.rb +58 -0
  27. data/lib/mui/config.rb +58 -0
  28. data/lib/mui/editor.rb +395 -0
  29. data/lib/mui/error.rb +29 -0
  30. data/lib/mui/file_completer.rb +51 -0
  31. data/lib/mui/floating_window.rb +161 -0
  32. data/lib/mui/handler_result.rb +107 -0
  33. data/lib/mui/highlight.rb +22 -0
  34. data/lib/mui/highlighters/base.rb +23 -0
  35. data/lib/mui/highlighters/search_highlighter.rb +27 -0
  36. data/lib/mui/highlighters/selection_highlighter.rb +48 -0
  37. data/lib/mui/highlighters/syntax_highlighter.rb +107 -0
  38. data/lib/mui/input.rb +17 -0
  39. data/lib/mui/insert_completion_renderer.rb +92 -0
  40. data/lib/mui/insert_completion_state.rb +77 -0
  41. data/lib/mui/job.rb +81 -0
  42. data/lib/mui/job_manager.rb +113 -0
  43. data/lib/mui/key_code.rb +30 -0
  44. data/lib/mui/key_handler/base.rb +187 -0
  45. data/lib/mui/key_handler/command_mode.rb +511 -0
  46. data/lib/mui/key_handler/insert_mode.rb +323 -0
  47. data/lib/mui/key_handler/motions/motion_handler.rb +56 -0
  48. data/lib/mui/key_handler/normal_mode.rb +552 -0
  49. data/lib/mui/key_handler/operators/base_operator.rb +134 -0
  50. data/lib/mui/key_handler/operators/change_operator.rb +179 -0
  51. data/lib/mui/key_handler/operators/delete_operator.rb +176 -0
  52. data/lib/mui/key_handler/operators/paste_operator.rb +119 -0
  53. data/lib/mui/key_handler/operators/yank_operator.rb +127 -0
  54. data/lib/mui/key_handler/search_mode.rb +191 -0
  55. data/lib/mui/key_handler/visual_line_mode.rb +20 -0
  56. data/lib/mui/key_handler/visual_mode.rb +402 -0
  57. data/lib/mui/key_handler/window_command.rb +112 -0
  58. data/lib/mui/key_handler.rb +16 -0
  59. data/lib/mui/key_notation_parser.rb +152 -0
  60. data/lib/mui/key_sequence.rb +67 -0
  61. data/lib/mui/key_sequence_buffer.rb +85 -0
  62. data/lib/mui/key_sequence_handler.rb +163 -0
  63. data/lib/mui/key_sequence_matcher.rb +79 -0
  64. data/lib/mui/layout/calculator.rb +15 -0
  65. data/lib/mui/layout/leaf_node.rb +33 -0
  66. data/lib/mui/layout/node.rb +29 -0
  67. data/lib/mui/layout/split_node.rb +132 -0
  68. data/lib/mui/line_renderer.rb +173 -0
  69. data/lib/mui/mode.rb +13 -0
  70. data/lib/mui/mode_manager.rb +186 -0
  71. data/lib/mui/motion.rb +139 -0
  72. data/lib/mui/plugin.rb +35 -0
  73. data/lib/mui/plugin_manager.rb +106 -0
  74. data/lib/mui/register.rb +110 -0
  75. data/lib/mui/screen.rb +103 -0
  76. data/lib/mui/search_completer.rb +50 -0
  77. data/lib/mui/search_input.rb +40 -0
  78. data/lib/mui/search_state.rb +121 -0
  79. data/lib/mui/selection.rb +55 -0
  80. data/lib/mui/status_line_renderer.rb +40 -0
  81. data/lib/mui/syntax/language_detector.rb +106 -0
  82. data/lib/mui/syntax/lexer_base.rb +106 -0
  83. data/lib/mui/syntax/lexers/c_lexer.rb +127 -0
  84. data/lib/mui/syntax/lexers/css_lexer.rb +121 -0
  85. data/lib/mui/syntax/lexers/go_lexer.rb +205 -0
  86. data/lib/mui/syntax/lexers/html_lexer.rb +118 -0
  87. data/lib/mui/syntax/lexers/javascript_lexer.rb +197 -0
  88. data/lib/mui/syntax/lexers/markdown_lexer.rb +210 -0
  89. data/lib/mui/syntax/lexers/ruby_lexer.rb +114 -0
  90. data/lib/mui/syntax/lexers/rust_lexer.rb +148 -0
  91. data/lib/mui/syntax/lexers/typescript_lexer.rb +203 -0
  92. data/lib/mui/syntax/token.rb +42 -0
  93. data/lib/mui/syntax/token_cache.rb +91 -0
  94. data/lib/mui/tab_bar_renderer.rb +87 -0
  95. data/lib/mui/tab_manager.rb +96 -0
  96. data/lib/mui/tab_page.rb +35 -0
  97. data/lib/mui/terminal_adapter/base.rb +92 -0
  98. data/lib/mui/terminal_adapter/curses.rb +164 -0
  99. data/lib/mui/terminal_adapter.rb +4 -0
  100. data/lib/mui/themes/default.rb +315 -0
  101. data/lib/mui/undo_manager.rb +83 -0
  102. data/lib/mui/undoable_action.rb +175 -0
  103. data/lib/mui/unicode_width.rb +100 -0
  104. data/lib/mui/version.rb +1 -1
  105. data/lib/mui/window.rb +201 -0
  106. data/lib/mui/window_manager.rb +256 -0
  107. data/lib/mui/wrap_cache.rb +40 -0
  108. data/lib/mui/wrap_helper.rb +84 -0
  109. data/lib/mui.rb +171 -2
  110. metadata +123 -5
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module KeyHandler
5
+ # Handles key inputs in Search mode (/ and ?)
6
+ class SearchMode < Base
7
+ attr_reader :completion_state
8
+
9
+ def initialize(mode_manager, buffer, search_input, search_state)
10
+ super(mode_manager, buffer)
11
+ @search_input = search_input
12
+ @search_state = search_state
13
+ @original_cursor_row = nil
14
+ @original_cursor_col = nil
15
+ @completion_state = CompletionState.new
16
+ @search_completer = SearchCompleter.new
17
+ end
18
+
19
+ def start_search
20
+ @original_cursor_row = cursor_row
21
+ @original_cursor_col = cursor_col
22
+ @completion_state.reset
23
+ end
24
+
25
+ def handle(key)
26
+ # Check plugin keymaps first
27
+ plugin_result = check_plugin_keymap(key, :search)
28
+ return plugin_result if plugin_result
29
+
30
+ case key
31
+ when KeyCode::ESCAPE
32
+ handle_escape
33
+ when KeyCode::BACKSPACE, Curses::KEY_BACKSPACE
34
+ handle_backspace
35
+ when KeyCode::ENTER_CR, KeyCode::ENTER_LF, Curses::KEY_ENTER
36
+ handle_enter
37
+ when KeyCode::TAB
38
+ handle_tab
39
+ when Curses::KEY_BTAB
40
+ handle_shift_tab
41
+ else
42
+ handle_character_input(key)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def handle_tab
49
+ return result unless @completion_state.active?
50
+
51
+ @completion_state.select_next
52
+ apply_current_completion
53
+ result
54
+ end
55
+
56
+ def handle_shift_tab
57
+ return result unless @completion_state.active?
58
+
59
+ @completion_state.select_previous
60
+ apply_current_completion
61
+ result
62
+ end
63
+
64
+ def apply_current_completion
65
+ candidate = @completion_state.current_candidate
66
+ return unless candidate
67
+
68
+ @search_input.clear
69
+ candidate.each_char { |c| @search_input.input(c) }
70
+ update_incremental_search
71
+ end
72
+
73
+ def handle_escape
74
+ @search_input.clear
75
+ @search_state.clear
76
+ @completion_state.reset
77
+ # Restore original cursor position
78
+ restore_cursor_position
79
+ result(mode: Mode::NORMAL, cancelled: true)
80
+ end
81
+
82
+ def restore_cursor_position
83
+ return unless @original_cursor_row && @original_cursor_col
84
+
85
+ window.cursor_row = @original_cursor_row
86
+ window.cursor_col = @original_cursor_col
87
+ end
88
+
89
+ def handle_backspace
90
+ if @search_input.empty?
91
+ @search_state.clear
92
+ @completion_state.reset
93
+ restore_cursor_position
94
+ result(mode: Mode::NORMAL, cancelled: true)
95
+ else
96
+ @search_input.backspace
97
+ update_incremental_search
98
+ update_completion
99
+ result
100
+ end
101
+ end
102
+
103
+ def handle_enter
104
+ @completion_state.reset
105
+ execute_search
106
+ end
107
+
108
+ def handle_character_input(key)
109
+ char = extract_printable_char(key)
110
+ if char
111
+ @search_input.input(char)
112
+ update_incremental_search
113
+ update_completion
114
+ end
115
+ result
116
+ end
117
+
118
+ def update_completion
119
+ prefix = @search_input.pattern
120
+ if prefix.empty?
121
+ @completion_state.reset
122
+ return
123
+ end
124
+
125
+ candidates = @search_completer.complete(buffer, prefix)
126
+ if candidates.empty?
127
+ @completion_state.reset
128
+ else
129
+ @completion_state.start(candidates, prefix, :search)
130
+ end
131
+ end
132
+
133
+ def update_incremental_search
134
+ pattern = @search_input.pattern
135
+ if pattern.empty?
136
+ @search_state.clear
137
+ restore_cursor_position
138
+ return
139
+ end
140
+
141
+ direction = @search_input.prompt == "/" ? :forward : :backward
142
+ @search_state.set_pattern(pattern, direction)
143
+
144
+ # Move cursor to first match from original position
145
+ matches = @search_state.find_all_matches(buffer)
146
+ return if matches.empty?
147
+
148
+ # Use original position if set, otherwise use current cursor position
149
+ search_row = @original_cursor_row || cursor_row
150
+ search_col = @original_cursor_col || cursor_col
151
+
152
+ match = if direction == :forward
153
+ @search_state.find_next(search_row, search_col, buffer:)
154
+ else
155
+ @search_state.find_previous(search_row, search_col, buffer:)
156
+ end
157
+
158
+ return unless match
159
+
160
+ window.cursor_row = match[:row]
161
+ window.cursor_col = match[:col]
162
+ end
163
+
164
+ def execute_search
165
+ pattern = @search_input.pattern
166
+ return result(mode: Mode::NORMAL, cancelled: true) if pattern.empty?
167
+
168
+ direction = @search_input.prompt == "/" ? :forward : :backward
169
+ @search_state.set_pattern(pattern, direction)
170
+
171
+ match = if direction == :forward
172
+ @search_state.find_next(cursor_row, cursor_col, buffer:)
173
+ else
174
+ @search_state.find_previous(cursor_row, cursor_col, buffer:)
175
+ end
176
+
177
+ if match
178
+ window.cursor_row = match[:row]
179
+ window.cursor_col = match[:col]
180
+ result(mode: Mode::NORMAL)
181
+ else
182
+ result(mode: Mode::NORMAL, message: "Pattern not found: #{pattern}")
183
+ end
184
+ end
185
+
186
+ def result(mode: nil, message: nil, quit: false, cancelled: false)
187
+ HandlerResult::SearchModeResult.new(mode:, message:, quit:, cancelled:)
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module KeyHandler
5
+ # Handler for line-wise visual mode (V)
6
+ class VisualLineMode < VisualMode
7
+ private
8
+
9
+ def handle_v_key
10
+ # v in visual line mode switches to visual mode
11
+ result(mode: Mode::VISUAL, toggle_line_mode: true)
12
+ end
13
+
14
+ def handle_upper_v_key
15
+ # V in visual line mode exits to normal mode
16
+ result(mode: Mode::NORMAL, clear_selection: true)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,402 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module KeyHandler
5
+ # Handler for character-wise visual mode (v)
6
+ class VisualMode < Base
7
+ include Motions::MotionHandler
8
+
9
+ attr_reader :selection
10
+
11
+ def initialize(mode_manager, buffer, selection, register = nil, undo_manager: nil)
12
+ super(mode_manager, buffer)
13
+ @selection = selection
14
+ @register = register || Register.new
15
+ @undo_manager = undo_manager
16
+ @pending_motion = nil
17
+ @pending_register = nil
18
+ end
19
+
20
+ def handle(key)
21
+ # Check plugin keymaps first (only when no pending motion)
22
+ unless @pending_motion
23
+ plugin_result = check_plugin_keymap(key, :visual)
24
+ return plugin_result if plugin_result
25
+ end
26
+
27
+ if @pending_motion
28
+ handle_pending_motion(key)
29
+ else
30
+ handle_visual_key(key)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def handle_visual_key(key)
37
+ case key
38
+ when KeyCode::ESCAPE
39
+ result(mode: Mode::NORMAL, clear_selection: true)
40
+ when "v"
41
+ handle_v_key
42
+ when "V"
43
+ handle_upper_v_key
44
+ when "h", Curses::KEY_LEFT
45
+ handle_move_left
46
+ when "j", Curses::KEY_DOWN
47
+ handle_move_down
48
+ when "k", Curses::KEY_UP
49
+ handle_move_up
50
+ when "l", Curses::KEY_RIGHT
51
+ handle_move_right
52
+ when "w"
53
+ handle_word_forward
54
+ when "b"
55
+ handle_word_backward
56
+ when "e"
57
+ handle_word_end
58
+ when "0"
59
+ handle_line_start
60
+ when "^"
61
+ handle_first_non_blank
62
+ when "$"
63
+ handle_line_end
64
+ when "g"
65
+ @pending_motion = :g
66
+ result
67
+ when "G"
68
+ handle_file_end
69
+ when "f"
70
+ @pending_motion = :f
71
+ result
72
+ when "F"
73
+ @pending_motion = :F
74
+ result
75
+ when "t"
76
+ @pending_motion = :t
77
+ result
78
+ when "T"
79
+ @pending_motion = :T
80
+ result
81
+ when "d"
82
+ handle_delete
83
+ when "c"
84
+ handle_change
85
+ when "y"
86
+ handle_yank
87
+ when ">"
88
+ handle_indent(:right)
89
+ when "<"
90
+ handle_indent(:left)
91
+ when '"'
92
+ @pending_motion = :register_select
93
+ result
94
+ when "*"
95
+ handle_search_selection(:forward)
96
+ when "#"
97
+ handle_search_selection(:backward)
98
+ else
99
+ result
100
+ end
101
+ end
102
+
103
+ def handle_v_key
104
+ # v in visual mode exits to normal mode
105
+ result(mode: Mode::NORMAL, clear_selection: true)
106
+ end
107
+
108
+ def handle_upper_v_key
109
+ # V in visual mode switches to visual line mode
110
+ result(mode: Mode::VISUAL_LINE, toggle_line_mode: true)
111
+ end
112
+
113
+ def handle_delete
114
+ range = @selection.normalized_range
115
+ if @selection.line_mode
116
+ delete_lines(range)
117
+ else
118
+ delete_range(range)
119
+ end
120
+ @pending_register = nil
121
+ result(mode: Mode::NORMAL, clear_selection: true)
122
+ end
123
+
124
+ def handle_change
125
+ range = @selection.normalized_range
126
+ if @selection.line_mode
127
+ change_lines(range)
128
+ else
129
+ undo_manager&.begin_group
130
+ change_range(range)
131
+ end
132
+ @pending_register = nil
133
+ result(mode: Mode::INSERT, clear_selection: true, group_started: true)
134
+ end
135
+
136
+ def handle_yank
137
+ range = @selection.normalized_range
138
+ if @selection.line_mode
139
+ yank_lines(range)
140
+ else
141
+ yank_range(range)
142
+ end
143
+ @pending_register = nil
144
+ self.cursor_row = range[:start_row]
145
+ self.cursor_col = range[:start_col]
146
+ result(mode: Mode::NORMAL, clear_selection: true)
147
+ end
148
+
149
+ def handle_search_selection(direction)
150
+ range = @selection.normalized_range
151
+ text = if @selection.line_mode
152
+ # For line mode, use the full line content (trimmed)
153
+ buffer.line(range[:start_row]).strip
154
+ else
155
+ extract_selection_text(range)
156
+ end
157
+
158
+ return result(mode: Mode::NORMAL, clear_selection: true) if text.empty?
159
+
160
+ # Escape special regex characters for literal search
161
+ escaped_pattern = Regexp.escape(text)
162
+
163
+ # Set search state
164
+ search_state = @mode_manager.search_state
165
+ search_state.set_pattern(escaped_pattern, direction)
166
+
167
+ # Find next/previous match from current position
168
+ match = if direction == :forward
169
+ search_state.find_next(cursor_row, cursor_col, buffer:)
170
+ else
171
+ search_state.find_previous(cursor_row, cursor_col, buffer:)
172
+ end
173
+
174
+ if match
175
+ window.cursor_row = match[:row]
176
+ window.cursor_col = match[:col]
177
+ result(mode: Mode::NORMAL, clear_selection: true)
178
+ else
179
+ result(mode: Mode::NORMAL, clear_selection: true, message: "Pattern not found: #{text}")
180
+ end
181
+ end
182
+
183
+ def yank_lines(range)
184
+ lines = (range[:start_row]..range[:end_row]).map { |r| buffer.line(r) }
185
+ @register.yank(lines.join("\n"), linewise: true, name: @pending_register)
186
+ end
187
+
188
+ def yank_range(range)
189
+ text = extract_selection_text(range)
190
+ @register.yank(text, linewise: false, name: @pending_register)
191
+ end
192
+
193
+ def extract_selection_text(range)
194
+ if range[:start_row] == range[:end_row]
195
+ buffer.line(range[:start_row])[range[:start_col]..range[:end_col]] || ""
196
+ else
197
+ lines = []
198
+ (range[:start_row]..range[:end_row]).each do |row|
199
+ line = buffer.line(row)
200
+ lines << if row == range[:start_row]
201
+ line[range[:start_col]..]
202
+ elsif row == range[:end_row]
203
+ line[0..range[:end_col]]
204
+ else
205
+ line
206
+ end
207
+ end
208
+ lines.join("\n")
209
+ end
210
+ end
211
+
212
+ def change_lines(range)
213
+ lines = (range[:start_row]..range[:end_row]).map { |r| buffer.line(r) }
214
+ @register.delete(lines.join("\n"), linewise: true, name: @pending_register)
215
+ undo_manager&.begin_group
216
+ (range[:end_row] - range[:start_row] + 1).times do
217
+ buffer.delete_line(range[:start_row])
218
+ end
219
+ buffer.insert_line(range[:start_row])
220
+ # NOTE: group will be closed when leaving Insert mode
221
+ self.cursor_row = range[:start_row]
222
+ self.cursor_col = 0
223
+ end
224
+
225
+ def change_range(range)
226
+ text = extract_selection_text(range)
227
+ @register.delete(text, linewise: false, name: @pending_register)
228
+ buffer.delete_range(range[:start_row], range[:start_col], range[:end_row], range[:end_col])
229
+ self.cursor_row = range[:start_row]
230
+ self.cursor_col = range[:start_col]
231
+ window.clamp_cursor_to_line(buffer)
232
+ end
233
+
234
+ def delete_lines(range)
235
+ lines = (range[:start_row]..range[:end_row]).map { |r| buffer.line(r) }
236
+ @register.delete(lines.join("\n"), linewise: true, name: @pending_register)
237
+ undo_manager&.begin_group unless undo_manager&.in_group?
238
+ (range[:end_row] - range[:start_row] + 1).times do
239
+ buffer.delete_line(range[:start_row])
240
+ end
241
+ undo_manager&.end_group
242
+ self.cursor_row = [range[:start_row], buffer.line_count - 1].min
243
+ self.cursor_col = 0
244
+ window.clamp_cursor_to_line(buffer)
245
+ end
246
+
247
+ def delete_range(range)
248
+ text = extract_selection_text(range)
249
+ @register.delete(text, linewise: false, name: @pending_register)
250
+ buffer.delete_range(range[:start_row], range[:start_col], range[:end_row], range[:end_col])
251
+ self.cursor_row = range[:start_row]
252
+ self.cursor_col = range[:start_col]
253
+ window.clamp_cursor_to_line(buffer)
254
+ end
255
+
256
+ def handle_indent(direction)
257
+ range = @selection.normalized_range
258
+ indent_lines(range[:start_row], range[:end_row], direction)
259
+
260
+ # Move cursor to the beginning of the first selected line (Vim behavior)
261
+ self.cursor_row = range[:start_row]
262
+ self.cursor_col = 0
263
+
264
+ if Mui.config.get(:reselect_after_indent)
265
+ # Keep selection for continuous indent adjustment
266
+ @selection.update_end(range[:end_row], buffer.line(range[:end_row]).length)
267
+ result
268
+ else
269
+ result(mode: Mode::NORMAL, clear_selection: true)
270
+ end
271
+ end
272
+
273
+ def indent_lines(start_row, end_row, direction)
274
+ indent_string = build_indent_string
275
+
276
+ undo_manager&.begin_group unless undo_manager&.in_group?
277
+
278
+ (start_row..end_row).each do |row|
279
+ if direction == :right
280
+ add_indent(row, indent_string)
281
+ else
282
+ remove_indent(row, Mui.config.get(:shiftwidth))
283
+ end
284
+ end
285
+
286
+ undo_manager&.end_group
287
+ end
288
+
289
+ def build_indent_string
290
+ if Mui.config.get(:expandtab)
291
+ " " * Mui.config.get(:shiftwidth)
292
+ else
293
+ "\t"
294
+ end
295
+ end
296
+
297
+ def add_indent(row, indent_string)
298
+ return if buffer.line(row).empty? # Skip empty lines
299
+
300
+ indent_string.reverse.each_char do |char|
301
+ buffer.insert_char(row, 0, char)
302
+ end
303
+ end
304
+
305
+ def remove_indent(row, width)
306
+ line = buffer.line(row)
307
+ return if line.empty? # Skip empty lines
308
+
309
+ removed = 0
310
+
311
+ while removed < width && !line.empty?
312
+ char = line[0]
313
+ break unless [" ", "\t"].include?(char)
314
+
315
+ char_width = char == "\t" ? Mui.config.get(:tabstop) : 1
316
+ break if removed + char_width > width && char == "\t"
317
+
318
+ buffer.delete_char(row, 0)
319
+ removed += char_width
320
+ line = buffer.line(row)
321
+ end
322
+ end
323
+
324
+ def handle_pending_motion(key)
325
+ char = key_to_char(key)
326
+ return clear_pending unless char
327
+
328
+ return handle_register_select(char) if @pending_motion == :register_select
329
+
330
+ motion_result = execute_pending_motion(char)
331
+ apply_motion(motion_result) if motion_result
332
+ clear_pending
333
+ end
334
+
335
+ def handle_register_select(char)
336
+ if valid_register_name?(char)
337
+ @pending_register = char
338
+ @pending_motion = nil
339
+ result
340
+ else
341
+ clear_pending
342
+ end
343
+ end
344
+
345
+ def valid_register_name?(char)
346
+ Register::NAMED_REGISTERS.include?(char) ||
347
+ Register::DELETE_HISTORY_REGISTERS.include?(char) ||
348
+ [Register::YANK_REGISTER, Register::BLACK_HOLE_REGISTER, Register::UNNAMED_REGISTER].include?(char)
349
+ end
350
+
351
+ def clear_pending
352
+ @pending_motion = nil
353
+ @pending_register = nil
354
+ result
355
+ end
356
+
357
+ def handle_move_left
358
+ window.move_left
359
+ update_selection
360
+ result
361
+ end
362
+
363
+ def handle_move_down
364
+ window.move_down
365
+ update_selection
366
+ result
367
+ end
368
+
369
+ def handle_move_up
370
+ window.move_up
371
+ update_selection
372
+ result
373
+ end
374
+
375
+ def handle_move_right
376
+ window.move_right
377
+ update_selection
378
+ result
379
+ end
380
+
381
+ def apply_motion(motion_result)
382
+ super
383
+ update_selection if motion_result
384
+ end
385
+
386
+ def update_selection
387
+ @selection.update_end(cursor_row, cursor_col)
388
+ end
389
+
390
+ def result(mode: nil, message: nil, quit: false, clear_selection: false, toggle_line_mode: false, group_started: false)
391
+ HandlerResult::VisualModeResult.new(
392
+ mode:,
393
+ message:,
394
+ quit:,
395
+ clear_selection:,
396
+ toggle_line_mode:,
397
+ group_started:
398
+ )
399
+ end
400
+ end
401
+ end
402
+ end