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