ruvim 0.3.0 → 0.6.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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +68 -7
  3. data/README.md +30 -7
  4. data/Rakefile +7 -0
  5. data/benchmark/cext_compare.rb +165 -0
  6. data/benchmark/chunked_load.rb +256 -0
  7. data/benchmark/file_load.rb +140 -0
  8. data/benchmark/hotspots.rb +178 -0
  9. data/docs/binding.md +18 -1
  10. data/docs/command.md +156 -10
  11. data/docs/config.md +10 -2
  12. data/docs/done.md +23 -0
  13. data/docs/spec.md +162 -25
  14. data/docs/todo.md +9 -0
  15. data/docs/tutorial.md +33 -1
  16. data/docs/vim_diff.md +31 -8
  17. data/ext/ruvim/extconf.rb +5 -0
  18. data/ext/ruvim/ruvim_ext.c +519 -0
  19. data/lib/ruvim/app.rb +246 -2525
  20. data/lib/ruvim/browser.rb +104 -0
  21. data/lib/ruvim/buffer.rb +43 -20
  22. data/lib/ruvim/cli.rb +6 -0
  23. data/lib/ruvim/command_invocation.rb +2 -2
  24. data/lib/ruvim/completion_manager.rb +708 -0
  25. data/lib/ruvim/dispatcher.rb +14 -8
  26. data/lib/ruvim/display_width.rb +91 -45
  27. data/lib/ruvim/editor.rb +74 -80
  28. data/lib/ruvim/ex_command_registry.rb +3 -1
  29. data/lib/ruvim/file_watcher.rb +243 -0
  30. data/lib/ruvim/gh/link.rb +207 -0
  31. data/lib/ruvim/git/blame.rb +255 -0
  32. data/lib/ruvim/git/branch.rb +112 -0
  33. data/lib/ruvim/git/commit.rb +102 -0
  34. data/lib/ruvim/git/diff.rb +129 -0
  35. data/lib/ruvim/git/grep.rb +107 -0
  36. data/lib/ruvim/git/handler.rb +125 -0
  37. data/lib/ruvim/git/log.rb +41 -0
  38. data/lib/ruvim/git/status.rb +103 -0
  39. data/lib/ruvim/global_commands.rb +351 -77
  40. data/lib/ruvim/highlighter.rb +4 -11
  41. data/lib/ruvim/input.rb +1 -0
  42. data/lib/ruvim/key_handler.rb +1510 -0
  43. data/lib/ruvim/keymap_manager.rb +7 -7
  44. data/lib/ruvim/lang/base.rb +5 -0
  45. data/lib/ruvim/lang/c.rb +116 -0
  46. data/lib/ruvim/lang/cpp.rb +107 -0
  47. data/lib/ruvim/lang/csv.rb +4 -1
  48. data/lib/ruvim/lang/diff.rb +43 -0
  49. data/lib/ruvim/lang/dockerfile.rb +36 -0
  50. data/lib/ruvim/lang/elixir.rb +85 -0
  51. data/lib/ruvim/lang/erb.rb +30 -0
  52. data/lib/ruvim/lang/go.rb +83 -0
  53. data/lib/ruvim/lang/html.rb +34 -0
  54. data/lib/ruvim/lang/javascript.rb +83 -0
  55. data/lib/ruvim/lang/json.rb +40 -0
  56. data/lib/ruvim/lang/lua.rb +76 -0
  57. data/lib/ruvim/lang/makefile.rb +36 -0
  58. data/lib/ruvim/lang/markdown.rb +3 -4
  59. data/lib/ruvim/lang/ocaml.rb +77 -0
  60. data/lib/ruvim/lang/perl.rb +91 -0
  61. data/lib/ruvim/lang/python.rb +85 -0
  62. data/lib/ruvim/lang/registry.rb +102 -0
  63. data/lib/ruvim/lang/ruby.rb +7 -0
  64. data/lib/ruvim/lang/rust.rb +95 -0
  65. data/lib/ruvim/lang/scheme.rb +5 -0
  66. data/lib/ruvim/lang/sh.rb +76 -0
  67. data/lib/ruvim/lang/sql.rb +52 -0
  68. data/lib/ruvim/lang/toml.rb +36 -0
  69. data/lib/ruvim/lang/tsv.rb +4 -1
  70. data/lib/ruvim/lang/typescript.rb +53 -0
  71. data/lib/ruvim/lang/yaml.rb +62 -0
  72. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  73. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  74. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  75. data/lib/ruvim/rich_view.rb +30 -7
  76. data/lib/ruvim/screen.rb +135 -84
  77. data/lib/ruvim/stream/file_load.rb +85 -0
  78. data/lib/ruvim/stream/follow.rb +40 -0
  79. data/lib/ruvim/stream/git.rb +43 -0
  80. data/lib/ruvim/stream/run.rb +74 -0
  81. data/lib/ruvim/stream/stdin.rb +55 -0
  82. data/lib/ruvim/stream.rb +35 -0
  83. data/lib/ruvim/stream_mixer.rb +394 -0
  84. data/lib/ruvim/terminal.rb +18 -4
  85. data/lib/ruvim/text_metrics.rb +84 -65
  86. data/lib/ruvim/version.rb +1 -1
  87. data/lib/ruvim/window.rb +5 -5
  88. data/lib/ruvim.rb +31 -4
  89. data/test/app_command_test.rb +382 -0
  90. data/test/app_completion_test.rb +65 -16
  91. data/test/app_dot_repeat_test.rb +27 -3
  92. data/test/app_ex_command_test.rb +154 -0
  93. data/test/app_motion_test.rb +13 -12
  94. data/test/app_register_test.rb +2 -1
  95. data/test/app_scenario_test.rb +182 -8
  96. data/test/app_startup_test.rb +70 -27
  97. data/test/app_text_object_test.rb +2 -1
  98. data/test/app_unicode_behavior_test.rb +3 -2
  99. data/test/browser_test.rb +88 -0
  100. data/test/buffer_test.rb +24 -0
  101. data/test/cli_test.rb +77 -0
  102. data/test/clipboard_test.rb +67 -0
  103. data/test/command_invocation_test.rb +33 -0
  104. data/test/command_line_test.rb +118 -0
  105. data/test/config_dsl_test.rb +134 -0
  106. data/test/dispatcher_test.rb +74 -4
  107. data/test/display_width_test.rb +41 -0
  108. data/test/ex_command_registry_test.rb +106 -0
  109. data/test/file_watcher_test.rb +197 -0
  110. data/test/follow_test.rb +198 -0
  111. data/test/gh_link_test.rb +141 -0
  112. data/test/git_blame_test.rb +792 -0
  113. data/test/git_grep_test.rb +64 -0
  114. data/test/highlighter_test.rb +169 -0
  115. data/test/indent_test.rb +223 -0
  116. data/test/input_screen_integration_test.rb +1 -1
  117. data/test/keyword_chars_test.rb +85 -0
  118. data/test/lang_test.rb +634 -0
  119. data/test/markdown_renderer_test.rb +5 -5
  120. data/test/on_save_hook_test.rb +12 -8
  121. data/test/render_snapshot_test.rb +78 -0
  122. data/test/rich_view_test.rb +279 -23
  123. data/test/run_command_test.rb +307 -0
  124. data/test/screen_test.rb +68 -5
  125. data/test/search_option_test.rb +19 -0
  126. data/test/stream_test.rb +165 -0
  127. data/test/test_helper.rb +9 -0
  128. data/test/window_test.rb +59 -0
  129. metadata +68 -2
