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,552 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module KeyHandler
5
+ # Handles key inputs in Normal mode
6
+ class NormalMode < Base
7
+ include Motions::MotionHandler
8
+
9
+ def initialize(mode_manager, buffer, register = nil, undo_manager: nil, search_state: nil)
10
+ super(mode_manager, buffer)
11
+ @register = register || Register.new
12
+ @undo_manager = undo_manager
13
+ @search_state = search_state
14
+ @pending_motion = nil
15
+ @pending_register = nil
16
+ @window_command = nil
17
+ initialize_operators
18
+ end
19
+
20
+ def handle(key)
21
+ # Sync operators with current buffer/window (may have changed via tab switch)
22
+ sync_operators
23
+
24
+ # Check plugin keymaps first
25
+ # Skip if: has pending motion OR (is builtin key AND no pending sequence)
26
+ unless @pending_motion || (builtin_operator_key?(key) && !pending_sequence?)
27
+ plugin_result = check_plugin_keymap(key, :normal)
28
+ return plugin_result if plugin_result
29
+ end
30
+
31
+ if @pending_motion
32
+ handle_pending_motion(key)
33
+ else
34
+ handle_normal_key(key)
35
+ end
36
+ end
37
+
38
+ # Check if there's a pending multi-key sequence in progress
39
+ def pending_sequence?
40
+ key_sequence_handler&.pending?
41
+ end
42
+
43
+ # Check if key starts a built-in operator (takes priority over plugins)
44
+ def builtin_operator_key?(key)
45
+ char = key_to_char(key)
46
+ return false unless char
47
+
48
+ # Built-in operators and motions that set pending_motion
49
+ %w[d c y g f F t T].include?(char) || key == KeyCode::CTRL_W
50
+ end
51
+
52
+ private
53
+
54
+ def initialize_operators
55
+ @operators = {
56
+ delete: Operators::DeleteOperator.new(
57
+ buffer:, window:, register: @register, undo_manager: @undo_manager
58
+ ),
59
+ change: Operators::ChangeOperator.new(
60
+ buffer:, window:, register: @register, undo_manager: @undo_manager
61
+ ),
62
+ yank: Operators::YankOperator.new(
63
+ buffer:, window:, register: @register
64
+ ),
65
+ paste: Operators::PasteOperator.new(
66
+ buffer:, window:, register: @register
67
+ )
68
+ }
69
+ end
70
+
71
+ def sync_operators
72
+ @operators.each_value { |op| op.update(buffer:, window:, undo_manager:) }
73
+ end
74
+
75
+ def handle_normal_key(key)
76
+ case key
77
+ when "h", Curses::KEY_LEFT
78
+ handle_move_left
79
+ when "j", Curses::KEY_DOWN
80
+ handle_move_down
81
+ when "k", Curses::KEY_UP
82
+ handle_move_up
83
+ when "l", Curses::KEY_RIGHT
84
+ handle_move_right
85
+ when "w"
86
+ handle_word_forward
87
+ when "b"
88
+ handle_word_backward
89
+ when "e"
90
+ handle_word_end
91
+ when "0"
92
+ handle_line_start
93
+ when "^"
94
+ handle_first_non_blank
95
+ when "$"
96
+ handle_line_end
97
+ when "g"
98
+ @pending_motion = :g
99
+ result
100
+ when "G"
101
+ handle_file_end
102
+ when "f"
103
+ @pending_motion = :f
104
+ result
105
+ when "F"
106
+ @pending_motion = :F
107
+ result
108
+ when "t"
109
+ @pending_motion = :t
110
+ result
111
+ when "T"
112
+ @pending_motion = :T
113
+ result
114
+ when "i"
115
+ return readonly_error if buffer.readonly?
116
+
117
+ result(mode: Mode::INSERT)
118
+ when "a"
119
+ return readonly_error if buffer.readonly?
120
+
121
+ handle_append
122
+ when "o"
123
+ return readonly_error if buffer.readonly?
124
+
125
+ handle_open_below
126
+ when "O"
127
+ return readonly_error if buffer.readonly?
128
+
129
+ handle_open_above
130
+ when "x"
131
+ return readonly_error if buffer.readonly?
132
+
133
+ handle_delete_char
134
+ when "d"
135
+ return readonly_error if buffer.readonly?
136
+
137
+ @pending_motion = :d
138
+ result
139
+ when "c"
140
+ return readonly_error if buffer.readonly?
141
+
142
+ @pending_motion = :c
143
+ result
144
+ when "y"
145
+ @pending_motion = :y
146
+ result
147
+ when "p"
148
+ return readonly_error if buffer.readonly?
149
+
150
+ handle_paste_after
151
+ when "P"
152
+ return readonly_error if buffer.readonly?
153
+
154
+ handle_paste_before
155
+ when ":"
156
+ result(mode: Mode::COMMAND)
157
+ when "v"
158
+ result(mode: Mode::VISUAL, start_selection: true)
159
+ when "V"
160
+ result(mode: Mode::VISUAL_LINE, start_selection: true, line_mode: true)
161
+ when '"'
162
+ @pending_motion = :register_select
163
+ result
164
+ when "u"
165
+ handle_undo
166
+ when 18 # Ctrl-r
167
+ handle_redo
168
+ when "/"
169
+ result(mode: Mode::SEARCH_FORWARD)
170
+ when "?"
171
+ result(mode: Mode::SEARCH_BACKWARD)
172
+ when "n"
173
+ handle_search_next
174
+ when "N"
175
+ handle_search_previous
176
+ when "*"
177
+ handle_search_word(:forward)
178
+ when "#"
179
+ handle_search_word(:backward)
180
+ when KeyCode::CTRL_W
181
+ @pending_motion = :window_command
182
+ result
183
+ else
184
+ result
185
+ end
186
+ end
187
+
188
+ def handle_pending_motion(key)
189
+ # Window command doesn't need char conversion
190
+ return dispatch_window_command(key) if @pending_motion == :window_command
191
+
192
+ char = key_to_char(key)
193
+ return clear_pending unless char
194
+
195
+ case @pending_motion
196
+ when :register_select
197
+ handle_register_select(char)
198
+ when :g
199
+ dispatch_g_command(char)
200
+ when :d
201
+ dispatch_delete_operator(char)
202
+ when :dg
203
+ dispatch_delete_to_file_start(char)
204
+ when :df, :dF, :dt, :dT
205
+ dispatch_delete_find_char(char)
206
+ when :c
207
+ dispatch_change_operator(char)
208
+ when :cg
209
+ dispatch_change_to_file_start(char)
210
+ when :cf, :cF, :ct, :cT
211
+ dispatch_change_find_char(char)
212
+ when :y
213
+ dispatch_yank_operator(char)
214
+ when :yg
215
+ dispatch_yank_to_file_start(char)
216
+ when :yf, :yF, :yt, :yT
217
+ dispatch_yank_find_char(char)
218
+ else
219
+ motion_result = execute_pending_motion(char)
220
+ apply_motion(motion_result) if motion_result
221
+ clear_pending
222
+ end
223
+ end
224
+
225
+ def clear_pending
226
+ @pending_motion = nil
227
+ @pending_register = nil
228
+ result
229
+ end
230
+
231
+ def handle_register_select(char)
232
+ if valid_register_name?(char)
233
+ @pending_register = char
234
+ @pending_motion = nil
235
+ result
236
+ else
237
+ clear_pending
238
+ end
239
+ end
240
+
241
+ def valid_register_name?(char)
242
+ Register::NAMED_REGISTERS.include?(char) ||
243
+ Register::DELETE_HISTORY_REGISTERS.include?(char) ||
244
+ [Register::YANK_REGISTER, Register::BLACK_HOLE_REGISTER, Register::UNNAMED_REGISTER].include?(char)
245
+ end
246
+
247
+ # Movement handlers
248
+ def handle_move_left
249
+ window.move_left
250
+ result
251
+ end
252
+
253
+ def handle_move_down
254
+ window.move_down
255
+ result
256
+ end
257
+
258
+ def handle_move_up
259
+ window.move_up
260
+ result
261
+ end
262
+
263
+ def handle_move_right
264
+ window.move_right
265
+ result
266
+ end
267
+
268
+ # Edit handlers
269
+ def handle_append
270
+ self.cursor_col = cursor_col + 1 if current_line_length.positive?
271
+ result(mode: Mode::INSERT)
272
+ end
273
+
274
+ def handle_open_below
275
+ undo_manager&.begin_group
276
+ buffer.insert_line(cursor_row + 1)
277
+ self.cursor_row = cursor_row + 1
278
+ self.cursor_col = 0
279
+ result(mode: Mode::INSERT, group_started: true)
280
+ end
281
+
282
+ def handle_open_above
283
+ undo_manager&.begin_group
284
+ buffer.insert_line(cursor_row)
285
+ self.cursor_col = 0
286
+ result(mode: Mode::INSERT, group_started: true)
287
+ end
288
+
289
+ def handle_delete_char
290
+ buffer.delete_char(cursor_row, cursor_col)
291
+ result
292
+ end
293
+
294
+ # Delete operator dispatchers
295
+ def dispatch_delete_operator(char)
296
+ status = @operators[:delete].handle_pending(char, pending_register: @pending_register)
297
+ handle_operator_result(status)
298
+ end
299
+
300
+ def dispatch_delete_to_file_start(char)
301
+ status = @operators[:delete].handle_to_file_start(char)
302
+ handle_operator_result(status)
303
+ end
304
+
305
+ def dispatch_delete_find_char(char)
306
+ status = @operators[:delete].handle_find_char(char, @pending_motion)
307
+ handle_operator_result(status)
308
+ end
309
+
310
+ # Change operator dispatchers
311
+ def dispatch_change_operator(char)
312
+ status = @operators[:change].handle_pending(char, pending_register: @pending_register)
313
+ handle_operator_result(status)
314
+ end
315
+
316
+ def dispatch_change_to_file_start(char)
317
+ status = @operators[:change].handle_to_file_start(char)
318
+ handle_operator_result(status)
319
+ end
320
+
321
+ def dispatch_change_find_char(char)
322
+ status = @operators[:change].handle_find_char(char, @pending_motion)
323
+ handle_operator_result(status)
324
+ end
325
+
326
+ # Yank operator dispatchers
327
+ def dispatch_yank_operator(char)
328
+ status = @operators[:yank].handle_pending(char, pending_register: @pending_register)
329
+ handle_operator_result(status)
330
+ end
331
+
332
+ def dispatch_yank_to_file_start(char)
333
+ status = @operators[:yank].handle_to_file_start(char)
334
+ handle_operator_result(status)
335
+ end
336
+
337
+ def dispatch_yank_find_char(char)
338
+ status = @operators[:yank].handle_find_char(char, @pending_motion)
339
+ handle_operator_result(status)
340
+ end
341
+
342
+ # Paste handlers (delegate to operator)
343
+ def handle_paste_after
344
+ @operators[:paste].paste_after(pending_register: @pending_register)
345
+ @pending_register = nil
346
+ result
347
+ end
348
+
349
+ def handle_paste_before
350
+ @operators[:paste].paste_before(pending_register: @pending_register)
351
+ @pending_register = nil
352
+ result
353
+ end
354
+
355
+ def handle_operator_result(status)
356
+ case status
357
+ when :insert_mode
358
+ @pending_motion = nil
359
+ @pending_register = nil
360
+ result(mode: Mode::INSERT)
361
+ when /^pending_/
362
+ @pending_motion = status.to_s.sub("pending_", "").to_sym
363
+ result
364
+ else
365
+ # :done, :cancel, or any other status
366
+ clear_pending
367
+ end
368
+ end
369
+
370
+ def result(mode: nil, message: nil, quit: false, start_selection: false, line_mode: false, group_started: false)
371
+ HandlerResult::NormalModeResult.new(
372
+ mode:,
373
+ message:,
374
+ quit:,
375
+ start_selection:,
376
+ line_mode:,
377
+ group_started:
378
+ )
379
+ end
380
+
381
+ def readonly_error
382
+ result(message: "E21: Cannot make changes, buffer is readonly")
383
+ end
384
+
385
+ # Undo/Redo handlers
386
+ def handle_undo
387
+ if undo_manager&.undo(buffer)
388
+ window.clamp_cursor_to_line(buffer)
389
+ result
390
+ else
391
+ result(message: "Already at oldest change")
392
+ end
393
+ end
394
+
395
+ def handle_redo
396
+ if undo_manager&.redo(buffer)
397
+ window.clamp_cursor_to_line(buffer)
398
+ result
399
+ else
400
+ result(message: "Already at newest change")
401
+ end
402
+ end
403
+
404
+ # Window command dispatcher
405
+ def dispatch_window_command(key)
406
+ window_manager = @mode_manager&.window_manager
407
+ unless window_manager
408
+ @pending_motion = nil
409
+ return result(message: "Window commands not available")
410
+ end
411
+
412
+ @window_command ||= WindowCommand.new(window_manager)
413
+ @window_command.handle(key)
414
+ clear_pending
415
+ end
416
+
417
+ # g command dispatcher (gg, gt, gT)
418
+ def dispatch_g_command(char)
419
+ case char
420
+ when "g"
421
+ # gg - go to file start
422
+ motion_result = Motion.file_start(buffer, cursor_row, cursor_col)
423
+ apply_motion(motion_result) if motion_result
424
+ clear_pending
425
+ when "t"
426
+ # gt - next tab
427
+ handle_tab_next
428
+ when "T"
429
+ # gT - previous tab
430
+ handle_tab_prev
431
+ when "v"
432
+ # gv - restore last visual selection
433
+ handle_restore_visual
434
+ else
435
+ clear_pending
436
+ end
437
+ end
438
+
439
+ def handle_restore_visual
440
+ @pending_motion = nil
441
+ if @mode_manager.last_visual_selection
442
+ @mode_manager.restore_visual_selection
443
+ line_mode = @mode_manager.last_visual_selection[:line_mode]
444
+ result(mode: line_mode ? Mode::VISUAL_LINE : Mode::VISUAL)
445
+ else
446
+ result(message: "No previous visual selection")
447
+ end
448
+ end
449
+
450
+ def handle_tab_next
451
+ tab_manager = @mode_manager&.editor&.tab_manager
452
+ unless tab_manager
453
+ @pending_motion = nil
454
+ return result(message: "Tab commands not available")
455
+ end
456
+
457
+ tab_manager.next_tab
458
+ clear_pending
459
+ end
460
+
461
+ def handle_tab_prev
462
+ tab_manager = @mode_manager&.editor&.tab_manager
463
+ unless tab_manager
464
+ @pending_motion = nil
465
+ return result(message: "Tab commands not available")
466
+ end
467
+
468
+ tab_manager.prev_tab
469
+ clear_pending
470
+ end
471
+
472
+ # Search handlers
473
+ def handle_search_next
474
+ return result(message: "No previous search pattern") unless @search_state&.has_pattern?
475
+
476
+ match = if @search_state.direction == :forward
477
+ @search_state.find_next(cursor_row, cursor_col, buffer:)
478
+ else
479
+ @search_state.find_previous(cursor_row, cursor_col, buffer:)
480
+ end
481
+
482
+ if match
483
+ apply_motion(match)
484
+ result
485
+ else
486
+ result(message: "Pattern not found: #{@search_state.pattern}")
487
+ end
488
+ end
489
+
490
+ def handle_search_previous
491
+ return result(message: "No previous search pattern") unless @search_state&.has_pattern?
492
+
493
+ match = if @search_state.direction == :forward
494
+ @search_state.find_previous(cursor_row, cursor_col, buffer:)
495
+ else
496
+ @search_state.find_next(cursor_row, cursor_col, buffer:)
497
+ end
498
+
499
+ if match
500
+ apply_motion(match)
501
+ result
502
+ else
503
+ result(message: "Pattern not found: #{@search_state.pattern}")
504
+ end
505
+ end
506
+
507
+ def handle_search_word(direction)
508
+ word = word_under_cursor
509
+ return result if word.nil? || word.empty?
510
+
511
+ # Use word boundary for whole word matching (Vim behavior)
512
+ escaped_pattern = "\\b#{Regexp.escape(word)}\\b"
513
+
514
+ @search_state.set_pattern(escaped_pattern, direction)
515
+
516
+ # Find next/previous match from current position
517
+ match = if direction == :forward
518
+ @search_state.find_next(cursor_row, cursor_col, buffer:)
519
+ else
520
+ @search_state.find_previous(cursor_row, cursor_col, buffer:)
521
+ end
522
+
523
+ if match
524
+ apply_motion(match)
525
+ result
526
+ else
527
+ result(message: "Pattern not found: #{word}")
528
+ end
529
+ end
530
+
531
+ def word_under_cursor
532
+ line = buffer.line(cursor_row)
533
+ return nil if line.nil? || line.empty?
534
+
535
+ col = cursor_col
536
+ return nil if col >= line.length
537
+
538
+ # Check if cursor is on a word character
539
+ return nil unless line[col]&.match?(/\w/)
540
+
541
+ # Find word boundaries
542
+ start_col = col
543
+ start_col -= 1 while start_col.positive? && line[start_col - 1]&.match?(/\w/)
544
+
545
+ end_col = col
546
+ end_col += 1 while end_col < line.length && line[end_col]&.match?(/\w/)
547
+
548
+ line[start_col...end_col]
549
+ end
550
+ end
551
+ end
552
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mui
4
+ module KeyHandler
5
+ module Operators
6
+ # Base class for operator implementations (delete, change, yank, paste)
7
+ # Provides common functionality for text extraction, deletion, and cursor management
8
+ class BaseOperator
9
+ def initialize(buffer:, window:, register:, undo_manager: nil)
10
+ @buffer = buffer
11
+ @window = window
12
+ @register = register
13
+ @undo_manager = undo_manager
14
+ end
15
+
16
+ # Update dependencies (called when window changes)
17
+ def update(buffer: nil, window: nil, register: nil, undo_manager: nil)
18
+ @buffer = buffer if buffer
19
+ @window = window if window
20
+ @register = register if register
21
+ @undo_manager = undo_manager if undo_manager
22
+ end
23
+
24
+ # Handle pending operator motion
25
+ def handle_pending(_char, pending_register: nil) # rubocop:disable Lint/UnusedMethodArgument
26
+ raise Mui::MethodNotOverriddenError, :handle_pending
27
+ end
28
+
29
+ protected
30
+
31
+ attr_reader :buffer, :window, :register, :undo_manager
32
+
33
+ # Cursor accessors
34
+ def cursor_row
35
+ @window.cursor_row
36
+ end
37
+
38
+ def cursor_col
39
+ @window.cursor_col
40
+ end
41
+
42
+ def cursor_row=(value)
43
+ @window.cursor_row = value
44
+ end
45
+
46
+ def cursor_col=(value)
47
+ @window.cursor_col = value
48
+ end
49
+
50
+ # Text extraction methods
51
+ def extract_text(start_pos, end_pos, inclusive: false)
52
+ if start_pos[:row] == end_pos[:row]
53
+ extract_text_same_line(start_pos, end_pos, inclusive:)
54
+ else
55
+ extract_text_across_lines(start_pos, end_pos, inclusive:)
56
+ end
57
+ end
58
+
59
+ def extract_text_same_line(start_pos, end_pos, inclusive: false)
60
+ from_col = [start_pos[:col], end_pos[:col]].min
61
+ to_col = [start_pos[:col], end_pos[:col]].max
62
+ to_col -= 1 unless inclusive
63
+ return "" if to_col < from_col
64
+
65
+ @buffer.line(start_pos[:row])[from_col..to_col] || ""
66
+ end
67
+
68
+ def extract_text_across_lines(start_pos, end_pos, inclusive: false)
69
+ from_row, to_row = [start_pos[:row], end_pos[:row]].minmax
70
+ from_col = from_row == start_pos[:row] ? start_pos[:col] : end_pos[:col]
71
+ to_col = to_row == end_pos[:row] ? end_pos[:col] : start_pos[:col]
72
+ to_col -= 1 unless inclusive
73
+
74
+ lines = []
75
+ (from_row..to_row).each do |row|
76
+ line = @buffer.line(row)
77
+ lines << if row == from_row
78
+ line[from_col..]
79
+ elsif row == to_row
80
+ line[0..to_col]
81
+ else
82
+ line
83
+ end
84
+ end
85
+ lines.join("\n")
86
+ end
87
+
88
+ # Motion calculation
89
+ def calculate_motion_end(motion_type)
90
+ case motion_type
91
+ when :word_forward
92
+ Motion.word_forward(@buffer, cursor_row, cursor_col)
93
+ when :word_end
94
+ Motion.word_end(@buffer, cursor_row, cursor_col)
95
+ when :word_backward
96
+ Motion.word_backward(@buffer, cursor_row, cursor_col)
97
+ end
98
+ end
99
+
100
+ # Delete execution methods (used by delete and change operators)
101
+ def execute_delete(start_pos, end_pos, inclusive: false, clamp: true)
102
+ if start_pos[:row] == end_pos[:row]
103
+ execute_delete_same_line(start_pos, end_pos, inclusive:, clamp:)
104
+ else
105
+ execute_delete_across_lines(start_pos, end_pos, inclusive:, clamp:)
106
+ end
107
+ end
108
+
109
+ def execute_delete_same_line(start_pos, end_pos, inclusive: false, clamp: true)
110
+ from_col = [start_pos[:col], end_pos[:col]].min
111
+ to_col = [start_pos[:col], end_pos[:col]].max
112
+ to_col -= 1 unless inclusive
113
+ return if to_col < from_col
114
+
115
+ @buffer.delete_range(start_pos[:row], from_col, start_pos[:row], to_col)
116
+ self.cursor_col = from_col
117
+ @window.clamp_cursor_to_line(@buffer) if clamp
118
+ end
119
+
120
+ def execute_delete_across_lines(start_pos, end_pos, inclusive: false, clamp: true)
121
+ from_row, to_row = [start_pos[:row], end_pos[:row]].minmax
122
+ from_col = from_row == start_pos[:row] ? start_pos[:col] : end_pos[:col]
123
+ to_col = to_row == end_pos[:row] ? end_pos[:col] : start_pos[:col]
124
+ to_col -= 1 unless inclusive
125
+
126
+ @buffer.delete_range(from_row, from_col, to_row, to_col)
127
+ self.cursor_row = from_row
128
+ self.cursor_col = from_col
129
+ @window.clamp_cursor_to_line(@buffer) if clamp
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end