@@ -0,0 +1,1510 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ class KeyHandler
5
+ # Rich mode: delegates to normal mode key handling but blocks mutating operations.
6
+ RICH_MODE_BLOCKED_COMMANDS = %w[
7
+ mode.insert mode.append mode.append_line_end mode.insert_nonblank
8
+ mode.open_below mode.open_above
9
+ buffer.delete_char buffer.delete_line buffer.delete_motion
10
+ buffer.change_motion buffer.change_line
11
+ buffer.paste_after buffer.paste_before
12
+ buffer.replace_char
13
+ buffer.visual_delete
14
+ ].freeze
15
+
16
+ attr_accessor :paste_batch
17
+
18
+ def initialize(editor:, dispatcher:, keymaps:, terminal:, screen:, completion:, stream_mixer:)
19
+ @editor = editor
20
+ @dispatcher = dispatcher
21
+ @keymaps = keymaps
22
+ @terminal = terminal
23
+ @screen = screen
24
+ @completion = completion
25
+ @stream_mixer = stream_mixer
26
+
27
+ @pending_key_deadline = nil
28
+ @pending_ambiguous_invocation = nil
29
+ @pending_keys = nil
30
+ @insert_start_location = nil
31
+ @paste_batch = false
32
+
33
+ # Pending state flags
34
+ @operator_pending = nil
35
+ @register_pending = false
36
+ @mark_pending = false
37
+ @jump_pending = nil
38
+ @find_pending = nil
39
+ @replace_pending = nil
40
+ @macro_record_pending = false
41
+ @macro_play_pending = false
42
+ @visual_pending = nil
43
+ @skip_record_for_current_key = false
44
+ @last_macro_name = nil
45
+ @macro_play_stack = nil
46
+ @suspend_macro_recording_depth = nil
47
+
48
+ # Dot repeat state
49
+ @dot_change_capture_active = false
50
+ @dot_change_capture_keys = nil
51
+ @last_change_keys = nil
52
+ @dot_replay_depth = nil
53
+ end
54
+
55
+ # Returns true if redraw is needed due to timeout/transient message handling
56
+ def handle_idle_timeout
57
+ redraw = false
58
+ if pending_key_timeout_expired?
59
+ handle_pending_key_timeout
60
+ redraw = true
61
+ end
62
+ redraw = true if @editor.clear_expired_transient_message!(now: monotonic_now)
63
+ redraw
64
+ end
65
+
66
+ def pending_key_timeout_seconds
67
+ return nil unless @pending_key_deadline
68
+
69
+ [@pending_key_deadline - monotonic_now, 0.0].max
70
+ end
71
+
72
+ def loop_timeout_seconds
73
+ now = monotonic_now
74
+ timeouts = []
75
+ if @pending_key_deadline
76
+ timeouts << [@pending_key_deadline - now, 0.0].max
77
+ end
78
+ if (msg_to = @editor.transient_message_timeout_seconds(now:))
79
+ timeouts << msg_to
80
+ end
81
+ timeouts.min
82
+ end
83
+
84
+ def escape_sequence_timeout_seconds
85
+ ms = @editor.global_options["ttimeoutlen"].to_i
86
+ ms = 50 if ms <= 0
87
+ ms / 1000.0
88
+ rescue StandardError
89
+ 0.005
90
+ end
91
+
92
+ # Returns true if redraw is needed
93
+ def handle(key)
94
+ mode_before = @editor.mode
95
+ clear_stale_message_before_key(key)
96
+ @skip_record_for_current_key = false
97
+ append_dot_change_capture_key(key)
98
+ if key == :ctrl_z
99
+ suspend_to_shell
100
+ track_mode_transition(mode_before)
101
+ return true
102
+ end
103
+ if key == :ctrl_c && @editor.mode != :normal
104
+ handle_ctrl_c
105
+ track_mode_transition(mode_before)
106
+ record_macro_key_if_needed(key)
107
+ return false
108
+ end
109
+
110
+ case @editor.mode
111
+ when :hit_enter
112
+ handle_hit_enter_key(key)
113
+ when :insert
114
+ handle_insert_key(key)
115
+ when :command_line
116
+ handle_command_line_key(key)
117
+ when :visual_char, :visual_line, :visual_block
118
+ handle_visual_key(key)
119
+ when :rich
120
+ handle_rich_key(key)
121
+ else
122
+ handle_normal_key(key)
123
+ end
124
+ track_mode_transition(mode_before)
125
+ record_macro_key_if_needed(key)
126
+ false
127
+ rescue RuVim::CommandError => e
128
+ @editor.echo_error(e.message)
129
+ false
130
+ end
131
+
132
+ def handle_editor_app_action(name, **kwargs)
133
+ if @editor.rich_mode?
134
+ case name
135
+ when :normal_operator_start
136
+ op = kwargs[:name] || kwargs["name"]
137
+ return if op == :delete || op == :change
138
+ when :normal_replace_pending_start, :normal_change_repeat
139
+ return
140
+ end
141
+ end
142
+
143
+ case name
144
+ when :normal_register_pending_start
145
+ start_register_pending
146
+ when :normal_operator_start
147
+ start_operator_pending(kwargs[:name] || kwargs["name"])
148
+ when :normal_replace_pending_start
149
+ start_replace_pending
150
+ when :normal_find_pending_start
151
+ start_find_pending((kwargs[:token] || kwargs["token"]).to_s)
152
+ when :normal_find_repeat
153
+ repeat_last_find(reverse: !!(kwargs[:reverse] || kwargs["reverse"]))
154
+ when :normal_change_repeat
155
+ repeat_last_change
156
+ when :normal_macro_record_toggle
157
+ toggle_macro_recording_or_start_pending
158
+ when :normal_macro_play_pending_start
159
+ start_macro_play_pending
160
+ when :normal_mark_pending_start
161
+ start_mark_pending
162
+ when :normal_jump_pending_start
163
+ start_jump_pending(
164
+ linewise: !!(kwargs[:linewise] || kwargs["linewise"]),
165
+ repeat_token: (kwargs[:repeat_token] || kwargs["repeat_token"]).to_s
166
+ )
167
+ when :follow_toggle
168
+ @stream_mixer.ex_follow_toggle
169
+ when :normal_ctrl_c
170
+ handle_normal_ctrl_c
171
+ else
172
+ raise RuVim::CommandError, "Unknown app action: #{name}"
173
+ end
174
+ end
175
+
176
+ def handle_normal_ctrl_c
177
+ clear_pending_key_timeout
178
+ @editor.pending_count = nil
179
+ @pending_keys = []
180
+ @operator_pending = nil
181
+ @replace_pending = nil
182
+ @register_pending = false
183
+ @mark_pending = false
184
+ @jump_pending = nil
185
+ @macro_record_pending = false
186
+ @macro_play_pending = false
187
+ buf = @editor.current_buffer
188
+ if buf && @stream_mixer.follow_active?(buf)
189
+ @stream_mixer.stop_follow!(buf)
190
+ else
191
+ @editor.clear_message
192
+ end
193
+ end
194
+
195
+ private
196
+
197
+ def monotonic_now
198
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
199
+ rescue StandardError
200
+ Time.now.to_f
201
+ end
202
+
203
+ def arm_pending_key_timeout
204
+ ms = @editor.global_options["timeoutlen"].to_i
205
+ ms = 1000 if ms <= 0
206
+ @pending_key_deadline = monotonic_now + (ms / 1000.0)
207
+ end
208
+
209
+ def clear_pending_key_timeout
210
+ @pending_key_deadline = nil
211
+ @pending_ambiguous_invocation = nil
212
+ end
213
+
214
+ def pending_key_timeout_expired?
215
+ @pending_key_deadline && monotonic_now >= @pending_key_deadline
216
+ end
217
+
218
+ def handle_pending_key_timeout
219
+ inv = @pending_ambiguous_invocation
220
+ clear_pending_key_timeout
221
+ if inv
222
+ @dispatcher.dispatch(@editor, dup_invocation(inv))
223
+ elsif @pending_keys && !@pending_keys.empty?
224
+ @editor.echo_error("Unknown key: #{@pending_keys.join}")
225
+ end
226
+ @editor.pending_count = nil
227
+ @pending_keys = []
228
+ end
229
+
230
+ def clear_stale_message_before_key(key)
231
+ return if @editor.message.to_s.empty?
232
+ return if @editor.command_line_active?
233
+ return if @editor.hit_enter_active?
234
+ return if key == :ctrl_c
235
+
236
+ @editor.clear_message
237
+ end
238
+
239
+ # --- Normal mode ---
240
+
241
+ def handle_normal_key(key)
242
+ case
243
+ when handle_normal_key_pre_dispatch(key)
244
+ when (token = normalize_key_token(key)).nil?
245
+ when handle_normal_pending_state(token)
246
+ when handle_normal_direct_token(token)
247
+ else
248
+ @pending_keys ||= []
249
+ @pending_keys << token
250
+ resolve_normal_key_sequence
251
+ end
252
+ end
253
+
254
+ def handle_normal_key_pre_dispatch(key)
255
+ case
256
+ when key == :enter && handle_list_window_enter
257
+ when digit_key?(key) && count_digit_allowed?(key)
258
+ @editor.pending_count = (@editor.pending_count.to_s + key).to_i
259
+ @editor.echo(@editor.pending_count.to_s)
260
+ @pending_keys = []
261
+ else
262
+ return false
263
+ end
264
+ true
265
+ end
266
+
267
+ def handle_normal_pending_state(token)
268
+ case
269
+ when @pending_keys && !@pending_keys.empty?
270
+ @pending_keys << token
271
+ resolve_normal_key_sequence
272
+ when @operator_pending
273
+ handle_operator_pending_key(token)
274
+ when @register_pending
275
+ finish_register_pending(token)
276
+ when @mark_pending
277
+ finish_mark_pending(token)
278
+ when @jump_pending
279
+ finish_jump_pending(token)
280
+ when @macro_record_pending
281
+ finish_macro_record_pending(token)
282
+ when @macro_play_pending
283
+ finish_macro_play_pending(token)
284
+ when @replace_pending
285
+ handle_replace_pending_key(token)
286
+ when @find_pending
287
+ finish_find_pending(token)
288
+ else
289
+ return false
290
+ end
291
+ true
292
+ end
293
+
294
+ def handle_normal_direct_token(token)
295
+ false
296
+ end
297
+
298
+ def resolve_normal_key_sequence
299
+ match = @keymaps.resolve_with_context(:normal, @pending_keys, editor: @editor)
300
+ case match.status
301
+ when :pending, :ambiguous
302
+ if match.status == :ambiguous && match.invocation
303
+ inv = dup_invocation(match.invocation)
304
+ inv.count = @editor.pending_count
305
+ @pending_ambiguous_invocation = inv
306
+ else
307
+ @pending_ambiguous_invocation = nil
308
+ end
309
+ arm_pending_key_timeout
310
+ return
311
+ when :match
312
+ clear_pending_key_timeout
313
+ matched_keys = @pending_keys.dup
314
+ repeat_count = @editor.pending_count
315
+ @pending_keys = []
316
+ invocation = dup_invocation(match.invocation)
317
+ invocation.count = repeat_count
318
+ if @editor.rich_mode? && rich_mode_block_command?(invocation.id)
319
+ @editor.pending_count = nil
320
+ @pending_keys = []
321
+ return
322
+ end
323
+ @dispatcher.dispatch(@editor, invocation)
324
+ maybe_record_simple_dot_change(invocation, matched_keys, repeat_count)
325
+ else
326
+ clear_pending_key_timeout
327
+ @editor.echo_error("Unknown key: #{@pending_keys.join}")
328
+ end
329
+ @editor.pending_count = nil
330
+ @pending_keys = []
331
+ end
332
+
333
+ # --- Insert mode ---
334
+
335
+ def handle_insert_key(key)
336
+ case key
337
+ when :escape
338
+ finish_insert_change_group
339
+ finish_dot_change_capture
340
+ @completion.clear_insert_completion
341
+ @editor.enter_normal_mode
342
+ @editor.echo("")
343
+ when :backspace
344
+ @completion.clear_insert_completion
345
+ return unless insert_backspace_allowed?
346
+ insert_backspace_in_insert_mode
347
+ when :ctrl_n
348
+ @completion.insert_complete(+1)
349
+ when :ctrl_p
350
+ @completion.insert_complete(-1)
351
+ when :ctrl_i
352
+ @completion.clear_insert_completion
353
+ insert_tab_in_insert_mode
354
+ when :enter
355
+ @completion.clear_insert_completion
356
+ y, x = @editor.current_buffer.insert_newline(@editor.current_window.cursor_y, @editor.current_window.cursor_x)
357
+ x = apply_insert_autoindent(y, x, previous_row: y - 1)
358
+ @editor.current_window.cursor_y = y
359
+ @editor.current_window.cursor_x = x
360
+ when :left
361
+ @completion.clear_insert_completion
362
+ dispatch_insert_cursor_motion("cursor.left")
363
+ when :right
364
+ @completion.clear_insert_completion
365
+ dispatch_insert_cursor_motion("cursor.right")
366
+ when :up
367
+ @completion.clear_insert_completion
368
+ @editor.current_window.move_up(@editor.current_buffer, 1)
369
+ when :down
370
+ @completion.clear_insert_completion
371
+ @editor.current_window.move_down(@editor.current_buffer, 1)
372
+ when :pageup, :pagedown
373
+ @completion.clear_insert_completion
374
+ invoke_page_key(key)
375
+ else
376
+ return unless key.is_a?(String)
377
+
378
+ @completion.clear_insert_completion
379
+ @editor.current_buffer.insert_char(@editor.current_window.cursor_y, @editor.current_window.cursor_x, key)
380
+ @editor.current_window.cursor_x += 1
381
+ maybe_showmatch_after_insert(key)
382
+ maybe_dedent_after_insert(key)
383
+ end
384
+ end
385
+
386
+ # --- Visual mode ---
387
+
388
+ def handle_visual_key(key)
389
+ if arrow_key?(key)
390
+ invoke_arrow(key)
391
+ return
392
+ end
393
+
394
+ if paging_key?(key)
395
+ invoke_page_key(key)
396
+ return
397
+ end
398
+
399
+ token = normalize_key_token(key)
400
+ return if token.nil?
401
+
402
+ case token
403
+ when "\e"
404
+ @register_pending = false
405
+ @visual_pending = nil
406
+ @editor.enter_normal_mode
407
+ when "v"
408
+ if @editor.mode == :visual_char
409
+ @editor.enter_normal_mode
410
+ else
411
+ @editor.enter_visual(:visual_char)
412
+ end
413
+ when "V"
414
+ if @editor.mode == :visual_line
415
+ @editor.enter_normal_mode
416
+ else
417
+ @editor.enter_visual(:visual_line)
418
+ end
419
+ when "<C-v>"
420
+ if @editor.mode == :visual_block
421
+ @editor.enter_normal_mode
422
+ else
423
+ @editor.enter_visual(:visual_block)
424
+ end
425
+ when "y"
426
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_yank"))
427
+ when "d"
428
+ @visual_pending = nil
429
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_delete"))
430
+ when "="
431
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_indent"))
432
+ when "\""
433
+ start_register_pending
434
+ when "i", "a"
435
+ @visual_pending = token
436
+ else
437
+ if @register_pending
438
+ finish_register_pending(token)
439
+ return
440
+ end
441
+ if @visual_pending
442
+ if @editor.mode == :visual_block
443
+ @visual_pending = nil
444
+ @editor.echo_error("text object in Visual block not supported yet")
445
+ return
446
+ end
447
+ motion = "#{@visual_pending}#{token}"
448
+ @visual_pending = nil
449
+ inv = CommandInvocation.new(id: "buffer.visual_select_text_object", kwargs: { motion: motion })
450
+ @dispatcher.dispatch(@editor, inv)
451
+ else
452
+ handle_visual_motion_token(token)
453
+ end
454
+ end
455
+ @editor.pending_count = nil
456
+ @pending_keys = []
457
+ end
458
+
459
+ def handle_visual_motion_token(token)
460
+ id = {
461
+ "h" => "cursor.left",
462
+ "j" => "cursor.down",
463
+ "k" => "cursor.up",
464
+ "l" => "cursor.right",
465
+ "0" => "cursor.line_start",
466
+ "$" => "cursor.line_end",
467
+ "^" => "cursor.first_nonblank",
468
+ "w" => "cursor.word_forward",
469
+ "b" => "cursor.word_backward",
470
+ "e" => "cursor.word_end",
471
+ "G" => "cursor.buffer_end"
472
+ }[token]
473
+
474
+ if token == "g"
475
+ @pending_keys ||= []
476
+ @pending_keys << token
477
+ arm_pending_key_timeout
478
+ return
479
+ end
480
+
481
+ if @pending_keys == ["g"] && token == "g"
482
+ id = "cursor.buffer_start"
483
+ end
484
+
485
+ if id
486
+ clear_pending_key_timeout
487
+ count = @editor.pending_count
488
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id:, count: count))
489
+ else
490
+ clear_pending_key_timeout
491
+ @editor.echo_error("Unknown visual key: #{token}")
492
+ end
493
+ ensure
494
+ @pending_keys = [] unless token == "g"
495
+ end
496
+
497
+ # --- Command-line mode ---
498
+
499
+ def handle_command_line_key(key)
500
+ cmd = @editor.command_line
501
+ case key
502
+ when :escape
503
+ @completion.clear_command_line_completion
504
+ @completion.cancel_incsearch_preview_if_any
505
+ @editor.cancel_command_line
506
+ when :enter
507
+ @completion.clear_command_line_completion
508
+ line = cmd.text.dup
509
+ @completion.push_history(cmd.prefix, line)
510
+ handle_command_line_submit(cmd.prefix, line)
511
+ when :backspace
512
+ @completion.clear_command_line_completion
513
+ if cmd.text.empty? && cmd.cursor.zero?
514
+ @completion.cancel_incsearch_preview_if_any
515
+ @editor.cancel_command_line
516
+ return
517
+ end
518
+ cmd.backspace
519
+ when :up
520
+ @completion.clear_command_line_completion
521
+ @completion.history_move(-1)
522
+ when :down
523
+ @completion.clear_command_line_completion
524
+ @completion.history_move(1)
525
+ when :left
526
+ @completion.clear_command_line_completion
527
+ cmd.move_left
528
+ when :right
529
+ @completion.clear_command_line_completion
530
+ cmd.move_right
531
+ else
532
+ if key == :ctrl_i
533
+ @completion.command_line_complete
534
+ elsif key.is_a?(String)
535
+ @completion.clear_command_line_completion
536
+ @completion.reset_history_index!
537
+ cmd.insert(key)
538
+ end
539
+ end
540
+ @completion.update_incsearch_preview_if_needed
541
+ end
542
+
543
+ # --- Hit-enter, rich mode ---
544
+
545
+ def handle_hit_enter_key(key)
546
+ token = normalize_key_token(key)
547
+ case token
548
+ when ":"
549
+ @editor.exit_hit_enter_mode
550
+ @editor.enter_command_line_mode(":")
551
+ when "/", "?"
552
+ @editor.exit_hit_enter_mode
553
+ @editor.enter_command_line_mode(token)
554
+ else
555
+ @editor.exit_hit_enter_mode
556
+ end
557
+ end
558
+
559
+ def handle_rich_key(key)
560
+ token = normalize_key_token(key)
561
+ if token == "\e"
562
+ RuVim::RichView.close!(@editor)
563
+ return
564
+ end
565
+
566
+ handle_normal_key(key)
567
+ end
568
+
569
+ def rich_mode_block_command?(command_id)
570
+ RICH_MODE_BLOCKED_COMMANDS.include?(command_id.to_s)
571
+ end
572
+
573
+ # --- Ctrl-C / suspend ---
574
+
575
+ def handle_ctrl_c
576
+ case @editor.mode
577
+ when :hit_enter
578
+ @editor.exit_hit_enter_mode
579
+ when :insert
580
+ finish_insert_change_group
581
+ finish_dot_change_capture
582
+ @completion.clear_insert_completion
583
+ clear_pending_key_timeout
584
+ @editor.enter_normal_mode
585
+ @editor.echo("")
586
+ when :command_line
587
+ clear_pending_key_timeout
588
+ @completion.cancel_incsearch_preview_if_any
589
+ @editor.cancel_command_line
590
+ when :visual_char, :visual_line, :visual_block
591
+ @visual_pending = nil
592
+ @register_pending = false
593
+ @mark_pending = false
594
+ @jump_pending = nil
595
+ clear_pending_key_timeout
596
+ @editor.enter_normal_mode
597
+ when :rich
598
+ clear_pending_key_timeout
599
+ @editor.pending_count = nil
600
+ @pending_keys = []
601
+ @operator_pending = nil
602
+ @replace_pending = nil
603
+ @register_pending = false
604
+ @mark_pending = false
605
+ @jump_pending = nil
606
+ @macro_record_pending = false
607
+ @macro_play_pending = false
608
+ RuVim::RichView.close!(@editor)
609
+ else
610
+ clear_pending_key_timeout
611
+ @editor.pending_count = nil
612
+ @pending_keys = []
613
+ @operator_pending = nil
614
+ @replace_pending = nil
615
+ @register_pending = false
616
+ @mark_pending = false
617
+ @jump_pending = nil
618
+ @macro_record_pending = false
619
+ @macro_play_pending = false
620
+ @editor.clear_message
621
+ end
622
+ end
623
+
624
+ def suspend_to_shell
625
+ @terminal.suspend_for_tstp
626
+ @screen.invalidate_cache! if @screen.respond_to?(:invalidate_cache!)
627
+ rescue StandardError => e
628
+ @editor.echo_error("suspend failed: #{e.message}")
629
+ end
630
+
631
+ # --- Command-line submit ---
632
+
633
+ def handle_command_line_submit(prefix, line)
634
+ @completion.clear_incsearch_preview_state(apply: false) if %w[/ ?].include?(prefix)
635
+ case prefix
636
+ when ":"
637
+ @dispatcher.dispatch_ex(@editor, line)
638
+ when "/"
639
+ submit_search(line, direction: :forward)
640
+ when "?"
641
+ submit_search(line, direction: :backward)
642
+ else
643
+ @editor.echo_error("Unknown command-line prefix: #{prefix}")
644
+ @editor.enter_normal_mode
645
+ end
646
+ @completion.reset_history_index!
647
+ end
648
+
649
+ # --- List/quickfix/filter enter handlers ---
650
+
651
+ def handle_list_window_enter
652
+ buffer = @editor.current_buffer
653
+ return handle_filter_buffer_enter if buffer.kind == :filter
654
+ return handle_git_status_enter if buffer.kind == :git_status
655
+ return handle_git_diff_enter if buffer.kind == :git_diff || buffer.kind == :git_log
656
+ return handle_git_grep_enter if buffer.kind == :git_grep
657
+ return handle_git_branch_enter if buffer.kind == :git_branch
658
+ return false unless buffer.kind == :quickfix || buffer.kind == :location_list
659
+
660
+ item_index = @editor.current_window.cursor_y - 2
661
+ if item_index.negative?
662
+ @editor.echo_error("No list item on this line")
663
+ return true
664
+ end
665
+
666
+ source_window_id = buffer.options["ruvim_list_source_window_id"]
667
+ source_window_id = source_window_id.to_i if source_window_id
668
+ source_window_id = nil unless source_window_id && @editor.windows.key?(source_window_id)
669
+
670
+ item =
671
+ if buffer.kind == :quickfix
672
+ @editor.select_quickfix(item_index)
673
+ else
674
+ owner_window_id = source_window_id || @editor.current_window_id
675
+ @editor.select_location_list(item_index, window_id: owner_window_id)
676
+ end
677
+
678
+ unless item
679
+ @editor.echo_error("#{buffer.kind == :quickfix ? 'quickfix' : 'location list'} item not found")
680
+ return true
681
+ end
682
+
683
+ if source_window_id
684
+ @editor.current_window_id = source_window_id
685
+ end
686
+ @editor.jump_to_location(item)
687
+ @editor.echo(
688
+ if buffer.kind == :quickfix
689
+ "qf #{@editor.quickfix_index.to_i + 1}/#{@editor.quickfix_items.length}"
690
+ else
691
+ owner_window_id = source_window_id || @editor.current_window_id
692
+ list = @editor.location_list(owner_window_id)
693
+ "ll #{list[:index].to_i + 1}/#{list[:items].length}"
694
+ end
695
+ )
696
+ true
697
+ end
698
+
699
+ def handle_filter_buffer_enter
700
+ buffer = @editor.current_buffer
701
+ origins = buffer.options["filter_origins"]
702
+ return false unless origins
703
+
704
+ row = @editor.current_window.cursor_y
705
+ origin = origins[row]
706
+ unless origin
707
+ @editor.echo_error("No filter item on this line")
708
+ return true
709
+ end
710
+
711
+ target_buffer_id = origin[:buffer_id]
712
+ target_row = origin[:row]
713
+ filter_buf_id = buffer.id
714
+
715
+ @editor.delete_buffer(filter_buf_id)
716
+ target_buf = @editor.buffers[target_buffer_id]
717
+ if target_buf
718
+ @editor.switch_to_buffer(target_buffer_id) unless @editor.current_buffer.id == target_buffer_id
719
+ @editor.current_window.cursor_y = [target_row, target_buf.lines.length - 1].min
720
+ @editor.current_window.cursor_x = 0
721
+ end
722
+ true
723
+ end
724
+
725
+ def handle_git_status_enter
726
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "git.status.open_file"))
727
+ true
728
+ end
729
+
730
+ def handle_git_diff_enter
731
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "git.diff.open_file"))
732
+ true
733
+ end
734
+
735
+ def handle_git_grep_enter
736
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "git.grep.open_file"))
737
+ true
738
+ end
739
+
740
+ def handle_git_branch_enter
741
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "git.branch.checkout"))
742
+ true
743
+ end
744
+
745
+ # --- Key helpers ---
746
+
747
+ def arrow_key?(key)
748
+ %i[left right up down].include?(key)
749
+ end
750
+
751
+ def paging_key?(key)
752
+ %i[pageup pagedown].include?(key)
753
+ end
754
+
755
+ def invoke_arrow(key)
756
+ id = {
757
+ left: "cursor.left",
758
+ right: "cursor.right",
759
+ up: "cursor.up",
760
+ down: "cursor.down"
761
+ }.fetch(key)
762
+ inv = CommandInvocation.new(id:, count: @editor.pending_count)
763
+ @dispatcher.dispatch(@editor, inv)
764
+ @editor.pending_count = nil
765
+ @pending_keys = []
766
+ end
767
+
768
+ def invoke_page_key(key)
769
+ id = (key == :pageup ? "cursor.page_up" : "cursor.page_down")
770
+ inv = CommandInvocation.new(
771
+ id: id,
772
+ count: @editor.pending_count,
773
+ kwargs: { page_lines: current_page_step_lines }
774
+ )
775
+ @dispatcher.dispatch(@editor, inv)
776
+ @editor.pending_count = nil
777
+ @pending_keys = []
778
+ end
779
+
780
+ def digit_key?(key)
781
+ key.is_a?(String) && key.match?(/\A\d\z/)
782
+ end
783
+
784
+ def count_digit_allowed?(key)
785
+ return false unless @editor.mode == :normal
786
+ return true unless @editor.pending_count.nil?
787
+
788
+ key != "0"
789
+ end
790
+
791
+ def normalize_key_token(key)
792
+ case key
793
+ when String then key
794
+ when :escape then "\e"
795
+ when :ctrl_r then "<C-r>"
796
+ when :ctrl_d then "<C-d>"
797
+ when :ctrl_u then "<C-u>"
798
+ when :ctrl_f then "<C-f>"
799
+ when :ctrl_b then "<C-b>"
800
+ when :ctrl_e then "<C-e>"
801
+ when :ctrl_y then "<C-y>"
802
+ when :ctrl_v then "<C-v>"
803
+ when :ctrl_i then "<C-i>"
804
+ when :ctrl_o then "<C-o>"
805
+ when :ctrl_w then "<C-w>"
806
+ when :ctrl_l then "<C-l>"
807
+ when :ctrl_c then "<C-c>"
808
+ when :ctrl_g then "<C-g>"
809
+ when :left then "<Left>"
810
+ when :right then "<Right>"
811
+ when :up then "<Up>"
812
+ when :down then "<Down>"
813
+ when :home then "<Home>"
814
+ when :end then "<End>"
815
+ when :pageup then "<PageUp>"
816
+ when :pagedown then "<PageDown>"
817
+ when :shift_up then "<S-Up>"
818
+ when :shift_down then "<S-Down>"
819
+ when :shift_left then "<S-Left>"
820
+ when :shift_right then "<S-Right>"
821
+ else nil
822
+ end
823
+ end
824
+
825
+ def dup_invocation(inv)
826
+ CommandInvocation.new(
827
+ id: inv.id,
828
+ argv: inv.argv.dup,
829
+ kwargs: inv.kwargs.dup,
830
+ count: inv.count,
831
+ bang: inv.bang,
832
+ raw_keys: inv.raw_keys&.dup
833
+ )
834
+ end
835
+
836
+ # --- Mode transition tracking ---
837
+
838
+ def track_mode_transition(mode_before)
839
+ mode_after = @editor.mode
840
+ if mode_before != :insert && mode_after == :insert
841
+ @insert_start_location = @editor.current_location
842
+ elsif mode_before == :insert && mode_after != :insert
843
+ @insert_start_location = nil
844
+ end
845
+
846
+ if mode_before != :command_line && mode_after == :command_line
847
+ @completion.clear_incsearch_preview_state(apply: false) rescue nil
848
+ end
849
+ end
850
+
851
+ def finish_insert_change_group
852
+ @editor.current_buffer.end_change_group
853
+ end
854
+
855
+ # --- Insert editing helpers ---
856
+
857
+ def insert_backspace_allowed?
858
+ buf = @editor.current_buffer
859
+ win = @editor.current_window
860
+ row = win.cursor_y
861
+ col = win.cursor_x
862
+ return false if row.zero? && col.zero?
863
+
864
+ opt = @editor.effective_option("backspace", window: win, buffer: buf).to_s
865
+ allow = opt.split(",").map { |s| s.strip.downcase }.reject(&:empty?)
866
+ allow_all = allow.include?("2")
867
+ allow_indent = allow_all || allow.include?("indent")
868
+
869
+ if col.zero? && row.positive?
870
+ return true if allow_all || allow.include?("eol")
871
+
872
+ @editor.echo_error("backspace=eol required")
873
+ return false
874
+ end
875
+
876
+ if @insert_start_location
877
+ same_buf = @insert_start_location[:buffer_id] == buf.id
878
+ if same_buf && (row < @insert_start_location[:row] || (row == @insert_start_location[:row] && col <= @insert_start_location[:col]))
879
+ if allow_all || allow.include?("start")
880
+ return true
881
+ end
882
+
883
+ if allow_indent && same_row_autoindent_backspace?(buf, row, col)
884
+ return true
885
+ end
886
+
887
+ @editor.echo_error("backspace=start required")
888
+ return false
889
+ end
890
+ end
891
+
892
+ true
893
+ end
894
+
895
+ def insert_backspace_in_insert_mode
896
+ buf = @editor.current_buffer
897
+ win = @editor.current_window
898
+ row = win.cursor_y
899
+ col = win.cursor_x
900
+
901
+ if row >= 0 && col.positive? && try_softtabstop_backspace(buf, win)
902
+ return
903
+ end
904
+
905
+ y, x = buf.backspace(row, col)
906
+ win.cursor_y = y
907
+ win.cursor_x = x
908
+ end
909
+
910
+ def dispatch_insert_cursor_motion(id)
911
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: id, count: 1))
912
+ rescue StandardError => e
913
+ @editor.echo_error("Motion error: #{e.message}")
914
+ end
915
+
916
+ def try_softtabstop_backspace(buf, win)
917
+ row = win.cursor_y
918
+ col = win.cursor_x
919
+ line = buf.line_at(row)
920
+ return false unless line
921
+ return false unless @editor.effective_option("expandtab", window: win, buffer: buf)
922
+
923
+ sts = @editor.effective_option("softtabstop", window: win, buffer: buf).to_i
924
+ sts = @editor.effective_option("tabstop", window: win, buffer: buf).to_i if sts <= 0
925
+ return false if sts <= 0
926
+
927
+ prefix = line[0...col].to_s
928
+ m = prefix.match(/ +\z/)
929
+ return false unless m
930
+
931
+ run = m[0].length
932
+ return false if run <= 1
933
+
934
+ tabstop = effective_tabstop(win, buf)
935
+ cur_screen = RuVim::TextMetrics.screen_col_for_char_index(line, col, tabstop:)
936
+ target_screen = [cur_screen - sts, 0].max
937
+ target_col = RuVim::TextMetrics.char_index_for_screen_col(line, target_screen, tabstop:, align: :floor)
938
+ delete_cols = col - target_col
939
+ delete_cols = [delete_cols, run, sts].min
940
+ return false if delete_cols <= 1
941
+
942
+ run_start = col - run
943
+ target_col = [target_col, run_start].max
944
+ delete_cols = col - target_col
945
+ return false if delete_cols <= 1
946
+
947
+ buf.delete_span(row, target_col, row, col)
948
+ win.cursor_x = target_col
949
+ true
950
+ rescue StandardError
951
+ false
952
+ end
953
+
954
+ def same_row_autoindent_backspace?(buf, row, col)
955
+ return false unless @insert_start_location
956
+ return false unless row == @insert_start_location[:row]
957
+ return false unless col <= @insert_start_location[:col]
958
+
959
+ line = buf.line_at(row)
960
+ line[0...@insert_start_location[:col]].to_s.match?(/\A[ \t]*\z/)
961
+ rescue StandardError
962
+ false
963
+ end
964
+
965
+ def insert_tab_in_insert_mode
966
+ buf = @editor.current_buffer
967
+ win = @editor.current_window
968
+ if @editor.effective_option("expandtab", window: win, buffer: buf)
969
+ width = @editor.effective_option("softtabstop", window: win, buffer: buf).to_i
970
+ width = @editor.effective_option("tabstop", window: win, buffer: buf).to_i if width <= 0
971
+ width = 2 if width <= 0
972
+ line = buf.line_at(win.cursor_y)
973
+ current_col = RuVim::TextMetrics.screen_col_for_char_index(line, win.cursor_x, tabstop: effective_tabstop(win, buf))
974
+ spaces = width - (current_col % width)
975
+ spaces = width if spaces <= 0
976
+ _y, x = buf.insert_text(win.cursor_y, win.cursor_x, " " * spaces)
977
+ win.cursor_x = x
978
+ else
979
+ buf.insert_char(win.cursor_y, win.cursor_x, "\t")
980
+ win.cursor_x += 1
981
+ end
982
+ end
983
+
984
+ def apply_insert_autoindent(row, x, previous_row:)
985
+ return x if @paste_batch
986
+ buf = @editor.current_buffer
987
+ win = @editor.current_window
988
+ return x unless @editor.effective_option("autoindent", window: win, buffer: buf)
989
+ return x if previous_row.negative?
990
+
991
+ prev = buf.line_at(previous_row)
992
+ indent = prev[/\A[ \t]*/].to_s
993
+ if @editor.effective_option("smartindent", window: win, buffer: buf)
994
+ trimmed = prev.rstrip
995
+ needs_indent = trimmed.end_with?("{", "[", "(")
996
+ if !needs_indent
997
+ needs_indent = buf.lang_module.indent_trigger?(trimmed)
998
+ end
999
+ if needs_indent
1000
+ sw = @editor.effective_option("shiftwidth", window: win, buffer: buf).to_i
1001
+ sw = effective_tabstop(win, buf) if sw <= 0
1002
+ sw = 2 if sw <= 0
1003
+ indent += " " * sw
1004
+ end
1005
+ end
1006
+ return x if indent.empty?
1007
+
1008
+ _y, new_x = buf.insert_text(row, x, indent)
1009
+ new_x
1010
+ end
1011
+
1012
+ def maybe_showmatch_after_insert(key)
1013
+ return unless [")", "]", "}"].include?(key)
1014
+ return unless @editor.effective_option("showmatch")
1015
+
1016
+ mt = @editor.effective_option("matchtime").to_i
1017
+ mt = 5 if mt <= 0
1018
+ @editor.echo_temporary("match", duration_seconds: mt * 0.1)
1019
+ end
1020
+
1021
+ def maybe_dedent_after_insert(key)
1022
+ return unless @editor.effective_option("smartindent", window: @editor.current_window, buffer: @editor.current_buffer)
1023
+
1024
+ buf = @editor.current_buffer
1025
+ lang_mod = buf.lang_module
1026
+
1027
+ pattern = lang_mod.dedent_trigger(key)
1028
+ return unless pattern
1029
+
1030
+ row = @editor.current_window.cursor_y
1031
+ line = buf.line_at(row)
1032
+ m = line.match(pattern)
1033
+ return unless m
1034
+
1035
+ sw = @editor.effective_option("shiftwidth", buffer: buf).to_i
1036
+ sw = 2 if sw <= 0
1037
+ target_indent = lang_mod.calculate_indent(buf.lines, row, sw)
1038
+ return unless target_indent
1039
+
1040
+ current_indent = m[1].length
1041
+ return if current_indent == target_indent
1042
+
1043
+ stripped = line.strip
1044
+ buf.delete_span(row, 0, row, current_indent) if current_indent > 0
1045
+ buf.insert_text(row, 0, " " * target_indent) if target_indent > 0
1046
+ @editor.current_window.cursor_x = target_indent + stripped.length
1047
+ end
1048
+
1049
+ def effective_tabstop(window = @editor.current_window, buffer = @editor.current_buffer)
1050
+ v = @editor.effective_option("tabstop", window:, buffer:).to_i
1051
+ v.positive? ? v : 2
1052
+ end
1053
+
1054
+ # --- Operator pending ---
1055
+
1056
+ def start_operator_pending(name)
1057
+ @operator_pending = { name:, count: @editor.pending_count }
1058
+ @editor.pending_count = nil
1059
+ @pending_keys = []
1060
+ @editor.echo(name == :delete ? "d" : name.to_s)
1061
+ end
1062
+
1063
+ def handle_operator_pending_key(token)
1064
+ op = @operator_pending
1065
+ if %w[i a g].include?(token) && !op[:motion_prefix]
1066
+ @operator_pending[:motion_prefix] = token
1067
+ @editor.echo("#{op[:name].to_s[0]}#{token}")
1068
+ return
1069
+ end
1070
+
1071
+ motion = [op[:motion_prefix], token].compact.join
1072
+ @operator_pending = nil
1073
+
1074
+ if token == "\e"
1075
+ @editor.clear_message
1076
+ return
1077
+ end
1078
+
1079
+ if op[:name] == :delete && motion == "d"
1080
+ inv = CommandInvocation.new(id: "buffer.delete_line", count: op[:count])
1081
+ @dispatcher.dispatch(@editor, inv)
1082
+ record_last_change_keys(count_prefixed_keys(op[:count], ["d", "d"]))
1083
+ return
1084
+ end
1085
+
1086
+ if op[:name] == :delete
1087
+ inv = CommandInvocation.new(id: "buffer.delete_motion", count: op[:count], kwargs: { motion: motion })
1088
+ @dispatcher.dispatch(@editor, inv)
1089
+ record_last_change_keys(count_prefixed_keys(op[:count], ["d", *motion.each_char.to_a]))
1090
+ return
1091
+ end
1092
+
1093
+ if op[:name] == :yank && motion == "y"
1094
+ inv = CommandInvocation.new(id: "buffer.yank_line", count: op[:count])
1095
+ @dispatcher.dispatch(@editor, inv)
1096
+ return
1097
+ end
1098
+
1099
+ if op[:name] == :yank
1100
+ inv = CommandInvocation.new(id: "buffer.yank_motion", count: op[:count], kwargs: { motion: motion })
1101
+ @dispatcher.dispatch(@editor, inv)
1102
+ return
1103
+ end
1104
+
1105
+ if op[:name] == :indent && motion == "="
1106
+ inv = CommandInvocation.new(id: "buffer.indent_lines", count: op[:count])
1107
+ @dispatcher.dispatch(@editor, inv)
1108
+ return
1109
+ end
1110
+
1111
+ if op[:name] == :indent
1112
+ inv = CommandInvocation.new(id: "buffer.indent_motion", count: op[:count], kwargs: { motion: motion })
1113
+ @dispatcher.dispatch(@editor, inv)
1114
+ return
1115
+ end
1116
+
1117
+ if op[:name] == :change && motion == "c"
1118
+ inv = CommandInvocation.new(id: "buffer.change_line", count: op[:count])
1119
+ @dispatcher.dispatch(@editor, inv)
1120
+ begin_dot_change_capture(count_prefixed_keys(op[:count], ["c", "c"])) if @editor.mode == :insert
1121
+ return
1122
+ end
1123
+
1124
+ if op[:name] == :change
1125
+ inv = CommandInvocation.new(id: "buffer.change_motion", count: op[:count], kwargs: { motion: motion })
1126
+ @dispatcher.dispatch(@editor, inv)
1127
+ begin_dot_change_capture(count_prefixed_keys(op[:count], ["c", *motion.each_char.to_a])) if @editor.mode == :insert
1128
+ return
1129
+ end
1130
+
1131
+ @editor.echo_error("Unknown operator")
1132
+ end
1133
+
1134
+ # --- Register / mark / jump pending ---
1135
+
1136
+ def start_register_pending
1137
+ @register_pending = true
1138
+ @editor.echo('"')
1139
+ end
1140
+
1141
+ def finish_register_pending(token)
1142
+ @register_pending = false
1143
+ if token.is_a?(String) && token.length == 1
1144
+ @editor.set_active_register(token)
1145
+ @editor.echo(%("#{token}))
1146
+ else
1147
+ @editor.echo_error("Invalid register")
1148
+ end
1149
+ end
1150
+
1151
+ def start_mark_pending
1152
+ @mark_pending = true
1153
+ @editor.echo("m")
1154
+ end
1155
+
1156
+ def finish_mark_pending(token)
1157
+ @mark_pending = false
1158
+ if token == "\e"
1159
+ @editor.clear_message
1160
+ return
1161
+ end
1162
+ unless token.is_a?(String) && token.match?(/\A[A-Za-z]\z/)
1163
+ @editor.echo_error("Invalid mark")
1164
+ return
1165
+ end
1166
+
1167
+ inv = CommandInvocation.new(id: "mark.set", kwargs: { mark: token })
1168
+ @dispatcher.dispatch(@editor, inv)
1169
+ end
1170
+
1171
+ def start_jump_pending(linewise:, repeat_token:)
1172
+ @jump_pending = { linewise: linewise, repeat_token: repeat_token }
1173
+ @editor.echo(repeat_token)
1174
+ end
1175
+
1176
+ def finish_jump_pending(token)
1177
+ pending = @jump_pending
1178
+ @jump_pending = nil
1179
+ return unless pending
1180
+ if token == "\e"
1181
+ @editor.clear_message
1182
+ return
1183
+ end
1184
+
1185
+ if token == pending[:repeat_token]
1186
+ inv = CommandInvocation.new(id: "jump.older", kwargs: { linewise: pending[:linewise] })
1187
+ @dispatcher.dispatch(@editor, inv)
1188
+ return
1189
+ end
1190
+
1191
+ unless token.is_a?(String) && token.match?(/\A[A-Za-z]\z/)
1192
+ @editor.echo_error("Invalid mark")
1193
+ return
1194
+ end
1195
+
1196
+ inv = CommandInvocation.new(id: "mark.jump", kwargs: { mark: token, linewise: pending[:linewise] })
1197
+ @dispatcher.dispatch(@editor, inv)
1198
+ end
1199
+
1200
+ # --- Replace pending ---
1201
+
1202
+ def start_replace_pending
1203
+ @replace_pending = { count: @editor.pending_count }
1204
+ @editor.pending_count = nil
1205
+ @pending_keys = []
1206
+ @editor.echo("r")
1207
+ end
1208
+
1209
+ def handle_replace_pending_key(token)
1210
+ pending = @replace_pending
1211
+ @replace_pending = nil
1212
+ if token == "\e"
1213
+ @editor.clear_message
1214
+ return
1215
+ end
1216
+
1217
+ if token.is_a?(String) && !token.empty?
1218
+ inv = CommandInvocation.new(id: "buffer.replace_char", argv: [token], count: pending[:count])
1219
+ @dispatcher.dispatch(@editor, inv)
1220
+ record_last_change_keys(count_prefixed_keys(pending[:count], ["r", token]))
1221
+ else
1222
+ @editor.echo("r expects one character")
1223
+ end
1224
+ end
1225
+
1226
+ # --- Macro recording / playback ---
1227
+
1228
+ def start_macro_record_pending
1229
+ @macro_record_pending = true
1230
+ @editor.echo("q")
1231
+ end
1232
+
1233
+ def toggle_macro_recording_or_start_pending
1234
+ if @editor.macro_recording?
1235
+ stop_macro_recording
1236
+ else
1237
+ start_macro_record_pending
1238
+ end
1239
+ end
1240
+
1241
+ def finish_macro_record_pending(token)
1242
+ @macro_record_pending = false
1243
+ if token == "\e"
1244
+ @editor.clear_message
1245
+ return
1246
+ end
1247
+ unless token.is_a?(String) && token.match?(/\A[A-Za-z0-9]\z/)
1248
+ @editor.echo_error("Invalid macro register")
1249
+ return
1250
+ end
1251
+
1252
+ unless @editor.start_macro_recording(token)
1253
+ @editor.echo("Failed to start recording")
1254
+ return
1255
+ end
1256
+ @skip_record_for_current_key = true
1257
+ @editor.echo("recording @#{token}")
1258
+ end
1259
+
1260
+ def stop_macro_recording
1261
+ reg = @editor.macro_recording_name
1262
+ @editor.stop_macro_recording
1263
+ @editor.echo("recording @#{reg} stopped")
1264
+ end
1265
+
1266
+ def start_macro_play_pending
1267
+ @macro_play_pending = true
1268
+ @editor.echo("@")
1269
+ end
1270
+
1271
+ def finish_macro_play_pending(token)
1272
+ @macro_play_pending = false
1273
+ if token == "\e"
1274
+ @editor.clear_message
1275
+ return
1276
+ end
1277
+ name =
1278
+ if token == "@"
1279
+ @last_macro_name
1280
+ elsif token.is_a?(String) && token.match?(/\A[A-Za-z0-9]\z/)
1281
+ token
1282
+ end
1283
+ unless name
1284
+ @editor.echo_error("Invalid macro register")
1285
+ return
1286
+ end
1287
+
1288
+ count = @editor.pending_count
1289
+ @editor.pending_count = nil
1290
+ play_macro(name, count:)
1291
+ end
1292
+
1293
+ def play_macro(name, count:)
1294
+ reg = name.to_s.downcase
1295
+ keys = @editor.macro_keys(reg)
1296
+ if keys.nil? || keys.empty?
1297
+ @editor.echo("Macro empty: #{reg}")
1298
+ return
1299
+ end
1300
+
1301
+ @macro_play_stack ||= []
1302
+ if @macro_play_stack.include?(reg) || @macro_play_stack.length >= 20
1303
+ @editor.echo("Macro recursion blocked: #{reg}")
1304
+ return
1305
+ end
1306
+
1307
+ @last_macro_name = reg
1308
+ @macro_play_stack << reg
1309
+ @suspend_macro_recording_depth = (@suspend_macro_recording_depth || 0) + 1
1310
+ [count.to_i, 1].max.times do
1311
+ keys.each { |k| handle(dup_macro_runtime_key(k)) }
1312
+ end
1313
+ @editor.echo("@#{reg}")
1314
+ ensure
1315
+ @suspend_macro_recording_depth = [(@suspend_macro_recording_depth || 1) - 1, 0].max
1316
+ @macro_play_stack.pop if @macro_play_stack && !@macro_play_stack.empty?
1317
+ end
1318
+
1319
+ def record_macro_key_if_needed(key)
1320
+ return if @skip_record_for_current_key
1321
+ return unless @editor.macro_recording?
1322
+ return if (@suspend_macro_recording_depth || 0).positive?
1323
+ return if (@dot_replay_depth || 0).positive?
1324
+
1325
+ @editor.record_macro_key(key)
1326
+ end
1327
+
1328
+ def dup_macro_runtime_key(key)
1329
+ case key
1330
+ when String
1331
+ key.dup
1332
+ when Array
1333
+ key.map { |v| v.is_a?(String) ? v.dup : v }
1334
+ else
1335
+ key
1336
+ end
1337
+ end
1338
+
1339
+ # --- Dot repeat ---
1340
+
1341
+ def repeat_last_change
1342
+ keys = @last_change_keys
1343
+ if keys.nil? || keys.empty?
1344
+ @editor.echo("No previous change")
1345
+ return
1346
+ end
1347
+
1348
+ @dot_replay_depth = (@dot_replay_depth || 0) + 1
1349
+ keys.each { |k| handle(dup_macro_runtime_key(k)) }
1350
+ @editor.echo(".")
1351
+ ensure
1352
+ @dot_replay_depth = [(@dot_replay_depth || 1) - 1, 0].max
1353
+ end
1354
+
1355
+ def maybe_record_simple_dot_change(invocation, matched_keys, count)
1356
+ return if (@dot_replay_depth || 0).positive?
1357
+
1358
+ case invocation.id
1359
+ when "buffer.delete_char", "buffer.delete_motion", "buffer.join_lines", "buffer.swapcase_char", "buffer.paste_after", "buffer.paste_before"
1360
+ record_last_change_keys(count_prefixed_keys(count, matched_keys))
1361
+ when "mode.insert", "mode.append", "mode.append_line_end", "mode.insert_nonblank", "mode.open_below", "mode.open_above", "buffer.substitute_char", "buffer.change_motion", "buffer.change_line"
1362
+ begin_dot_change_capture(count_prefixed_keys(count, matched_keys)) if @editor.mode == :insert
1363
+ end
1364
+ end
1365
+
1366
+ def begin_dot_change_capture(prefix_keys)
1367
+ return if (@dot_replay_depth || 0).positive?
1368
+
1369
+ @dot_change_capture_keys = Array(prefix_keys).map { |k| dup_macro_runtime_key(k) }
1370
+ @dot_change_capture_active = true
1371
+ end
1372
+
1373
+ def append_dot_change_capture_key(key)
1374
+ return unless @dot_change_capture_active
1375
+ return if (@dot_replay_depth || 0).positive?
1376
+
1377
+ @dot_change_capture_keys ||= []
1378
+ @dot_change_capture_keys << dup_macro_runtime_key(key)
1379
+ end
1380
+
1381
+ def finish_dot_change_capture
1382
+ return unless @dot_change_capture_active
1383
+
1384
+ keys = Array(@dot_change_capture_keys)
1385
+ @dot_change_capture_active = false
1386
+ @dot_change_capture_keys = nil
1387
+ record_last_change_keys(keys)
1388
+ end
1389
+
1390
+ def record_last_change_keys(keys)
1391
+ return if (@dot_replay_depth || 0).positive?
1392
+
1393
+ @last_change_keys = Array(keys).map { |k| dup_macro_runtime_key(k) }
1394
+ end
1395
+
1396
+ def count_prefixed_keys(count, keys)
1397
+ c = count.to_i
1398
+ prefix = c > 1 ? c.to_s.each_char.to_a : []
1399
+ prefix + Array(keys)
1400
+ end
1401
+
1402
+ # --- Find character on line ---
1403
+
1404
+ def start_find_pending(token)
1405
+ @find_pending = {
1406
+ direction: (token == "f" || token == "t") ? :forward : :backward,
1407
+ till: (token == "t" || token == "T"),
1408
+ count: @editor.pending_count
1409
+ }
1410
+ @editor.pending_count = nil
1411
+ @pending_keys = []
1412
+ @editor.echo(token)
1413
+ end
1414
+
1415
+ def finish_find_pending(token)
1416
+ pending = @find_pending
1417
+ @find_pending = nil
1418
+ if token == "\e"
1419
+ @editor.clear_message
1420
+ return
1421
+ end
1422
+ unless token.is_a?(String) && !token.empty?
1423
+ @editor.echo("find expects one character")
1424
+ return
1425
+ end
1426
+
1427
+ moved = perform_find_on_line(
1428
+ char: token,
1429
+ direction: pending[:direction],
1430
+ till: pending[:till],
1431
+ count: pending[:count]
1432
+ )
1433
+ if moved
1434
+ @editor.set_last_find(char: token, direction: pending[:direction], till: pending[:till])
1435
+ else
1436
+ @editor.echo("Char not found: #{token}")
1437
+ end
1438
+ end
1439
+
1440
+ def repeat_last_find(reverse:)
1441
+ last = @editor.last_find
1442
+ unless last
1443
+ @editor.echo("No previous f/t")
1444
+ return
1445
+ end
1446
+
1447
+ direction =
1448
+ if reverse
1449
+ last[:direction] == :forward ? :backward : :forward
1450
+ else
1451
+ last[:direction]
1452
+ end
1453
+ count = @editor.pending_count
1454
+ @editor.pending_count = nil
1455
+ @pending_keys = []
1456
+ moved = perform_find_on_line(char: last[:char], direction:, till: last[:till], count:)
1457
+ @editor.echo("Char not found: #{last[:char]}") unless moved
1458
+ end
1459
+
1460
+ def perform_find_on_line(char:, direction:, till:, count:)
1461
+ win = @editor.current_window
1462
+ buf = @editor.current_buffer
1463
+ line = buf.line_at(win.cursor_y)
1464
+ pos = win.cursor_x
1465
+ target = nil
1466
+
1467
+ [count.to_i, 1].max.times do
1468
+ idx =
1469
+ if direction == :forward
1470
+ line.index(char, pos + 1)
1471
+ else
1472
+ rindex_from(line, char, pos - 1)
1473
+ end
1474
+ return false if idx.nil?
1475
+
1476
+ target = idx
1477
+ pos = idx
1478
+ end
1479
+
1480
+ if till
1481
+ target =
1482
+ if direction == :forward
1483
+ RuVim::TextMetrics.previous_grapheme_char_index(line, target)
1484
+ else
1485
+ RuVim::TextMetrics.next_grapheme_char_index(line, target)
1486
+ end
1487
+ end
1488
+
1489
+ win.cursor_x = target
1490
+ win.clamp_to_buffer(buf)
1491
+ true
1492
+ end
1493
+
1494
+ def rindex_from(line, char, pos)
1495
+ return nil if pos.negative?
1496
+
1497
+ line.rindex(char, pos)
1498
+ end
1499
+
1500
+ def submit_search(line, direction:)
1501
+ inv = CommandInvocation.new(id: "__search_submit__", argv: [line], kwargs: { pattern: line, direction: direction })
1502
+ ctx = Context.new(editor: @editor, invocation: inv)
1503
+ GlobalCommands.instance.submit_search(ctx, pattern: line, direction: direction)
1504
+ @editor.enter_normal_mode
1505
+ rescue StandardError => e
1506
+ @editor.echo_error("Error: #{e.message}")
1507
+ @editor.enter_normal_mode
1508
+ end
1509
+ end
1510
+ end