ruvim 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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +84 -0
  4. data/CLAUDE.md +1 -0
  5. data/docs/binding.md +29 -0
  6. data/docs/command.md +101 -0
  7. data/docs/config.md +203 -84
  8. data/docs/done.md +21 -0
  9. data/docs/lib_cleanup_report.md +79 -0
  10. data/docs/plugin.md +13 -15
  11. data/docs/spec.md +195 -33
  12. data/docs/todo.md +183 -10
  13. data/docs/tutorial.md +1 -1
  14. data/docs/vim_diff.md +94 -171
  15. data/lib/ruvim/app.rb +1543 -172
  16. data/lib/ruvim/buffer.rb +35 -1
  17. data/lib/ruvim/cli.rb +12 -3
  18. data/lib/ruvim/clipboard.rb +2 -0
  19. data/lib/ruvim/command_invocation.rb +3 -1
  20. data/lib/ruvim/command_line.rb +2 -0
  21. data/lib/ruvim/command_registry.rb +2 -0
  22. data/lib/ruvim/config_dsl.rb +2 -0
  23. data/lib/ruvim/config_loader.rb +21 -5
  24. data/lib/ruvim/context.rb +2 -7
  25. data/lib/ruvim/dispatcher.rb +153 -13
  26. data/lib/ruvim/display_width.rb +28 -2
  27. data/lib/ruvim/editor.rb +622 -69
  28. data/lib/ruvim/ex_command_registry.rb +2 -0
  29. data/lib/ruvim/global_commands.rb +1386 -114
  30. data/lib/ruvim/highlighter.rb +16 -21
  31. data/lib/ruvim/input.rb +52 -29
  32. data/lib/ruvim/keymap_manager.rb +83 -0
  33. data/lib/ruvim/keyword_chars.rb +48 -0
  34. data/lib/ruvim/lang/base.rb +25 -0
  35. data/lib/ruvim/lang/csv.rb +18 -0
  36. data/lib/ruvim/lang/json.rb +18 -0
  37. data/lib/ruvim/lang/markdown.rb +170 -0
  38. data/lib/ruvim/lang/ruby.rb +236 -0
  39. data/lib/ruvim/lang/scheme.rb +44 -0
  40. data/lib/ruvim/lang/tsv.rb +19 -0
  41. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  42. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  43. data/lib/ruvim/rich_view.rb +93 -0
  44. data/lib/ruvim/screen.rb +851 -119
  45. data/lib/ruvim/terminal.rb +18 -1
  46. data/lib/ruvim/text_metrics.rb +28 -0
  47. data/lib/ruvim/version.rb +2 -2
  48. data/lib/ruvim/window.rb +37 -10
  49. data/lib/ruvim.rb +15 -0
  50. data/test/app_completion_test.rb +174 -0
  51. data/test/app_dot_repeat_test.rb +13 -0
  52. data/test/app_motion_test.rb +110 -2
  53. data/test/app_scenario_test.rb +998 -0
  54. data/test/app_startup_test.rb +197 -0
  55. data/test/arglist_test.rb +113 -0
  56. data/test/buffer_test.rb +49 -30
  57. data/test/config_loader_test.rb +37 -0
  58. data/test/dispatcher_test.rb +438 -0
  59. data/test/display_width_test.rb +18 -0
  60. data/test/editor_register_test.rb +23 -0
  61. data/test/fixtures/render_basic_snapshot.txt +7 -8
  62. data/test/fixtures/render_basic_snapshot_nonumber.txt +1 -2
  63. data/test/fixtures/render_unicode_scrolled_snapshot.txt +6 -7
  64. data/test/highlighter_test.rb +121 -0
  65. data/test/indent_test.rb +201 -0
  66. data/test/input_screen_integration_test.rb +65 -14
  67. data/test/markdown_renderer_test.rb +279 -0
  68. data/test/on_save_hook_test.rb +150 -0
  69. data/test/rich_view_test.rb +478 -0
  70. data/test/screen_test.rb +470 -0
  71. data/test/window_test.rb +26 -0
  72. metadata +37 -2
data/lib/ruvim/app.rb CHANGED
@@ -1,15 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
1
6
  module RuVim
2
7
  class App
3
- def initialize(path: nil, paths: nil, stdin: STDIN, stdout: STDOUT, pre_config_actions: [], startup_actions: [], clean: false, skip_user_config: false, config_path: nil, readonly: false, diff_mode: false, quickfix_errorfile: nil, session_file: nil, nomodifiable: false, restricted: false, verbose_level: 0, verbose_io: STDERR, startup_time_path: nil, startup_open_layout: nil, startup_open_count: nil)
8
+ LARGE_FILE_ASYNC_THRESHOLD_BYTES = 64 * 1024 * 1024
9
+ LARGE_FILE_STAGED_PREFIX_BYTES = 8 * 1024 * 1024
10
+ ASYNC_FILE_READ_CHUNK_BYTES = 1 * 1024 * 1024
11
+ ASYNC_FILE_EVENT_FLUSH_BYTES = 4 * 1024 * 1024
12
+
13
+ def initialize(path: nil, paths: nil, stdin: STDIN, ui_stdin: nil, stdin_stream_mode: false, stdout: STDOUT, pre_config_actions: [], startup_actions: [], clean: false, skip_user_config: false, config_path: nil, readonly: false, diff_mode: false, quickfix_errorfile: nil, session_file: nil, nomodifiable: false, restricted: false, verbose_level: 0, verbose_io: STDERR, startup_time_path: nil, startup_open_layout: nil, startup_open_count: nil)
14
+ startup_paths = Array(paths || path).compact
15
+ @ui_stdin = ui_stdin || stdin
16
+ @stdin_stream_mode = !!stdin_stream_mode
17
+ @stdin_stream_source = @stdin_stream_mode ? stdin : nil
4
18
  @editor = Editor.new
5
- @terminal = Terminal.new(stdin:, stdout:)
6
- @input = Input.new(stdin:)
19
+ @terminal = Terminal.new(stdin: @ui_stdin, stdout:)
20
+ @input = Input.new(@ui_stdin)
7
21
  @screen = Screen.new(terminal: @terminal)
8
22
  @dispatcher = Dispatcher.new
9
23
  @keymaps = KeymapManager.new
10
24
  @signal_r, @signal_w = IO.pipe
25
+ @stream_event_queue = nil
26
+ @stream_reader_thread = nil
27
+ @stream_buffer_id = nil
28
+ @stream_stop_requested = false
29
+ @async_file_loads = {}
11
30
  @cmdline_history = Hash.new { |h, k| h[k] = [] }
12
31
  @cmdline_history_index = nil
32
+ @cmdline_completion = nil
33
+ @pending_key_deadline = nil
34
+ @pending_ambiguous_invocation = nil
35
+ @insert_start_location = nil
36
+ @incsearch_preview = nil
13
37
  @needs_redraw = true
14
38
  @clean_mode = clean
15
39
  @skip_user_config = skip_user_config
@@ -28,6 +52,11 @@ module RuVim
28
52
  @startup_open_layout = startup_open_layout
29
53
  @startup_open_count = startup_open_count
30
54
  @editor.restricted_mode = @restricted_mode
55
+ @editor.stdin_stream_stop_handler = method(:stdin_stream_stop_command)
56
+ @editor.open_path_handler = method(:open_path_with_large_file_support)
57
+ @editor.keymap_manager = @keymaps
58
+ @editor.app_action_handler = method(:handle_editor_app_action)
59
+ load_command_line_history!
31
60
 
32
61
  startup_mark("init.start")
33
62
  register_builtins!
@@ -43,8 +72,10 @@ module RuVim
43
72
  install_signal_handlers
44
73
  startup_mark("signals.installed")
45
74
 
46
- startup_paths = Array(paths || path).compact
47
- if startup_paths.empty?
75
+ if @stdin_stream_mode && startup_paths.empty?
76
+ verbose_log(1, "startup: stdin stream buffer")
77
+ prepare_stdin_stream_buffer!
78
+ elsif startup_paths.empty?
48
79
  verbose_log(1, "startup: intro")
49
80
  @editor.show_intro_buffer_if_applicable!
50
81
  else
@@ -59,25 +90,45 @@ module RuVim
59
90
  verbose_log(1, "startup: run_startup_actions count=#{Array(startup_actions).length}")
60
91
  run_startup_actions!(startup_actions)
61
92
  startup_mark("startup_actions.done")
93
+ start_stdin_stream_reader! if @stream_buffer_id
62
94
  write_startuptime_log!
63
95
  end
64
96
 
65
97
  def run
66
98
  @terminal.with_ui do
67
99
  loop do
100
+ @needs_redraw = true if drain_stream_events!
68
101
  if @needs_redraw
69
102
  @screen.render(@editor)
70
103
  @needs_redraw = false
71
104
  end
72
105
  break unless @editor.running?
73
106
 
74
- key = @input.read_key(wakeup_ios: [@signal_r])
75
- next if key.nil?
107
+ key = @input.read_key(
108
+ wakeup_ios: [@signal_r],
109
+ timeout: loop_timeout_seconds,
110
+ esc_timeout: escape_sequence_timeout_seconds
111
+ )
112
+ if key.nil?
113
+ handle_pending_key_timeout if pending_key_timeout_expired?
114
+ clear_expired_transient_message_if_any
115
+ next
116
+ end
76
117
 
77
118
  handle_key(key)
78
119
  @needs_redraw = true
120
+
121
+ # Batch insert-mode keystrokes to avoid per-char rendering during paste
122
+ while @editor.mode == :insert && @input.has_pending_input?
123
+ batch_key = @input.read_key(timeout: 0, esc_timeout: 0)
124
+ break unless batch_key
125
+ handle_key(batch_key)
126
+ end
79
127
  end
80
128
  end
129
+ ensure
130
+ shutdown_background_readers!
131
+ save_command_line_history!
81
132
  end
82
133
 
83
134
  def run_startup_actions!(actions, log_prefix: "startup")
@@ -89,6 +140,59 @@ module RuVim
89
140
 
90
141
  private
91
142
 
143
+ def pending_key_timeout_seconds
144
+ return nil unless @pending_key_deadline
145
+
146
+ [@pending_key_deadline - monotonic_now, 0.0].max
147
+ end
148
+
149
+ def loop_timeout_seconds
150
+ now = monotonic_now
151
+ timeouts = []
152
+ if @pending_key_deadline
153
+ timeouts << [@pending_key_deadline - now, 0.0].max
154
+ end
155
+ if (msg_to = @editor.transient_message_timeout_seconds(now:))
156
+ timeouts << msg_to
157
+ end
158
+ timeouts.min
159
+ end
160
+
161
+ def pending_key_timeout_expired?
162
+ @pending_key_deadline && monotonic_now >= @pending_key_deadline
163
+ end
164
+
165
+ def escape_sequence_timeout_seconds
166
+ ms = @editor.global_options["ttimeoutlen"].to_i
167
+ ms = 50 if ms <= 0
168
+ ms / 1000.0
169
+ rescue StandardError
170
+ 0.005
171
+ end
172
+
173
+ def arm_pending_key_timeout
174
+ ms = @editor.global_options["timeoutlen"].to_i
175
+ ms = 1000 if ms <= 0
176
+ @pending_key_deadline = monotonic_now + (ms / 1000.0)
177
+ end
178
+
179
+ def clear_pending_key_timeout
180
+ @pending_key_deadline = nil
181
+ @pending_ambiguous_invocation = nil
182
+ end
183
+
184
+ def handle_pending_key_timeout
185
+ inv = @pending_ambiguous_invocation
186
+ clear_pending_key_timeout
187
+ if inv
188
+ @dispatcher.dispatch(@editor, dup_invocation(inv))
189
+ elsif @pending_keys && !@pending_keys.empty?
190
+ @editor.echo_error("Unknown key: #{@pending_keys.join}")
191
+ end
192
+ @editor.pending_count = nil
193
+ @pending_keys = []
194
+ end
195
+
92
196
  def register_builtins!
93
197
  cmd = CommandRegistry.instance
94
198
  ex = ExCommandRegistry.instance
@@ -99,6 +203,17 @@ module RuVim
99
203
  register_internal_unless(cmd, "cursor.down", call: :cursor_down, desc: "Move cursor down")
100
204
  register_internal_unless(cmd, "cursor.page_up", call: :cursor_page_up, desc: "Move one page up")
101
205
  register_internal_unless(cmd, "cursor.page_down", call: :cursor_page_down, desc: "Move one page down")
206
+ register_internal_unless(cmd, "window.scroll_up", call: :window_scroll_up, desc: "Scroll window up")
207
+ register_internal_unless(cmd, "window.scroll_down", call: :window_scroll_down, desc: "Scroll window down")
208
+ register_internal_unless(cmd, "cursor.page_up.default", call: :cursor_page_up_default, desc: "Move one page up (view-sized)")
209
+ register_internal_unless(cmd, "cursor.page_down.default", call: :cursor_page_down_default, desc: "Move one page down (view-sized)")
210
+ register_internal_unless(cmd, "cursor.page_up.half", call: :cursor_page_up_half, desc: "Move half page up")
211
+ register_internal_unless(cmd, "cursor.page_down.half", call: :cursor_page_down_half, desc: "Move half page down")
212
+ register_internal_unless(cmd, "window.scroll_up.line", call: :window_scroll_up_line, desc: "Scroll window up one line")
213
+ register_internal_unless(cmd, "window.scroll_down.line", call: :window_scroll_down_line, desc: "Scroll window down one line")
214
+ register_internal_unless(cmd, "window.cursor_line_top", call: :window_cursor_line_top, desc: "Put cursor line at top")
215
+ register_internal_unless(cmd, "window.cursor_line_center", call: :window_cursor_line_center, desc: "Put cursor line at center")
216
+ register_internal_unless(cmd, "window.cursor_line_bottom", call: :window_cursor_line_bottom, desc: "Put cursor line at bottom")
102
217
  register_internal_unless(cmd, "cursor.line_start", call: :cursor_line_start, desc: "Move to column 1")
103
218
  register_internal_unless(cmd, "cursor.line_end", call: :cursor_line_end, desc: "Move to end of line")
104
219
  register_internal_unless(cmd, "cursor.first_nonblank", call: :cursor_first_nonblank, desc: "Move to first nonblank")
@@ -124,10 +239,17 @@ module RuVim
124
239
  register_internal_unless(cmd, "window.focus_right", call: :window_focus_right, desc: "Focus right window")
125
240
  register_internal_unless(cmd, "window.focus_up", call: :window_focus_up, desc: "Focus upper window")
126
241
  register_internal_unless(cmd, "window.focus_down", call: :window_focus_down, desc: "Focus lower window")
242
+ register_internal_unless(cmd, "window.focus_or_split_left", call: :window_focus_or_split_left, desc: "Focus left window or split")
243
+ register_internal_unless(cmd, "window.focus_or_split_right", call: :window_focus_or_split_right, desc: "Focus right window or split")
244
+ register_internal_unless(cmd, "window.focus_or_split_up", call: :window_focus_or_split_up, desc: "Focus upper window or split")
245
+ register_internal_unless(cmd, "window.focus_or_split_down", call: :window_focus_or_split_down, desc: "Focus lower window or split")
127
246
  register_internal_unless(cmd, "mode.command_line", call: :enter_command_line_mode, desc: "Enter command-line mode")
128
247
  register_internal_unless(cmd, "mode.search_forward", call: :enter_search_forward_mode, desc: "Enter / search")
129
248
  register_internal_unless(cmd, "mode.search_backward", call: :enter_search_backward_mode, desc: "Enter ? search")
130
249
  register_internal_unless(cmd, "buffer.delete_char", call: :delete_char, desc: "Delete char under cursor")
250
+ register_internal_unless(cmd, "buffer.substitute_char", call: :substitute_char, desc: "Substitute char(s)")
251
+ register_internal_unless(cmd, "buffer.swapcase_char", call: :swapcase_char, desc: "Swap case under cursor")
252
+ register_internal_unless(cmd, "buffer.join_lines", call: :join_lines, desc: "Join lines")
131
253
  register_internal_unless(cmd, "buffer.delete_line", call: :delete_line, desc: "Delete current line")
132
254
  register_internal_unless(cmd, "buffer.delete_motion", call: :delete_motion, desc: "Delete by motion")
133
255
  register_internal_unless(cmd, "buffer.change_motion", call: :change_motion, desc: "Change by motion")
@@ -153,12 +275,43 @@ module RuVim
153
275
  register_internal_unless(cmd, "jump.newer", call: :jump_newer, desc: "Jump newer")
154
276
  register_internal_unless(cmd, "editor.buffer_next", call: :buffer_next, desc: "Next buffer")
155
277
  register_internal_unless(cmd, "editor.buffer_prev", call: :buffer_prev, desc: "Previous buffer")
278
+ register_internal_unless(cmd, "editor.buffer_delete", call: :buffer_delete, desc: "Delete buffer")
156
279
  register_internal_unless(cmd, "buffer.replace_char", call: :replace_char, desc: "Replace single char")
280
+ register_internal_unless(cmd, "file.goto_under_cursor", call: :file_goto_under_cursor, desc: "Open file under cursor")
157
281
  register_internal_unless(cmd, "ui.clear_message", call: :clear_message, desc: "Clear message")
282
+ register_internal_unless(cmd, "normal.register_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_register_pending_start) }, desc: "Select register for next operation")
283
+ register_internal_unless(cmd, "normal.operator_delete_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :delete) }, desc: "Start delete operator")
284
+ register_internal_unless(cmd, "normal.operator_yank_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :yank) }, desc: "Start yank operator")
285
+ register_internal_unless(cmd, "normal.operator_change_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :change) }, desc: "Start change operator")
286
+ register_internal_unless(cmd, "normal.operator_indent_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_operator_start, name: :indent) }, desc: "Start indent operator")
287
+ register_internal_unless(cmd, "buffer.indent_lines", call: :indent_lines, desc: "Auto-indent lines")
288
+ register_internal_unless(cmd, "buffer.indent_motion", call: :indent_motion, desc: "Auto-indent motion range")
289
+ register_internal_unless(cmd, "buffer.visual_indent", call: :visual_indent, desc: "Auto-indent visual selection")
290
+ register_internal_unless(cmd, "normal.replace_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_replace_pending_start) }, desc: "Start replace-char pending")
291
+ register_internal_unless(cmd, "normal.find_char_forward_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_pending_start, token: "f") }, desc: "Start char find forward")
292
+ register_internal_unless(cmd, "normal.find_char_backward_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_pending_start, token: "F") }, desc: "Start char find backward")
293
+ register_internal_unless(cmd, "normal.find_till_forward_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_pending_start, token: "t") }, desc: "Start till-char find forward")
294
+ register_internal_unless(cmd, "normal.find_till_backward_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_pending_start, token: "T") }, desc: "Start till-char find backward")
295
+ register_internal_unless(cmd, "normal.find_repeat", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_repeat, reverse: false) }, desc: "Repeat last f/t/F/T")
296
+ register_internal_unless(cmd, "normal.find_repeat_reverse", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_find_repeat, reverse: true) }, desc: "Repeat last f/t/F/T in reverse")
297
+ register_internal_unless(cmd, "normal.change_repeat", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_change_repeat) }, desc: "Repeat last change")
298
+ register_internal_unless(cmd, "normal.macro_record_toggle", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_macro_record_toggle) }, desc: "Start/stop macro recording")
299
+ register_internal_unless(cmd, "normal.macro_play_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_macro_play_pending_start) }, desc: "Start macro play pending")
300
+ register_internal_unless(cmd, "normal.mark_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_mark_pending_start) }, desc: "Start mark set pending")
301
+ register_internal_unless(cmd, "normal.jump_mark_linewise_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_jump_pending_start, linewise: true, repeat_token: "'") }, desc: "Start linewise mark jump pending")
302
+ register_internal_unless(cmd, "normal.jump_mark_exact_pending_start", call: ->(ctx, **) { ctx.editor.invoke_app_action(:normal_jump_pending_start, linewise: false, repeat_token: "`") }, desc: "Start exact mark jump pending")
303
+ register_internal_unless(
304
+ cmd,
305
+ "stdin.stream_stop",
306
+ call: ->(ctx, **) { ctx.editor.stdin_stream_stop_or_cancel! },
307
+ desc: "Stop stdin follow stream (or cancel pending state)"
308
+ )
158
309
 
159
310
  register_ex_unless(ex, "w", call: :file_write, aliases: %w[write], desc: "Write current buffer", nargs: :maybe_one, bang: true)
160
311
  register_ex_unless(ex, "q", call: :app_quit, aliases: %w[quit], desc: "Quit", nargs: 0, bang: true)
312
+ register_ex_unless(ex, "qa", call: :app_quit_all, aliases: %w[qall], desc: "Quit all", nargs: 0, bang: true)
161
313
  register_ex_unless(ex, "wq", call: :file_write_quit, desc: "Write and quit", nargs: :maybe_one, bang: true)
314
+ register_ex_unless(ex, "wqa", call: :file_write_quit_all, aliases: %w[wqall xa xall], desc: "Write all and quit", nargs: 0, bang: true)
162
315
  register_ex_unless(ex, "e", call: :file_edit, aliases: %w[edit], desc: "Edit file / reload", nargs: :maybe_one, bang: true)
163
316
  register_ex_unless(ex, "help", call: :ex_help, desc: "Show help / topics", nargs: :any)
164
317
  register_ex_unless(ex, "command", call: :ex_define_command, desc: "Define user command", nargs: :any, bang: true)
@@ -167,7 +320,14 @@ module RuVim
167
320
  register_ex_unless(ex, "bnext", call: :buffer_next, aliases: %w[bn], desc: "Next buffer", nargs: 0, bang: true)
168
321
  register_ex_unless(ex, "bprev", call: :buffer_prev, aliases: %w[bp], desc: "Previous buffer", nargs: 0, bang: true)
169
322
  register_ex_unless(ex, "buffer", call: :buffer_switch, aliases: %w[b], desc: "Switch buffer", nargs: 1, bang: true)
323
+ register_ex_unless(ex, "bdelete", call: :buffer_delete, aliases: %w[bd], desc: "Delete buffer", nargs: :maybe_one, bang: true)
324
+ register_ex_unless(ex, "args", call: :arglist_show, desc: "Show argument list", nargs: 0)
325
+ register_ex_unless(ex, "next", call: :arglist_next, desc: "Next argument", nargs: 0)
326
+ register_ex_unless(ex, "prev", call: :arglist_prev, desc: "Previous argument", nargs: 0)
327
+ register_ex_unless(ex, "first", call: :arglist_first, desc: "First argument", nargs: 0)
328
+ register_ex_unless(ex, "last", call: :arglist_last, desc: "Last argument", nargs: 0)
170
329
  register_ex_unless(ex, "commands", call: :ex_commands, desc: "List Ex commands", nargs: 0)
330
+ register_ex_unless(ex, "bindings", call: :ex_bindings, desc: "List active key bindings", nargs: :any)
171
331
  register_ex_unless(ex, "set", call: :ex_set, desc: "Set options", nargs: :any)
172
332
  register_ex_unless(ex, "setlocal", call: :ex_setlocal, desc: "Set window/buffer local option", nargs: :any)
173
333
  register_ex_unless(ex, "setglobal", call: :ex_setglobal, desc: "Set global option", nargs: :any)
@@ -176,6 +336,7 @@ module RuVim
176
336
  register_ex_unless(ex, "tabnew", call: :tab_new, desc: "New tab", nargs: :maybe_one)
177
337
  register_ex_unless(ex, "tabnext", call: :tab_next, aliases: %w[tabn], desc: "Next tab", nargs: 0)
178
338
  register_ex_unless(ex, "tabprev", call: :tab_prev, aliases: %w[tabp], desc: "Prev tab", nargs: 0)
339
+ register_ex_unless(ex, "tabs", call: :tab_list, desc: "List tabs", nargs: 0)
179
340
  register_ex_unless(ex, "vimgrep", call: :ex_vimgrep, desc: "Populate quickfix from regex (minimal)", nargs: :any)
180
341
  register_ex_unless(ex, "lvimgrep", call: :ex_lvimgrep, desc: "Populate location list from regex (minimal)", nargs: :any)
181
342
  register_ex_unless(ex, "copen", call: :ex_copen, desc: "Open quickfix list", nargs: 0)
@@ -186,6 +347,15 @@ module RuVim
186
347
  register_ex_unless(ex, "lclose", call: :ex_lclose, desc: "Close location list window", nargs: 0)
187
348
  register_ex_unless(ex, "lnext", call: :ex_lnext, aliases: %w[ln], desc: "Next location item", nargs: 0)
188
349
  register_ex_unless(ex, "lprev", call: :ex_lprev, aliases: %w[lp], desc: "Prev location item", nargs: 0)
350
+ register_ex_unless(ex, "grep", call: :ex_grep, desc: "Search with external grep", nargs: :any)
351
+ register_ex_unless(ex, "lgrep", call: :ex_lgrep, desc: "Search with external grep (location list)", nargs: :any)
352
+ register_ex_unless(ex, "d", call: :ex_delete_lines, aliases: %w[delete], desc: "Delete lines", nargs: :any)
353
+ register_ex_unless(ex, "y", call: :ex_yank_lines, aliases: %w[yank], desc: "Yank lines", nargs: :any)
354
+ register_ex_unless(ex, "rich", call: :ex_rich, desc: "Open/close Rich View", nargs: :maybe_one)
355
+ register_internal_unless(cmd, "rich.toggle", call: :rich_toggle, desc: "Toggle Rich View")
356
+ register_internal_unless(cmd, "quickfix.next", call: :ex_cnext, desc: "Next quickfix item")
357
+ register_internal_unless(cmd, "quickfix.prev", call: :ex_cprev, desc: "Prev quickfix item")
358
+ register_internal_unless(cmd, "quickfix.open", call: :ex_copen, desc: "Open quickfix list")
189
359
  end
190
360
 
191
361
  def bind_default_keys!
@@ -193,6 +363,10 @@ module RuVim
193
363
  @keymaps.bind(:normal, "j", "cursor.down")
194
364
  @keymaps.bind(:normal, "k", "cursor.up")
195
365
  @keymaps.bind(:normal, "l", "cursor.right")
366
+ @keymaps.bind(:normal, ["<Left>"], "cursor.left")
367
+ @keymaps.bind(:normal, ["<Down>"], "cursor.down")
368
+ @keymaps.bind(:normal, ["<Up>"], "cursor.up")
369
+ @keymaps.bind(:normal, ["<Right>"], "cursor.right")
196
370
  @keymaps.bind(:normal, "0", "cursor.line_start")
197
371
  @keymaps.bind(:normal, "$", "cursor.line_end")
198
372
  @keymaps.bind(:normal, "^", "cursor.first_nonblank")
@@ -216,198 +390,248 @@ module RuVim
216
390
  @keymaps.bind(:normal, ["<C-w>", "j"], "window.focus_down")
217
391
  @keymaps.bind(:normal, ["<C-w>", "k"], "window.focus_up")
218
392
  @keymaps.bind(:normal, ["<C-w>", "l"], "window.focus_right")
393
+ @keymaps.bind(:normal, ["<S-Left>"], "window.focus_or_split_left")
394
+ @keymaps.bind(:normal, ["<S-Right>"], "window.focus_or_split_right")
395
+ @keymaps.bind(:normal, ["<S-Up>"], "window.focus_or_split_up")
396
+ @keymaps.bind(:normal, ["<S-Down>"], "window.focus_or_split_down")
219
397
  @keymaps.bind(:normal, ":", "mode.command_line")
220
398
  @keymaps.bind(:normal, "/", "mode.search_forward")
221
399
  @keymaps.bind(:normal, "?", "mode.search_backward")
222
400
  @keymaps.bind(:normal, "x", "buffer.delete_char")
401
+ @keymaps.bind(:normal, "X", "buffer.delete_motion", kwargs: { motion: "h" })
402
+ @keymaps.bind(:normal, "s", "buffer.substitute_char")
403
+ @keymaps.bind(:normal, "D", "buffer.delete_motion", kwargs: { motion: "$" })
404
+ @keymaps.bind(:normal, "C", "buffer.change_motion", kwargs: { motion: "$" })
405
+ @keymaps.bind(:normal, "S", "buffer.change_line")
406
+ @keymaps.bind(:normal, "Y", "buffer.yank_line")
407
+ @keymaps.bind(:normal, "J", "buffer.join_lines")
408
+ @keymaps.bind(:normal, "~", "buffer.swapcase_char")
409
+ @keymaps.bind(:normal, "\"", "normal.register_pending_start")
410
+ @keymaps.bind(:normal, "d", "normal.operator_delete_start")
411
+ @keymaps.bind(:normal, "y", "normal.operator_yank_start")
412
+ @keymaps.bind(:normal, "c", "normal.operator_change_start")
413
+ @keymaps.bind(:normal, "=", "normal.operator_indent_start")
414
+ @keymaps.bind(:normal, "r", "normal.replace_pending_start")
415
+ @keymaps.bind(:normal, "f", "normal.find_char_forward_start")
416
+ @keymaps.bind(:normal, "F", "normal.find_char_backward_start")
417
+ @keymaps.bind(:normal, "t", "normal.find_till_forward_start")
418
+ @keymaps.bind(:normal, "T", "normal.find_till_backward_start")
419
+ @keymaps.bind(:normal, ";", "normal.find_repeat")
420
+ @keymaps.bind(:normal, ",", "normal.find_repeat_reverse")
421
+ @keymaps.bind(:normal, ".", "normal.change_repeat")
422
+ @keymaps.bind(:normal, "q", "normal.macro_record_toggle")
423
+ @keymaps.bind(:normal, "@", "normal.macro_play_pending_start")
424
+ @keymaps.bind(:normal, "m", "normal.mark_pending_start")
425
+ @keymaps.bind(:normal, "'", "normal.jump_mark_linewise_pending_start")
426
+ @keymaps.bind(:normal, "`", "normal.jump_mark_exact_pending_start")
223
427
  @keymaps.bind(:normal, "p", "buffer.paste_after")
224
428
  @keymaps.bind(:normal, "P", "buffer.paste_before")
225
429
  @keymaps.bind(:normal, "u", "buffer.undo")
226
430
  @keymaps.bind(:normal, ["<C-r>"], "buffer.redo")
227
431
  @keymaps.bind(:normal, ["<C-o>"], "jump.older")
228
432
  @keymaps.bind(:normal, ["<C-i>"], "jump.newer")
433
+ @keymaps.bind(:normal, ["<C-d>"], "cursor.page_down.half")
434
+ @keymaps.bind(:normal, ["<C-u>"], "cursor.page_up.half")
435
+ @keymaps.bind(:normal, ["<C-f>"], "cursor.page_down.default")
436
+ @keymaps.bind(:normal, ["<C-b>"], "cursor.page_up.default")
437
+ @keymaps.bind(:normal, ["<C-e>"], "window.scroll_down.line")
438
+ @keymaps.bind(:normal, ["<C-y>"], "window.scroll_up.line")
439
+ @keymaps.bind(:normal, "zt", "window.cursor_line_top")
440
+ @keymaps.bind(:normal, "zz", "window.cursor_line_center")
441
+ @keymaps.bind(:normal, "zb", "window.cursor_line_bottom")
442
+ @keymaps.bind(:normal, ["<C-c>"], "stdin.stream_stop")
229
443
  @keymaps.bind(:normal, "n", "search.next")
230
444
  @keymaps.bind(:normal, "N", "search.prev")
231
445
  @keymaps.bind(:normal, "*", "search.word_forward")
232
446
  @keymaps.bind(:normal, "#", "search.word_backward")
233
447
  @keymaps.bind(:normal, "g*", "search.word_forward_partial")
234
448
  @keymaps.bind(:normal, "g#", "search.word_backward_partial")
449
+ @keymaps.bind(:normal, "gf", "file.goto_under_cursor")
450
+ @keymaps.bind(:normal, "gr", "rich.toggle")
451
+ @keymaps.bind(:normal, "Q", "quickfix.open")
452
+ @keymaps.bind(:normal, ["]", "q"], "quickfix.next")
453
+ @keymaps.bind(:normal, ["[", "q"], "quickfix.prev")
454
+ @keymaps.bind(:normal, ["<PageUp>"], "cursor.page_up.default")
455
+ @keymaps.bind(:normal, ["<PageDown>"], "cursor.page_down.default")
235
456
  @keymaps.bind(:normal, "\e", "ui.clear_message")
236
457
  end
237
458
 
238
459
  def handle_key(key)
460
+ mode_before = @editor.mode
461
+ clear_stale_message_before_key(key)
239
462
  @skip_record_for_current_key = false
240
463
  append_dot_change_capture_key(key)
241
- if key == :ctrl_c
464
+ if key == :ctrl_z
465
+ suspend_to_shell
466
+ track_mode_transition(mode_before)
467
+ return
468
+ end
469
+ if key == :ctrl_c && @editor.mode != :normal
242
470
  handle_ctrl_c
471
+ track_mode_transition(mode_before)
243
472
  record_macro_key_if_needed(key)
244
473
  return
245
474
  end
246
475
 
247
476
  case @editor.mode
477
+ when :hit_enter
478
+ handle_hit_enter_key(key)
248
479
  when :insert
249
480
  handle_insert_key(key)
250
481
  when :command_line
251
482
  handle_command_line_key(key)
252
483
  when :visual_char, :visual_line, :visual_block
253
484
  handle_visual_key(key)
485
+ when :rich
486
+ handle_rich_key(key)
254
487
  else
255
488
  handle_normal_key(key)
256
489
  end
490
+ track_mode_transition(mode_before)
257
491
  load_current_ftplugin!
258
492
  record_macro_key_if_needed(key)
493
+ rescue RuVim::CommandError => e
494
+ @editor.echo_error(e.message)
259
495
  end
260
496
 
261
- def handle_normal_key(key)
262
- if arrow_key?(key)
263
- invoke_arrow(key)
264
- return
497
+ def clear_stale_message_before_key(key)
498
+ return if @editor.message.to_s.empty?
499
+ return if @editor.command_line_active?
500
+ return if @editor.hit_enter_active?
501
+
502
+ # Keep the error visible while the user is still dismissing/cancelling;
503
+ # otherwise, the next operation replaces the command-line area naturally.
504
+ return if key == :ctrl_c
505
+
506
+ @editor.clear_message
507
+ end
508
+
509
+ def handle_editor_app_action(name, **kwargs)
510
+ if @editor.rich_mode?
511
+ case name.to_sym
512
+ when :normal_operator_start
513
+ op = (kwargs[:name] || kwargs["name"]).to_sym
514
+ return if op == :delete || op == :change
515
+ when :normal_replace_pending_start, :normal_change_repeat
516
+ return
517
+ end
265
518
  end
266
519
 
267
- if paging_key?(key)
268
- invoke_page_key(key)
269
- return
520
+ case name.to_sym
521
+ when :normal_register_pending_start
522
+ start_register_pending
523
+ when :normal_operator_start
524
+ start_operator_pending((kwargs[:name] || kwargs["name"]).to_sym)
525
+ when :normal_replace_pending_start
526
+ start_replace_pending
527
+ when :normal_find_pending_start
528
+ start_find_pending((kwargs[:token] || kwargs["token"]).to_s)
529
+ when :normal_find_repeat
530
+ repeat_last_find(reverse: !!(kwargs[:reverse] || kwargs["reverse"]))
531
+ when :normal_change_repeat
532
+ repeat_last_change
533
+ when :normal_macro_record_toggle
534
+ toggle_macro_recording_or_start_pending
535
+ when :normal_macro_play_pending_start
536
+ start_macro_play_pending
537
+ when :normal_mark_pending_start
538
+ start_mark_pending
539
+ when :normal_jump_pending_start
540
+ start_jump_pending(
541
+ linewise: !!(kwargs[:linewise] || kwargs["linewise"]),
542
+ repeat_token: (kwargs[:repeat_token] || kwargs["repeat_token"]).to_s
543
+ )
544
+ else
545
+ raise RuVim::CommandError, "Unknown app action: #{name}"
546
+ end
547
+ end
548
+
549
+ def handle_normal_key(key)
550
+ case
551
+ when handle_normal_key_pre_dispatch(key)
552
+ when (token = normalize_key_token(key)).nil?
553
+ when handle_normal_pending_state(token)
554
+ when handle_normal_direct_token(token)
555
+ else
556
+ @pending_keys ||= []
557
+ @pending_keys << token
558
+ resolve_normal_key_sequence
270
559
  end
560
+ end
271
561
 
272
- if digit_key?(key) && count_digit_allowed?(key)
562
+ def handle_normal_key_pre_dispatch(key)
563
+ case
564
+ when key == :enter && handle_list_window_enter
565
+ when digit_key?(key) && count_digit_allowed?(key)
273
566
  @editor.pending_count = (@editor.pending_count.to_s + key).to_i
274
567
  @editor.echo(@editor.pending_count.to_s)
275
568
  @pending_keys = []
276
- return
569
+ else
570
+ return false
277
571
  end
572
+ true
573
+ end
278
574
 
279
- token = normalize_key_token(key)
280
- return if token.nil?
281
-
282
- if @operator_pending
575
+ def handle_normal_pending_state(token)
576
+ case
577
+ when @pending_keys && !@pending_keys.empty?
578
+ @pending_keys << token
579
+ resolve_normal_key_sequence
580
+ when @operator_pending
283
581
  handle_operator_pending_key(token)
284
- return
285
- end
286
-
287
- if @register_pending
582
+ when @register_pending
288
583
  finish_register_pending(token)
289
- return
290
- end
291
-
292
- if @mark_pending
584
+ when @mark_pending
293
585
  finish_mark_pending(token)
294
- return
295
- end
296
-
297
- if @jump_pending
586
+ when @jump_pending
298
587
  finish_jump_pending(token)
299
- return
300
- end
301
-
302
- if @macro_record_pending
588
+ when @macro_record_pending
303
589
  finish_macro_record_pending(token)
304
- return
305
- end
306
-
307
- if @macro_play_pending
590
+ when @macro_play_pending
308
591
  finish_macro_play_pending(token)
309
- return
310
- end
311
-
312
- if @replace_pending
592
+ when @replace_pending
313
593
  handle_replace_pending_key(token)
314
- return
315
- end
316
-
317
- if @find_pending
594
+ when @find_pending
318
595
  finish_find_pending(token)
319
- return
320
- end
321
-
322
- if token == "\""
323
- start_register_pending
324
- return
325
- end
326
-
327
- if token == "d"
328
- start_operator_pending(:delete)
329
- return
330
- end
331
-
332
- if token == "y"
333
- start_operator_pending(:yank)
334
- return
335
- end
336
-
337
- if token == "c"
338
- start_operator_pending(:change)
339
- return
340
- end
341
-
342
- if token == "r"
343
- start_replace_pending
344
- return
345
- end
346
-
347
- if %w[f F t T].include?(token)
348
- start_find_pending(token)
349
- return
350
- end
351
-
352
- if token == ";"
353
- repeat_last_find(reverse: false)
354
- return
355
- end
356
-
357
- if token == ","
358
- repeat_last_find(reverse: true)
359
- return
360
- end
361
-
362
- if token == "."
363
- repeat_last_change
364
- return
365
- end
366
-
367
- if token == "q"
368
- if @editor.macro_recording?
369
- stop_macro_recording
370
- else
371
- start_macro_record_pending
372
- end
373
- return
374
- end
375
-
376
- if token == "@"
377
- start_macro_play_pending
378
- return
379
- end
380
-
381
- if token == "m"
382
- start_mark_pending
383
- return
384
- end
385
-
386
- if token == "'"
387
- start_jump_pending(linewise: true, repeat_token: "'")
388
- return
389
- end
390
-
391
- if token == "`"
392
- start_jump_pending(linewise: false, repeat_token: "`")
393
- return
596
+ else
597
+ return false
394
598
  end
599
+ true
600
+ end
395
601
 
396
- @pending_keys ||= []
397
- @pending_keys << token
602
+ def handle_normal_direct_token(token)
603
+ false
604
+ end
398
605
 
606
+ def resolve_normal_key_sequence
399
607
  match = @keymaps.resolve_with_context(:normal, @pending_keys, editor: @editor)
400
608
  case match.status
401
609
  when :pending, :ambiguous
610
+ if match.status == :ambiguous && match.invocation
611
+ inv = dup_invocation(match.invocation)
612
+ inv.count = @editor.pending_count
613
+ @pending_ambiguous_invocation = inv
614
+ else
615
+ @pending_ambiguous_invocation = nil
616
+ end
617
+ arm_pending_key_timeout
402
618
  return
403
619
  when :match
620
+ clear_pending_key_timeout
404
621
  matched_keys = @pending_keys.dup
405
- repeat_count = @editor.pending_count || 1
622
+ repeat_count = @editor.pending_count
623
+ @pending_keys = []
406
624
  invocation = dup_invocation(match.invocation)
407
625
  invocation.count = repeat_count
626
+ if @editor.rich_mode? && rich_mode_block_command?(invocation.id)
627
+ @editor.pending_count = nil
628
+ @pending_keys = []
629
+ return
630
+ end
408
631
  @dispatcher.dispatch(@editor, invocation)
409
632
  maybe_record_simple_dot_change(invocation, matched_keys, repeat_count)
410
633
  else
634
+ clear_pending_key_timeout
411
635
  @editor.echo_error("Unknown key: #{@pending_keys.join}")
412
636
  end
413
637
  @editor.pending_count = nil
@@ -424,28 +648,27 @@ module RuVim
424
648
  @editor.echo("")
425
649
  when :backspace
426
650
  clear_insert_completion
427
- y, x = @editor.current_buffer.backspace(@editor.current_window.cursor_y, @editor.current_window.cursor_x)
428
- @editor.current_window.cursor_y = y
429
- @editor.current_window.cursor_x = x
651
+ return unless insert_backspace_allowed?
652
+ insert_backspace_in_insert_mode
430
653
  when :ctrl_n
431
654
  insert_complete(+1)
432
655
  when :ctrl_p
433
656
  insert_complete(-1)
434
657
  when :ctrl_i
435
658
  clear_insert_completion
436
- @editor.current_buffer.insert_char(@editor.current_window.cursor_y, @editor.current_window.cursor_x, "\t")
437
- @editor.current_window.cursor_x += 1
659
+ insert_tab_in_insert_mode
438
660
  when :enter
439
661
  clear_insert_completion
440
662
  y, x = @editor.current_buffer.insert_newline(@editor.current_window.cursor_y, @editor.current_window.cursor_x)
663
+ x = apply_insert_autoindent(y, x, previous_row: y - 1)
441
664
  @editor.current_window.cursor_y = y
442
665
  @editor.current_window.cursor_x = x
443
666
  when :left
444
667
  clear_insert_completion
445
- @editor.current_window.move_left(@editor.current_buffer, 1)
668
+ dispatch_insert_cursor_motion("cursor.left")
446
669
  when :right
447
670
  clear_insert_completion
448
- @editor.current_window.move_right(@editor.current_buffer, 1)
671
+ dispatch_insert_cursor_motion("cursor.right")
449
672
  when :up
450
673
  clear_insert_completion
451
674
  @editor.current_window.move_up(@editor.current_buffer, 1)
@@ -461,6 +684,8 @@ module RuVim
461
684
  clear_insert_completion
462
685
  @editor.current_buffer.insert_char(@editor.current_window.cursor_y, @editor.current_window.cursor_x, key)
463
686
  @editor.current_window.cursor_x += 1
687
+ maybe_showmatch_after_insert(key)
688
+ maybe_dedent_after_insert(key)
464
689
  end
465
690
  end
466
691
 
@@ -506,6 +731,8 @@ module RuVim
506
731
  when "d"
507
732
  @visual_pending = nil
508
733
  @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_delete"))
734
+ when "="
735
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: "buffer.visual_indent"))
509
736
  when "\""
510
737
  start_register_pending
511
738
  when "i", "a"
@@ -551,6 +778,7 @@ module RuVim
551
778
  if token == "g"
552
779
  @pending_keys ||= []
553
780
  @pending_keys << token
781
+ arm_pending_key_timeout
554
782
  return
555
783
  end
556
784
 
@@ -559,9 +787,11 @@ module RuVim
559
787
  end
560
788
 
561
789
  if id
562
- count = @editor.pending_count || 1
790
+ clear_pending_key_timeout
791
+ count = @editor.pending_count
563
792
  @dispatcher.dispatch(@editor, CommandInvocation.new(id:, count: count))
564
793
  else
794
+ clear_pending_key_timeout
565
795
  @editor.echo_error("Unknown visual key: #{token}")
566
796
  end
567
797
  ensure
@@ -572,29 +802,87 @@ module RuVim
572
802
  cmd = @editor.command_line
573
803
  case key
574
804
  when :escape
805
+ clear_command_line_completion
806
+ cancel_incsearch_preview_if_any
575
807
  @editor.cancel_command_line
576
808
  when :enter
809
+ clear_command_line_completion
577
810
  line = cmd.text.dup
578
811
  push_command_line_history(cmd.prefix, line)
579
812
  handle_command_line_submit(cmd.prefix, line)
580
813
  when :backspace
814
+ clear_command_line_completion
815
+ if cmd.text.empty? && cmd.cursor.zero?
816
+ cancel_incsearch_preview_if_any
817
+ @editor.cancel_command_line
818
+ return
819
+ end
581
820
  cmd.backspace
582
821
  when :up
822
+ clear_command_line_completion
583
823
  command_line_history_move(-1)
584
824
  when :down
825
+ clear_command_line_completion
585
826
  command_line_history_move(1)
586
827
  when :left
828
+ clear_command_line_completion
587
829
  cmd.move_left
588
830
  when :right
831
+ clear_command_line_completion
589
832
  cmd.move_right
590
833
  else
591
834
  if key == :ctrl_i
592
835
  command_line_complete
593
836
  elsif key.is_a?(String)
837
+ clear_command_line_completion
594
838
  @cmdline_history_index = nil
595
839
  cmd.insert(key)
596
840
  end
597
841
  end
842
+ update_incsearch_preview_if_needed
843
+ end
844
+
845
+ def handle_list_window_enter
846
+ buffer = @editor.current_buffer
847
+ return false unless buffer.kind == :quickfix || buffer.kind == :location_list
848
+
849
+ item_index = @editor.current_window.cursor_y - 2
850
+ if item_index.negative?
851
+ @editor.echo_error("No list item on this line")
852
+ return true
853
+ end
854
+
855
+ source_window_id = buffer.options["ruvim_list_source_window_id"]
856
+ source_window_id = source_window_id.to_i if source_window_id
857
+ source_window_id = nil unless source_window_id && @editor.windows.key?(source_window_id)
858
+
859
+ item =
860
+ if buffer.kind == :quickfix
861
+ @editor.select_quickfix(item_index)
862
+ else
863
+ owner_window_id = source_window_id || @editor.current_window_id
864
+ @editor.select_location_list(item_index, window_id: owner_window_id)
865
+ end
866
+
867
+ unless item
868
+ @editor.echo_error("#{buffer.kind == :quickfix ? 'quickfix' : 'location list'} item not found")
869
+ return true
870
+ end
871
+
872
+ if source_window_id
873
+ @editor.current_window_id = source_window_id
874
+ end
875
+ @editor.jump_to_location(item)
876
+ @editor.echo(
877
+ if buffer.kind == :quickfix
878
+ "qf #{@editor.quickfix_index.to_i + 1}/#{@editor.quickfix_items.length}"
879
+ else
880
+ owner_window_id = source_window_id || @editor.current_window_id
881
+ list = @editor.location_list(owner_window_id)
882
+ "ll #{list[:index].to_i + 1}/#{list[:items].length}"
883
+ end
884
+ )
885
+ true
598
886
  end
599
887
 
600
888
  def arrow_key?(key)
@@ -612,7 +900,7 @@ module RuVim
612
900
  up: "cursor.up",
613
901
  down: "cursor.down"
614
902
  }.fetch(key)
615
- inv = CommandInvocation.new(id:, count: @editor.pending_count || 1)
903
+ inv = CommandInvocation.new(id:, count: @editor.pending_count)
616
904
  @dispatcher.dispatch(@editor, inv)
617
905
  @editor.pending_count = nil
618
906
  @pending_keys = []
@@ -622,7 +910,7 @@ module RuVim
622
910
  id = (key == :pageup ? "cursor.page_up" : "cursor.page_down")
623
911
  inv = CommandInvocation.new(
624
912
  id: id,
625
- count: @editor.pending_count || 1,
913
+ count: @editor.pending_count,
626
914
  kwargs: { page_lines: current_page_step_lines }
627
915
  )
628
916
  @dispatcher.dispatch(@editor, inv)
@@ -630,13 +918,6 @@ module RuVim
630
918
  @pending_keys = []
631
919
  end
632
920
 
633
- def current_page_step_lines
634
- height = @screen.current_window_view_height(@editor)
635
- [height - 1, 1].max
636
- rescue StandardError
637
- 1
638
- end
639
-
640
921
  def digit_key?(key)
641
922
  key.is_a?(String) && key.match?(/\A\d\z/)
642
923
  end
@@ -653,14 +934,30 @@ module RuVim
653
934
  when String then key
654
935
  when :escape then "\e"
655
936
  when :ctrl_r then "<C-r>"
937
+ when :ctrl_d then "<C-d>"
938
+ when :ctrl_u then "<C-u>"
939
+ when :ctrl_f then "<C-f>"
940
+ when :ctrl_b then "<C-b>"
941
+ when :ctrl_e then "<C-e>"
942
+ when :ctrl_y then "<C-y>"
656
943
  when :ctrl_v then "<C-v>"
657
944
  when :ctrl_i then "<C-i>"
658
945
  when :ctrl_o then "<C-o>"
659
946
  when :ctrl_w then "<C-w>"
947
+ when :ctrl_l then "<C-l>"
948
+ when :ctrl_c then "<C-c>"
949
+ when :left then "<Left>"
950
+ when :right then "<Right>"
951
+ when :up then "<Up>"
952
+ when :down then "<Down>"
660
953
  when :home then "<Home>"
661
954
  when :end then "<End>"
662
955
  when :pageup then "<PageUp>"
663
956
  when :pagedown then "<PageDown>"
957
+ when :shift_up then "<S-Up>"
958
+ when :shift_down then "<S-Down>"
959
+ when :shift_left then "<S-Left>"
960
+ when :shift_right then "<S-Right>"
664
961
  else nil
665
962
  end
666
963
  end
@@ -676,23 +973,81 @@ module RuVim
676
973
  )
677
974
  end
678
975
 
976
+ # Rich mode: delegates to normal mode key handling but blocks mutating operations.
977
+ RICH_MODE_BLOCKED_COMMANDS = %w[
978
+ mode.insert mode.append mode.append_line_end mode.insert_nonblank
979
+ mode.open_below mode.open_above
980
+ buffer.delete_char buffer.delete_line buffer.delete_motion
981
+ buffer.change_motion buffer.change_line
982
+ buffer.paste_after buffer.paste_before
983
+ buffer.replace_char
984
+ buffer.visual_delete
985
+ ].freeze
986
+
987
+ def handle_hit_enter_key(key)
988
+ token = normalize_key_token(key)
989
+ case token
990
+ when ":"
991
+ @editor.exit_hit_enter_mode
992
+ @editor.enter_command_line_mode(":")
993
+ when "/", "?"
994
+ @editor.exit_hit_enter_mode
995
+ @editor.enter_command_line_mode(token)
996
+ else
997
+ @editor.exit_hit_enter_mode
998
+ end
999
+ end
1000
+
1001
+ def handle_rich_key(key)
1002
+ token = normalize_key_token(key)
1003
+ if token == "\e"
1004
+ RuVim::RichView.close!(@editor)
1005
+ return
1006
+ end
1007
+
1008
+ handle_normal_key(key)
1009
+ end
1010
+
1011
+ def rich_mode_block_command?(command_id)
1012
+ RICH_MODE_BLOCKED_COMMANDS.include?(command_id.to_s)
1013
+ end
1014
+
679
1015
  def handle_ctrl_c
680
1016
  case @editor.mode
1017
+ when :hit_enter
1018
+ @editor.exit_hit_enter_mode
681
1019
  when :insert
682
1020
  finish_insert_change_group
683
1021
  finish_dot_change_capture
684
1022
  clear_insert_completion
1023
+ clear_pending_key_timeout
685
1024
  @editor.enter_normal_mode
686
1025
  @editor.echo("")
687
1026
  when :command_line
1027
+ clear_pending_key_timeout
1028
+ cancel_incsearch_preview_if_any
688
1029
  @editor.cancel_command_line
689
1030
  when :visual_char, :visual_line, :visual_block
690
1031
  @visual_pending = nil
691
1032
  @register_pending = false
692
1033
  @mark_pending = false
693
1034
  @jump_pending = nil
1035
+ clear_pending_key_timeout
694
1036
  @editor.enter_normal_mode
1037
+ when :rich
1038
+ clear_pending_key_timeout
1039
+ @editor.pending_count = nil
1040
+ @pending_keys = []
1041
+ @operator_pending = nil
1042
+ @replace_pending = nil
1043
+ @register_pending = false
1044
+ @mark_pending = false
1045
+ @jump_pending = nil
1046
+ @macro_record_pending = false
1047
+ @macro_play_pending = false
1048
+ RuVim::RichView.close!(@editor)
695
1049
  else
1050
+ clear_pending_key_timeout
696
1051
  @editor.pending_count = nil
697
1052
  @pending_keys = []
698
1053
  @operator_pending = nil
@@ -706,11 +1061,34 @@ module RuVim
706
1061
  end
707
1062
  end
708
1063
 
1064
+ def handle_normal_ctrl_c
1065
+ clear_pending_key_timeout
1066
+ @editor.pending_count = nil
1067
+ @pending_keys = []
1068
+ @operator_pending = nil
1069
+ @replace_pending = nil
1070
+ @register_pending = false
1071
+ @mark_pending = false
1072
+ @jump_pending = nil
1073
+ @macro_record_pending = false
1074
+ @macro_play_pending = false
1075
+ @editor.clear_message
1076
+ end
1077
+
1078
+ def suspend_to_shell
1079
+ @terminal.suspend_for_tstp
1080
+ @screen.invalidate_cache! if @screen.respond_to?(:invalidate_cache!)
1081
+ @needs_redraw = true
1082
+ rescue StandardError => e
1083
+ @editor.echo_error("suspend failed: #{e.message}")
1084
+ end
1085
+
709
1086
  def finish_insert_change_group
710
1087
  @editor.current_buffer.end_change_group
711
1088
  end
712
1089
 
713
1090
  def handle_command_line_submit(prefix, line)
1091
+ clear_incsearch_preview_state(apply: false) if %w[/ ?].include?(prefix)
714
1092
  case prefix
715
1093
  when ":"
716
1094
  verbose_log(2, "ex: #{line}")
@@ -729,7 +1107,7 @@ module RuVim
729
1107
  end
730
1108
 
731
1109
  def start_operator_pending(name)
732
- @operator_pending = { name:, count: (@editor.pending_count || 1) }
1110
+ @operator_pending = { name:, count: @editor.pending_count }
733
1111
  @editor.pending_count = nil
734
1112
  @pending_keys = []
735
1113
  @editor.echo(name == :delete ? "d" : name.to_s)
@@ -804,6 +1182,14 @@ module RuVim
804
1182
  @editor.echo("q")
805
1183
  end
806
1184
 
1185
+ def toggle_macro_recording_or_start_pending
1186
+ if @editor.macro_recording?
1187
+ stop_macro_recording
1188
+ else
1189
+ start_macro_record_pending
1190
+ end
1191
+ end
1192
+
807
1193
  def finish_macro_record_pending(token)
808
1194
  @macro_record_pending = false
809
1195
  if token == "\e"
@@ -851,7 +1237,7 @@ module RuVim
851
1237
  return
852
1238
  end
853
1239
 
854
- count = @editor.pending_count || 1
1240
+ count = @editor.pending_count
855
1241
  @editor.pending_count = nil
856
1242
  play_macro(name, count:)
857
1243
  end
@@ -873,7 +1259,7 @@ module RuVim
873
1259
  @last_macro_name = reg
874
1260
  @macro_play_stack << reg
875
1261
  @suspend_macro_recording_depth = (@suspend_macro_recording_depth || 0) + 1
876
- count.times do
1262
+ [count.to_i, 1].max.times do
877
1263
  keys.each { |k| handle_key(dup_macro_runtime_key(k)) }
878
1264
  end
879
1265
  @editor.echo("@#{reg}")
@@ -944,6 +1330,18 @@ module RuVim
944
1330
  return
945
1331
  end
946
1332
 
1333
+ if op[:name] == :indent && motion == "="
1334
+ inv = CommandInvocation.new(id: "buffer.indent_lines", count: op[:count])
1335
+ @dispatcher.dispatch(@editor, inv)
1336
+ return
1337
+ end
1338
+
1339
+ if op[:name] == :indent
1340
+ inv = CommandInvocation.new(id: "buffer.indent_motion", count: op[:count], kwargs: { motion: motion })
1341
+ @dispatcher.dispatch(@editor, inv)
1342
+ return
1343
+ end
1344
+
947
1345
  if op[:name] == :change && motion == "c"
948
1346
  inv = CommandInvocation.new(id: "buffer.change_line", count: op[:count])
949
1347
  @dispatcher.dispatch(@editor, inv)
@@ -962,7 +1360,7 @@ module RuVim
962
1360
  end
963
1361
 
964
1362
  def start_replace_pending
965
- @replace_pending = { count: (@editor.pending_count || 1) }
1363
+ @replace_pending = { count: @editor.pending_count }
966
1364
  @editor.pending_count = nil
967
1365
  @pending_keys = []
968
1366
  @editor.echo("r")
@@ -1003,9 +1401,9 @@ module RuVim
1003
1401
  return if (@dot_replay_depth || 0).positive?
1004
1402
 
1005
1403
  case invocation.id
1006
- when "buffer.delete_char", "buffer.paste_after", "buffer.paste_before"
1404
+ when "buffer.delete_char", "buffer.delete_motion", "buffer.join_lines", "buffer.swapcase_char", "buffer.paste_after", "buffer.paste_before"
1007
1405
  record_last_change_keys(count_prefixed_keys(count, matched_keys))
1008
- when "mode.insert", "mode.append", "mode.append_line_end", "mode.insert_nonblank", "mode.open_below", "mode.open_above"
1406
+ 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"
1009
1407
  begin_dot_change_capture(count_prefixed_keys(count, matched_keys)) if @editor.mode == :insert
1010
1408
  end
1011
1409
  end
@@ -1050,7 +1448,7 @@ module RuVim
1050
1448
  @find_pending = {
1051
1449
  direction: (token == "f" || token == "t") ? :forward : :backward,
1052
1450
  till: (token == "t" || token == "T"),
1053
- count: (@editor.pending_count || 1)
1451
+ count: @editor.pending_count
1054
1452
  }
1055
1453
  @editor.pending_count = nil
1056
1454
  @pending_keys = []
@@ -1095,7 +1493,7 @@ module RuVim
1095
1493
  else
1096
1494
  last[:direction]
1097
1495
  end
1098
- count = @editor.pending_count || 1
1496
+ count = @editor.pending_count
1099
1497
  @editor.pending_count = nil
1100
1498
  @pending_keys = []
1101
1499
  moved = perform_find_on_line(char: last[:char], direction:, till: last[:till], count:)
@@ -1109,7 +1507,7 @@ module RuVim
1109
1507
  pos = win.cursor_x
1110
1508
  target = nil
1111
1509
 
1112
- count.times do
1510
+ [count.to_i, 1].max.times do
1113
1511
  idx =
1114
1512
  if direction == :forward
1115
1513
  line.index(char, pos + 1)
@@ -1163,6 +1561,66 @@ module RuVim
1163
1561
  @cmdline_history_index = nil
1164
1562
  end
1165
1563
 
1564
+ def load_command_line_history!
1565
+ path = command_line_history_file_path
1566
+ return unless path
1567
+ return unless File.file?(path)
1568
+
1569
+ raw = File.read(path)
1570
+ data = JSON.parse(raw)
1571
+ return unless data.is_a?(Hash)
1572
+
1573
+ loaded = Hash.new { |h, k| h[k] = [] }
1574
+ data.each do |prefix, items|
1575
+ key = prefix.to_s
1576
+ next unless [":", "/", "?"].include?(key)
1577
+ next unless items.is_a?(Array)
1578
+
1579
+ hist = loaded[key]
1580
+ items.each do |item|
1581
+ text = item.to_s
1582
+ next if text.empty?
1583
+
1584
+ hist.delete(text)
1585
+ hist << text
1586
+ end
1587
+ hist.shift while hist.length > 100
1588
+ end
1589
+ @cmdline_history = loaded
1590
+ rescue StandardError => e
1591
+ verbose_log(1, "history load error: #{e.message}")
1592
+ end
1593
+
1594
+ def save_command_line_history!
1595
+ path = command_line_history_file_path
1596
+ return unless path
1597
+
1598
+ payload = {
1599
+ ":" => Array(@cmdline_history[":"]).map(&:to_s).last(100),
1600
+ "/" => Array(@cmdline_history["/"]).map(&:to_s).last(100),
1601
+ "?" => Array(@cmdline_history["?"]).map(&:to_s).last(100)
1602
+ }
1603
+
1604
+ FileUtils.mkdir_p(File.dirname(path))
1605
+ tmp = "#{path}.tmp"
1606
+ File.write(tmp, JSON.pretty_generate(payload) + "\n")
1607
+ File.rename(tmp, path)
1608
+ rescue StandardError => e
1609
+ verbose_log(1, "history save error: #{e.message}")
1610
+ end
1611
+
1612
+ def command_line_history_file_path
1613
+ xdg_state_home = ENV["XDG_STATE_HOME"].to_s
1614
+ if !xdg_state_home.empty?
1615
+ return File.join(xdg_state_home, "ruvim", "history.json")
1616
+ end
1617
+
1618
+ home = ENV["HOME"].to_s
1619
+ return nil if home.empty?
1620
+
1621
+ File.join(home, ".ruvim", "history.json")
1622
+ end
1623
+
1166
1624
  def command_line_history_move(delta)
1167
1625
  cmd = @editor.command_line
1168
1626
  hist = @cmdline_history[cmd.prefix]
@@ -1181,6 +1639,7 @@ module RuVim
1181
1639
  else
1182
1640
  cmd.replace_text(hist[@cmdline_history_index])
1183
1641
  end
1642
+ update_incsearch_preview_if_needed
1184
1643
  end
1185
1644
 
1186
1645
  def command_line_complete
@@ -1190,25 +1649,173 @@ module RuVim
1190
1649
  ctx = ex_completion_context(cmd)
1191
1650
  return unless ctx
1192
1651
 
1193
- matches = ex_completion_candidates(ctx)
1652
+ matches = reusable_command_line_completion_matches(cmd, ctx) || ex_completion_candidates(ctx)
1194
1653
  case matches.length
1195
1654
  when 0
1655
+ clear_command_line_completion
1196
1656
  @editor.echo("No completion")
1197
1657
  when 1
1658
+ clear_command_line_completion
1198
1659
  cmd.replace_span(ctx[:token_start], ctx[:token_end], matches.first)
1199
1660
  else
1200
- prefix = common_prefix(matches)
1201
- cmd.replace_span(ctx[:token_start], ctx[:token_end], prefix) if prefix.length > ctx[:prefix].length
1202
- @editor.echo(matches.join(" "))
1661
+ apply_wildmode_completion(cmd, ctx, matches)
1662
+ end
1663
+ update_incsearch_preview_if_needed
1664
+ end
1665
+
1666
+ def reusable_command_line_completion_matches(cmd, ctx)
1667
+ state = @cmdline_completion
1668
+ return nil unless state
1669
+ return nil unless state[:prefix] == cmd.prefix
1670
+ return nil unless state[:kind] == ctx[:kind]
1671
+ return nil unless state[:command] == ctx[:command]
1672
+ return nil unless state[:arg_index] == ctx[:arg_index]
1673
+ return nil unless state[:token_start] == ctx[:token_start]
1674
+
1675
+ before_text = cmd.text[0...ctx[:token_start]].to_s
1676
+ after_text = cmd.text[ctx[:token_end]..].to_s
1677
+ return nil unless state[:before_text] == before_text
1678
+ return nil unless state[:after_text] == after_text
1679
+
1680
+ matches = Array(state[:matches]).map(&:to_s)
1681
+ return nil if matches.empty?
1682
+
1683
+ current_token = cmd.text[ctx[:token_start]...ctx[:token_end]].to_s
1684
+ return nil unless current_token.empty? || matches.include?(current_token) || common_prefix(matches).start_with?(current_token) || current_token.start_with?(common_prefix(matches))
1685
+
1686
+ matches
1687
+ end
1688
+
1689
+ def clear_command_line_completion
1690
+ @cmdline_completion = nil
1691
+ end
1692
+
1693
+ def apply_wildmode_completion(cmd, ctx, matches)
1694
+ mode_steps = wildmode_steps
1695
+ mode_steps = [:full] if mode_steps.empty?
1696
+ state = @cmdline_completion
1697
+ before_text = cmd.text[0...ctx[:token_start]].to_s
1698
+ after_text = cmd.text[ctx[:token_end]..].to_s
1699
+ same = state &&
1700
+ state[:prefix] == cmd.prefix &&
1701
+ state[:kind] == ctx[:kind] &&
1702
+ state[:command] == ctx[:command] &&
1703
+ state[:arg_index] == ctx[:arg_index] &&
1704
+ state[:token_start] == ctx[:token_start] &&
1705
+ state[:before_text] == before_text &&
1706
+ state[:after_text] == after_text &&
1707
+ state[:matches] == matches
1708
+ unless same
1709
+ state = {
1710
+ prefix: cmd.prefix,
1711
+ kind: ctx[:kind],
1712
+ command: ctx[:command],
1713
+ arg_index: ctx[:arg_index],
1714
+ token_start: ctx[:token_start],
1715
+ before_text: before_text,
1716
+ after_text: after_text,
1717
+ matches: matches.dup,
1718
+ step_index: -1,
1719
+ full_index: nil
1720
+ }
1721
+ end
1722
+
1723
+ state[:step_index] += 1
1724
+ step = mode_steps[state[:step_index] % mode_steps.length]
1725
+ case step
1726
+ when :longest
1727
+ pref = common_prefix(matches)
1728
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], pref) if pref.length > ctx[:prefix].length
1729
+ when :list
1730
+ show_command_line_completion_menu(matches, selected: state[:full_index], force: true)
1731
+ when :full
1732
+ state[:full_index] = state[:full_index] ? (state[:full_index] + 1) % matches.length : 0
1733
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], matches[state[:full_index]])
1734
+ show_command_line_completion_menu(matches, selected: state[:full_index], force: false)
1735
+ else
1736
+ pref = common_prefix(matches)
1737
+ cmd.replace_span(ctx[:token_start], ctx[:token_end], pref) if pref.length > ctx[:prefix].length
1203
1738
  end
1739
+
1740
+ @cmdline_completion = state
1204
1741
  end
1205
1742
 
1206
- def common_prefix(strings)
1207
- return "" if strings.empty?
1743
+ def wildmode_steps
1744
+ raw = @editor.effective_option("wildmode").to_s
1745
+ return [:full] if raw.empty?
1208
1746
 
1209
- prefix = strings.first.dup
1210
- strings[1..]&.each do |s|
1211
- while !prefix.empty? && !s.start_with?(prefix)
1747
+ raw.split(",").flat_map do |tok|
1748
+ tok.to_s.split(":").map do |part|
1749
+ case part.strip.downcase
1750
+ when "longest" then :longest
1751
+ when "list" then :list
1752
+ when "full" then :full
1753
+ end
1754
+ end
1755
+ end.compact
1756
+ end
1757
+
1758
+ def show_command_line_completion_menu(matches, selected:, force:)
1759
+ return unless force || @editor.effective_option("wildmenu")
1760
+
1761
+ items = matches.each_with_index.map do |m, i|
1762
+ idx = i
1763
+ idx == selected ? "[#{m}]" : m
1764
+ end
1765
+ @editor.echo(compose_command_line_completion_menu(items))
1766
+ end
1767
+
1768
+ def compose_command_line_completion_menu(items)
1769
+ parts = Array(items).map(&:to_s)
1770
+ return "" if parts.empty?
1771
+
1772
+ width = command_line_completion_menu_width
1773
+ width = [width.to_i, 1].max
1774
+ out = +""
1775
+ shown = 0
1776
+
1777
+ parts.each_with_index do |item, idx|
1778
+ token = shown.zero? ? item : " #{item}"
1779
+ if out.empty? && token.length > width
1780
+ out = token[0, width]
1781
+ shown = 1
1782
+ break
1783
+ end
1784
+ break if out.length + token.length > width
1785
+
1786
+ out << token
1787
+ shown = idx + 1
1788
+ end
1789
+
1790
+ if shown < parts.length
1791
+ ellipsis = (out.empty? ? "..." : " ...")
1792
+ if out.length + ellipsis.length <= width
1793
+ out << ellipsis
1794
+ elsif width >= 3
1795
+ out = out[0, width - 3] + "..."
1796
+ else
1797
+ out = "." * width
1798
+ end
1799
+ end
1800
+
1801
+ out
1802
+ end
1803
+
1804
+ def command_line_completion_menu_width
1805
+ return 80 unless defined?(@terminal) && @terminal && @terminal.respond_to?(:winsize)
1806
+
1807
+ _rows, cols = @terminal.winsize
1808
+ [cols.to_i, 1].max
1809
+ rescue StandardError
1810
+ 80
1811
+ end
1812
+
1813
+ def common_prefix(strings)
1814
+ return "" if strings.empty?
1815
+
1816
+ prefix = strings.first.dup
1817
+ strings[1..]&.each do |s|
1818
+ while !prefix.empty? && !s.start_with?(prefix)
1212
1819
  prefix = prefix[0...-1]
1213
1820
  end
1214
1821
  end
@@ -1219,6 +1826,98 @@ module RuVim
1219
1826
  @insert_completion = nil
1220
1827
  end
1221
1828
 
1829
+ def insert_tab_in_insert_mode
1830
+ buf = @editor.current_buffer
1831
+ win = @editor.current_window
1832
+ if @editor.effective_option("expandtab", window: win, buffer: buf)
1833
+ width = @editor.effective_option("softtabstop", window: win, buffer: buf).to_i
1834
+ width = @editor.effective_option("tabstop", window: win, buffer: buf).to_i if width <= 0
1835
+ width = 2 if width <= 0
1836
+ line = buf.line_at(win.cursor_y)
1837
+ current_col = RuVim::TextMetrics.screen_col_for_char_index(line, win.cursor_x, tabstop: effective_tabstop(win, buf))
1838
+ spaces = width - (current_col % width)
1839
+ spaces = width if spaces <= 0
1840
+ _y, x = buf.insert_text(win.cursor_y, win.cursor_x, " " * spaces)
1841
+ win.cursor_x = x
1842
+ else
1843
+ buf.insert_char(win.cursor_y, win.cursor_x, "\t")
1844
+ win.cursor_x += 1
1845
+ end
1846
+ end
1847
+
1848
+ def apply_insert_autoindent(row, x, previous_row:)
1849
+ buf = @editor.current_buffer
1850
+ win = @editor.current_window
1851
+ return x unless @editor.effective_option("autoindent", window: win, buffer: buf)
1852
+ return x if previous_row.negative?
1853
+
1854
+ prev = buf.line_at(previous_row)
1855
+ indent = prev[/\A[ \t]*/].to_s
1856
+ if @editor.effective_option("smartindent", window: win, buffer: buf)
1857
+ trimmed = prev.rstrip
1858
+ needs_indent = trimmed.end_with?("{", "[", "(")
1859
+ if !needs_indent
1860
+ needs_indent = buf.lang_module.indent_trigger?(trimmed)
1861
+ end
1862
+ if needs_indent
1863
+ sw = @editor.effective_option("shiftwidth", window: win, buffer: buf).to_i
1864
+ sw = effective_tabstop(win, buf) if sw <= 0
1865
+ sw = 2 if sw <= 0
1866
+ indent += " " * sw
1867
+ end
1868
+ end
1869
+ return x if indent.empty?
1870
+
1871
+ _y, new_x = buf.insert_text(row, x, indent)
1872
+ new_x
1873
+ end
1874
+
1875
+ def maybe_showmatch_after_insert(key)
1876
+ return unless [")", "]", "}"].include?(key)
1877
+ return unless @editor.effective_option("showmatch")
1878
+
1879
+ mt = @editor.effective_option("matchtime").to_i
1880
+ mt = 5 if mt <= 0
1881
+ @editor.echo_temporary("match", duration_seconds: mt * 0.1)
1882
+ end
1883
+
1884
+ def maybe_dedent_after_insert(key)
1885
+ return unless @editor.effective_option("smartindent", window: @editor.current_window, buffer: @editor.current_buffer)
1886
+
1887
+ buf = @editor.current_buffer
1888
+ lang_mod = buf.lang_module
1889
+
1890
+ pattern = lang_mod.dedent_trigger(key)
1891
+ return unless pattern
1892
+
1893
+ row = @editor.current_window.cursor_y
1894
+ line = buf.line_at(row)
1895
+ m = line.match(pattern)
1896
+ return unless m
1897
+
1898
+ sw = @editor.effective_option("shiftwidth", buffer: buf).to_i
1899
+ sw = 2 if sw <= 0
1900
+ target_indent = lang_mod.calculate_indent(buf.lines, row, sw)
1901
+ return unless target_indent
1902
+
1903
+ current_indent = m[1].length
1904
+ return if current_indent == target_indent
1905
+
1906
+ stripped = line.to_s.strip
1907
+ buf.delete_span(row, 0, row, current_indent) if current_indent > 0
1908
+ buf.insert_text(row, 0, " " * target_indent) if target_indent > 0
1909
+ @editor.current_window.cursor_x = target_indent + stripped.length
1910
+ end
1911
+
1912
+ def clear_expired_transient_message_if_any
1913
+ @needs_redraw = true if @editor.clear_expired_transient_message!(now: monotonic_now)
1914
+ end
1915
+
1916
+ def effective_tabstop(window = @editor.current_window, buffer = @editor.current_buffer)
1917
+ v = @editor.effective_option("tabstop", window:, buffer:).to_i
1918
+ v.positive? ? v : 2
1919
+ end
1920
+
1222
1921
  def insert_complete(direction)
1223
1922
  state = ensure_insert_completion_state
1224
1923
  return unless state
@@ -1229,8 +1928,27 @@ module RuVim
1229
1928
  return
1230
1929
  end
1231
1930
 
1931
+ if state[:index].nil? && insert_completion_noselect? && matches.length > 1
1932
+ show_insert_completion_menu(matches, selected: nil)
1933
+ state[:index] = :pending_select
1934
+ return
1935
+ end
1936
+
1937
+ if state[:index].nil? && insert_completion_noinsert?
1938
+ preview_idx = direction.positive? ? 0 : matches.length - 1
1939
+ state[:index] = :pending_insert
1940
+ state[:pending_index] = preview_idx
1941
+ show_insert_completion_menu(matches, selected: preview_idx, current: matches[preview_idx])
1942
+ return
1943
+ end
1944
+
1232
1945
  idx = state[:index]
1233
- idx = idx.nil? ? (direction.positive? ? 0 : matches.length - 1) : (idx + direction) % matches.length
1946
+ idx = nil if idx == :pending_select
1947
+ if idx == :pending_insert
1948
+ idx = state.delete(:pending_index) || (direction.positive? ? 0 : matches.length - 1)
1949
+ else
1950
+ idx = idx.nil? ? (direction.positive? ? 0 : matches.length - 1) : (idx + direction) % matches.length
1951
+ end
1234
1952
  replacement = matches[idx]
1235
1953
 
1236
1954
  end_col = state[:current_end_col]
@@ -1241,17 +1959,51 @@ module RuVim
1241
1959
  @editor.current_window.cursor_x = new_x
1242
1960
  state[:index] = idx
1243
1961
  state[:current_end_col] = start_col + replacement.length
1244
- @editor.echo(matches.length == 1 ? replacement : "#{replacement} (#{idx + 1}/#{matches.length})")
1962
+ if matches.length == 1
1963
+ @editor.echo(replacement)
1964
+ else
1965
+ show_insert_completion_menu(matches, selected: idx, current: replacement)
1966
+ end
1245
1967
  rescue StandardError => e
1246
1968
  @editor.echo_error("Completion error: #{e.message}")
1247
1969
  clear_insert_completion
1248
1970
  end
1249
1971
 
1972
+ def insert_completion_noselect?
1973
+ @editor.effective_option("completeopt").to_s.split(",").map { |s| s.strip.downcase }.include?("noselect")
1974
+ end
1975
+
1976
+ def insert_completion_noinsert?
1977
+ @editor.effective_option("completeopt").to_s.split(",").map { |s| s.strip.downcase }.include?("noinsert")
1978
+ end
1979
+
1980
+ def insert_completion_menu_enabled?
1981
+ opts = @editor.effective_option("completeopt").to_s.split(",").map { |s| s.strip.downcase }
1982
+ opts.include?("menu") || opts.include?("menuone")
1983
+ end
1984
+
1985
+ def show_insert_completion_menu(matches, selected:, current: nil)
1986
+ if insert_completion_menu_enabled?
1987
+ limit = [@editor.effective_option("pumheight").to_i, 1].max
1988
+ items = matches.first(limit).each_with_index.map do |m, i|
1989
+ i == selected ? "[#{m}]" : m
1990
+ end
1991
+ items << "..." if matches.length > limit
1992
+ if current
1993
+ @editor.echo("#{current} (#{selected + 1}/#{matches.length}) | #{items.join(' ')}")
1994
+ else
1995
+ @editor.echo(items.join(" "))
1996
+ end
1997
+ elsif current
1998
+ @editor.echo("#{current} (#{selected + 1}/#{matches.length})")
1999
+ end
2000
+ end
2001
+
1250
2002
  def ensure_insert_completion_state
1251
2003
  row = @editor.current_window.cursor_y
1252
2004
  col = @editor.current_window.cursor_x
1253
2005
  line = @editor.current_buffer.line_at(row)
1254
- prefix = line[0...col].to_s[/[[:alnum:]_]+\z/]
2006
+ prefix = trailing_keyword_fragment(line[0...col].to_s, @editor.current_window, @editor.current_buffer)
1255
2007
  return nil if prefix.nil? || prefix.empty?
1256
2008
 
1257
2009
  start_col = col - prefix.length
@@ -1280,9 +2032,10 @@ module RuVim
1280
2032
  def collect_buffer_word_completions(prefix, current_word:)
1281
2033
  words = []
1282
2034
  seen = {}
2035
+ rx = keyword_scan_regex(@editor.current_window, @editor.current_buffer)
1283
2036
  @editor.buffers.values.each do |buf|
1284
2037
  buf.lines.each do |line|
1285
- line.scan(/[[:alnum:]_]+/) do |w|
2038
+ line.scan(rx) do |w|
1286
2039
  next unless w.start_with?(prefix)
1287
2040
  next if w == current_word
1288
2041
  next if seen[w]
@@ -1295,6 +2048,210 @@ module RuVim
1295
2048
  words.sort
1296
2049
  end
1297
2050
 
2051
+ def track_mode_transition(mode_before)
2052
+ mode_after = @editor.mode
2053
+ if mode_before != :insert && mode_after == :insert
2054
+ @insert_start_location = @editor.current_location
2055
+ elsif mode_before == :insert && mode_after != :insert
2056
+ @insert_start_location = nil
2057
+ end
2058
+
2059
+ if mode_before != :command_line && mode_after == :command_line
2060
+ @incsearch_preview = nil
2061
+ elsif mode_before == :command_line && mode_after != :command_line
2062
+ @incsearch_preview = nil
2063
+ end
2064
+ end
2065
+
2066
+ def insert_backspace_allowed?
2067
+ buf = @editor.current_buffer
2068
+ win = @editor.current_window
2069
+ row = win.cursor_y
2070
+ col = win.cursor_x
2071
+ return false if row.zero? && col.zero?
2072
+
2073
+ opt = @editor.effective_option("backspace", window: win, buffer: buf).to_s
2074
+ allow = opt.split(",").map { |s| s.strip.downcase }.reject(&:empty?)
2075
+ allow_all = allow.include?("2")
2076
+ allow_indent = allow_all || allow.include?("indent")
2077
+
2078
+ if col.zero? && row.positive?
2079
+ return true if allow_all || allow.include?("eol")
2080
+
2081
+ @editor.echo_error("backspace=eol required")
2082
+ return false
2083
+ end
2084
+
2085
+ if @insert_start_location
2086
+ same_buf = @insert_start_location[:buffer_id] == buf.id
2087
+ if same_buf && (row < @insert_start_location[:row] || (row == @insert_start_location[:row] && col <= @insert_start_location[:col]))
2088
+ if allow_all || allow.include?("start")
2089
+ return true
2090
+ end
2091
+
2092
+ if allow_indent && same_row_autoindent_backspace?(buf, row, col)
2093
+ return true
2094
+ end
2095
+
2096
+ @editor.echo_error("backspace=start required")
2097
+ return false
2098
+ end
2099
+ end
2100
+
2101
+ true
2102
+ end
2103
+
2104
+ def insert_backspace_in_insert_mode
2105
+ buf = @editor.current_buffer
2106
+ win = @editor.current_window
2107
+ row = win.cursor_y
2108
+ col = win.cursor_x
2109
+
2110
+ if row >= 0 && col.positive? && try_softtabstop_backspace(buf, win)
2111
+ return
2112
+ end
2113
+
2114
+ y, x = buf.backspace(row, col)
2115
+ win.cursor_y = y
2116
+ win.cursor_x = x
2117
+ end
2118
+
2119
+ def dispatch_insert_cursor_motion(id)
2120
+ @dispatcher.dispatch(@editor, CommandInvocation.new(id: id, count: 1))
2121
+ rescue StandardError => e
2122
+ @editor.echo_error("Motion error: #{e.message}")
2123
+ end
2124
+
2125
+ def try_softtabstop_backspace(buf, win)
2126
+ row = win.cursor_y
2127
+ col = win.cursor_x
2128
+ line = buf.line_at(row)
2129
+ return false unless line
2130
+ return false unless @editor.effective_option("expandtab", window: win, buffer: buf)
2131
+
2132
+ sts = @editor.effective_option("softtabstop", window: win, buffer: buf).to_i
2133
+ sts = @editor.effective_option("tabstop", window: win, buffer: buf).to_i if sts <= 0
2134
+ return false if sts <= 0
2135
+
2136
+ prefix = line[0...col].to_s
2137
+ m = prefix.match(/ +\z/)
2138
+ return false unless m
2139
+
2140
+ run = m[0].length
2141
+ return false if run <= 1
2142
+
2143
+ tabstop = effective_tabstop(win, buf)
2144
+ cur_screen = RuVim::TextMetrics.screen_col_for_char_index(line, col, tabstop:)
2145
+ target_screen = [cur_screen - sts, 0].max
2146
+ target_col = RuVim::TextMetrics.char_index_for_screen_col(line, target_screen, tabstop:, align: :floor)
2147
+ delete_cols = col - target_col
2148
+ delete_cols = [delete_cols, run, sts].min
2149
+ return false if delete_cols <= 1
2150
+
2151
+ # Only collapse whitespace run; if target lands before the run, clamp to run start.
2152
+ run_start = col - run
2153
+ target_col = [target_col, run_start].max
2154
+ delete_cols = col - target_col
2155
+ return false if delete_cols <= 1
2156
+
2157
+ buf.delete_span(row, target_col, row, col)
2158
+ win.cursor_x = target_col
2159
+ true
2160
+ rescue StandardError
2161
+ false
2162
+ end
2163
+
2164
+ def same_row_autoindent_backspace?(buf, row, col)
2165
+ return false unless @insert_start_location
2166
+ return false unless row == @insert_start_location[:row]
2167
+ return false unless col <= @insert_start_location[:col]
2168
+
2169
+ line = buf.line_at(row)
2170
+ line[0...@insert_start_location[:col]].to_s.match?(/\A[ \t]*\z/)
2171
+ rescue StandardError
2172
+ false
2173
+ end
2174
+
2175
+ def incsearch_enabled?
2176
+ return false unless @editor.command_line_active?
2177
+ return false unless ["/", "?"].include?(@editor.command_line.prefix)
2178
+
2179
+ !!@editor.effective_option("incsearch")
2180
+ end
2181
+
2182
+ def update_incsearch_preview_if_needed
2183
+ return unless incsearch_enabled?
2184
+
2185
+ cmd = @editor.command_line
2186
+ ensure_incsearch_preview_origin!(direction: (cmd.prefix == "/" ? :forward : :backward))
2187
+ pattern = cmd.text.to_s
2188
+ if pattern.empty?
2189
+ clear_incsearch_preview_state(apply: false)
2190
+ return
2191
+ end
2192
+
2193
+ buf = @editor.current_buffer
2194
+ win = @editor.current_window
2195
+ origin = @incsearch_preview[:origin]
2196
+ tmp_window = RuVim::Window.new(id: -1, buffer_id: buf.id)
2197
+ tmp_window.cursor_y = origin[:row]
2198
+ tmp_window.cursor_x = origin[:col]
2199
+ regex = GlobalCommands.instance.send(:compile_search_regex, pattern, editor: @editor, window: win, buffer: buf)
2200
+ match = GlobalCommands.instance.send(:find_next_match, buf, tmp_window, regex, direction: @incsearch_preview[:direction])
2201
+ if match
2202
+ win.cursor_y = match[:row]
2203
+ win.cursor_x = match[:col]
2204
+ win.clamp_to_buffer(buf)
2205
+ end
2206
+ @incsearch_preview[:active] = true
2207
+ rescue RuVim::CommandError, RegexpError
2208
+ # Keep editing command-line without forcing an error flash on every keystroke.
2209
+ end
2210
+
2211
+ def ensure_incsearch_preview_origin!(direction:)
2212
+ return if @incsearch_preview
2213
+
2214
+ @incsearch_preview = {
2215
+ origin: @editor.current_location,
2216
+ direction: direction,
2217
+ active: false
2218
+ }
2219
+ end
2220
+
2221
+ def cancel_incsearch_preview_if_any
2222
+ clear_incsearch_preview_state(apply: false)
2223
+ end
2224
+
2225
+ def clear_incsearch_preview_state(apply:)
2226
+ return unless @incsearch_preview
2227
+
2228
+ if !apply && @incsearch_preview[:origin]
2229
+ @editor.jump_to_location(@incsearch_preview[:origin])
2230
+ end
2231
+ @incsearch_preview = nil
2232
+ end
2233
+
2234
+ def trailing_keyword_fragment(prefix_text, window, buffer)
2235
+ cls = keyword_char_class(window, buffer)
2236
+ prefix_text.to_s[/[#{cls}]+\z/]
2237
+ rescue RegexpError
2238
+ prefix_text.to_s[/[[:alnum:]_]+\z/]
2239
+ end
2240
+
2241
+ def keyword_scan_regex(window, buffer)
2242
+ cls = keyword_char_class(window, buffer)
2243
+ /[#{cls}]+/
2244
+ rescue RegexpError
2245
+ /[[:alnum:]_]+/
2246
+ end
2247
+
2248
+ def keyword_char_class(window, buffer)
2249
+ raw = @editor.effective_option("iskeyword", window:, buffer:).to_s
2250
+ RuVim::KeywordChars.char_class(raw)
2251
+ rescue StandardError
2252
+ "[:alnum:]_"
2253
+ end
2254
+
1298
2255
  def ex_completion_context(cmd)
1299
2256
  text = cmd.text
1300
2257
  cursor = cmd.cursor
@@ -1363,18 +2320,45 @@ module RuVim
1363
2320
  else
1364
2321
  File.dirname(input)
1365
2322
  end
1366
- base_dir = "." if base_dir == "."
1367
2323
  partial = input.end_with?("/") ? "" : File.basename(input)
1368
- pattern = input.empty? ? "*" : File.join(base_dir, "#{partial}*")
1369
- Dir.glob(pattern, File::FNM_DOTMATCH).sort.filter_map do |p|
2324
+ pattern =
2325
+ if input.empty?
2326
+ "*"
2327
+ elsif base_dir == "."
2328
+ "#{partial}*"
2329
+ else
2330
+ File.join(base_dir, "#{partial}*")
2331
+ end
2332
+ partial_starts_with_dot = partial.start_with?(".")
2333
+ entries = Dir.glob(pattern, File::FNM_DOTMATCH).filter_map do |p|
1370
2334
  next if [".", ".."].include?(File.basename(p))
1371
2335
  next unless p.start_with?(input) || input.empty?
2336
+ next if wildignore_path?(p)
1372
2337
  File.directory?(p) ? "#{p}/" : p
1373
2338
  end
2339
+ entries.sort_by do |p|
2340
+ base = File.basename(p.to_s.sub(%r{/\z}, ""))
2341
+ hidden_rank = (!partial_starts_with_dot && base.start_with?(".")) ? 1 : 0
2342
+ [hidden_rank, p]
2343
+ end
1374
2344
  rescue StandardError
1375
2345
  []
1376
2346
  end
1377
2347
 
2348
+ def wildignore_path?(path)
2349
+ spec = @editor.global_options["wildignore"].to_s
2350
+ return false if spec.empty?
2351
+
2352
+ flags = @editor.global_options["wildignorecase"] ? File::FNM_CASEFOLD : 0
2353
+ name = path.to_s
2354
+ base = File.basename(name)
2355
+ spec.split(",").map(&:strip).reject(&:empty?).any? do |pat|
2356
+ File.fnmatch?(pat, name, flags) || File.fnmatch?(pat, base, flags)
2357
+ end
2358
+ rescue StandardError
2359
+ false
2360
+ end
2361
+
1378
2362
  def buffer_completion_candidates(prefix)
1379
2363
  pfx = prefix.to_s
1380
2364
  items = @editor.buffers.values.flat_map do |b|
@@ -1538,6 +2522,13 @@ module RuVim
1538
2522
  list = Array(paths).compact
1539
2523
  return if list.empty?
1540
2524
 
2525
+ # Remove the bootstrap empty buffer and reset the ID counter
2526
+ # so the first file gets buffer id 1 (Vim-like behavior).
2527
+ evict_bootstrap_buffer!
2528
+
2529
+ # Initialize arglist with all paths
2530
+ @editor.set_arglist(list)
2531
+
1541
2532
  first, *rest = list
1542
2533
  @editor.open_path(first)
1543
2534
  apply_startup_readonly! if @startup_readonly
@@ -1545,20 +2536,38 @@ module RuVim
1545
2536
 
1546
2537
  case @startup_open_layout
1547
2538
  when :horizontal
2539
+ first_win_id = @editor.current_window_id
1548
2540
  rest.each { |p| open_path_in_split!(p, layout: :horizontal) }
2541
+ @editor.focus_window(first_win_id)
1549
2542
  when :vertical
2543
+ first_win_id = @editor.current_window_id
1550
2544
  rest.each { |p| open_path_in_split!(p, layout: :vertical) }
2545
+ @editor.focus_window(first_win_id)
1551
2546
  when :tab
1552
2547
  rest.each { |p| open_path_in_tab!(p) }
2548
+ @editor.tabnext(-(@editor.tabpage_count - 1))
1553
2549
  else
1554
- # No multi-file layout mode yet; ignore extras if called directly.
2550
+ # Load remaining files as buffers (Vim-like behavior).
2551
+ rest.each { |p| @editor.add_buffer_from_file(p) }
2552
+ end
2553
+ end
2554
+
2555
+ # Remove the bootstrap empty buffer before opening real files,
2556
+ # resetting the buffer ID counter so the first file gets id 1.
2557
+ def evict_bootstrap_buffer!
2558
+ bid = @editor.buffer_ids.find do |id|
2559
+ b = @editor.buffers[id]
2560
+ b.path.nil? && !b.modified? && b.line_count <= 1 && b.kind == :file
1555
2561
  end
2562
+ return unless bid
2563
+
2564
+ @editor.buffers.delete(bid)
2565
+ @editor.instance_variable_set(:@next_buffer_id, 1)
1556
2566
  end
1557
2567
 
1558
2568
  def open_path_in_split!(path, layout:)
1559
2569
  @editor.split_current_window(layout:)
1560
- buf = @editor.add_buffer_from_file(path)
1561
- @editor.switch_to_buffer(buf.id)
2570
+ @editor.open_path(path)
1562
2571
  apply_startup_readonly! if @startup_readonly
1563
2572
  apply_startup_nomodifiable! if @startup_nomodifiable
1564
2573
  end
@@ -1569,6 +2578,368 @@ module RuVim
1569
2578
  apply_startup_nomodifiable! if @startup_nomodifiable
1570
2579
  end
1571
2580
 
2581
+ def open_path_with_large_file_support(path)
2582
+ return @editor.open_path_sync(path) unless should_open_path_async?(path)
2583
+ return @editor.open_path_sync(path) unless can_start_async_file_load?
2584
+
2585
+ open_path_asynchronously!(path)
2586
+ end
2587
+
2588
+ def should_open_path_async?(path)
2589
+ p = path.to_s
2590
+ return false if p.empty?
2591
+ return false unless File.file?(p)
2592
+
2593
+ File.size(p) >= large_file_async_threshold_bytes
2594
+ rescue StandardError
2595
+ false
2596
+ end
2597
+
2598
+ def can_start_async_file_load?
2599
+ @async_file_loads.empty?
2600
+ end
2601
+
2602
+ def large_file_async_threshold_bytes
2603
+ raw = ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"]
2604
+ n = raw.to_i if raw
2605
+ return n if n && n.positive?
2606
+
2607
+ LARGE_FILE_ASYNC_THRESHOLD_BYTES
2608
+ end
2609
+
2610
+ def open_path_asynchronously!(path)
2611
+ file_size = File.size(path)
2612
+ buf = @editor.add_empty_buffer(path: path)
2613
+ @editor.switch_to_buffer(buf.id)
2614
+ buf.loading_state = :live
2615
+ buf.modified = false
2616
+
2617
+ ensure_stream_event_queue!
2618
+ io = File.open(path, "rb")
2619
+ state = { path: path, io: io, thread: nil, ended_with_newline: false }
2620
+ staged_prefix_bytes = async_file_staged_prefix_bytes
2621
+ staged_mode = file_size > staged_prefix_bytes
2622
+ if staged_mode
2623
+ prefix = io.read(staged_prefix_bytes) || "".b
2624
+ unless prefix.empty?
2625
+ buf.append_stream_text!(Buffer.decode_text(prefix))
2626
+ state[:ended_with_newline] = prefix.end_with?("\n")
2627
+ end
2628
+ end
2629
+
2630
+ if io.eof?
2631
+ buf.finalize_async_file_load!(ended_with_newline: state[:ended_with_newline])
2632
+ buf.loading_state = :closed
2633
+ io.close unless io.closed?
2634
+ return buf
2635
+ end
2636
+
2637
+ @async_file_loads[buf.id] = state
2638
+ state[:thread] = start_async_file_loader_thread(buf.id, io, bulk_once: staged_mode)
2639
+
2640
+ size_mb = file_size.fdiv(1024 * 1024)
2641
+ if staged_mode
2642
+ @editor.echo(format("\"%s\" loading... (showing first %.0fMB of %.1fMB)", path, staged_prefix_bytes.fdiv(1024 * 1024), size_mb))
2643
+ else
2644
+ @editor.echo(format("\"%s\" loading... (%.1fMB)", path, size_mb))
2645
+ end
2646
+ buf
2647
+ rescue StandardError
2648
+ @async_file_loads.delete(buf.id) if buf
2649
+ raise
2650
+ end
2651
+
2652
+ def async_file_staged_prefix_bytes
2653
+ raw = ENV["RUVIM_ASYNC_FILE_PREFIX_BYTES"]
2654
+ n = raw.to_i if raw
2655
+ return n if n && n.positive?
2656
+
2657
+ LARGE_FILE_STAGED_PREFIX_BYTES
2658
+ end
2659
+
2660
+ def start_async_file_loader_thread(buffer_id, io, bulk_once: false)
2661
+ Thread.new do
2662
+ if bulk_once
2663
+ rest = io.read || "".b
2664
+ unless rest.empty?
2665
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: Buffer.decode_text(rest) }
2666
+ notify_signal_wakeup
2667
+ end
2668
+ @stream_event_queue << { type: :file_eof, buffer_id: buffer_id, ended_with_newline: rest.end_with?("\n") }
2669
+ notify_signal_wakeup
2670
+ next
2671
+ end
2672
+
2673
+ ended_with_newline = false
2674
+ pending_text = +""
2675
+ loop do
2676
+ chunk = io.readpartial(ASYNC_FILE_READ_CHUNK_BYTES)
2677
+ next if chunk.nil? || chunk.empty?
2678
+
2679
+ ended_with_newline = chunk.end_with?("\n")
2680
+ pending_text << Buffer.decode_text(chunk)
2681
+ next if pending_text.bytesize < ASYNC_FILE_EVENT_FLUSH_BYTES
2682
+
2683
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: pending_text }
2684
+ pending_text = +""
2685
+ notify_signal_wakeup
2686
+ end
2687
+ rescue EOFError
2688
+ unless pending_text.empty?
2689
+ @stream_event_queue << { type: :file_data, buffer_id: buffer_id, data: pending_text }
2690
+ notify_signal_wakeup
2691
+ end
2692
+ @stream_event_queue << { type: :file_eof, buffer_id: buffer_id, ended_with_newline: ended_with_newline }
2693
+ notify_signal_wakeup
2694
+ rescue StandardError => e
2695
+ @stream_event_queue << { type: :file_error, buffer_id: buffer_id, error: e.message.to_s }
2696
+ notify_signal_wakeup
2697
+ ensure
2698
+ begin
2699
+ io.close unless io.closed?
2700
+ rescue StandardError
2701
+ nil
2702
+ end
2703
+ end
2704
+ end
2705
+
2706
+ def prepare_stdin_stream_buffer!
2707
+ buf = @editor.current_buffer
2708
+ if buf.intro_buffer?
2709
+ @editor.materialize_intro_buffer!
2710
+ buf = @editor.current_buffer
2711
+ end
2712
+
2713
+ buf.replace_all_lines!([""])
2714
+ buf.configure_special!(kind: :stream, name: "[stdin]", readonly: true, modifiable: false)
2715
+ buf.modified = false
2716
+ buf.stream_state = :live
2717
+ buf.options["filetype"] = "text"
2718
+ @stream_stop_requested = false
2719
+ ensure_stream_event_queue!
2720
+ @stream_buffer_id = buf.id
2721
+ move_window_to_stream_end!(@editor.current_window, buf)
2722
+ @editor.echo("[stdin] follow")
2723
+ end
2724
+
2725
+ def stdin_stream_stop_command
2726
+ return if stop_stdin_stream!
2727
+
2728
+ handle_normal_ctrl_c
2729
+ end
2730
+
2731
+ def stop_stdin_stream!
2732
+ buf = @editor.buffers[@stream_buffer_id]
2733
+ return false unless buf&.kind == :stream
2734
+ return false unless (buf.stream_state || :live) == :live
2735
+
2736
+ @stream_stop_requested = true
2737
+ io = @stdin_stream_source
2738
+ @stdin_stream_source = nil
2739
+ begin
2740
+ io.close if io && io.respond_to?(:close) && !(io.respond_to?(:closed?) && io.closed?)
2741
+ rescue StandardError
2742
+ nil
2743
+ end
2744
+ if @stream_reader_thread&.alive?
2745
+ @stream_reader_thread.kill
2746
+ @stream_reader_thread.join(0.05)
2747
+ end
2748
+ @stream_reader_thread = nil
2749
+
2750
+ buf.stream_state = :closed
2751
+ @editor.echo("[stdin] closed")
2752
+ notify_signal_wakeup
2753
+ true
2754
+ end
2755
+
2756
+ def start_stdin_stream_reader!
2757
+ return unless @stdin_stream_source
2758
+ ensure_stream_event_queue!
2759
+ return if @stream_reader_thread&.alive?
2760
+
2761
+ @stream_stop_requested = false
2762
+ io = @stdin_stream_source
2763
+ @stream_reader_thread = Thread.new do
2764
+ loop do
2765
+ chunk = io.readpartial(4096)
2766
+ next if chunk.nil? || chunk.empty?
2767
+
2768
+ @stream_event_queue << { type: :data, data: Buffer.decode_text(chunk) }
2769
+ notify_signal_wakeup
2770
+ end
2771
+ rescue EOFError
2772
+ unless @stream_stop_requested
2773
+ @stream_event_queue << { type: :eof }
2774
+ notify_signal_wakeup
2775
+ end
2776
+ rescue IOError => e
2777
+ unless @stream_stop_requested
2778
+ @stream_event_queue << { type: :error, error: e.message.to_s }
2779
+ notify_signal_wakeup
2780
+ end
2781
+ rescue StandardError => e
2782
+ unless @stream_stop_requested
2783
+ @stream_event_queue << { type: :error, error: e.message.to_s }
2784
+ notify_signal_wakeup
2785
+ end
2786
+ end
2787
+ end
2788
+
2789
+ def drain_stream_events!
2790
+ return false unless @stream_event_queue
2791
+
2792
+ changed = false
2793
+ loop do
2794
+ event = @stream_event_queue.pop(true)
2795
+ case event[:type]
2796
+ when :data
2797
+ changed = apply_stream_chunk!(event[:data]) || changed
2798
+ when :eof
2799
+ if (buf = @editor.buffers[@stream_buffer_id])
2800
+ buf.stream_state = :closed
2801
+ end
2802
+ @editor.echo("[stdin] EOF")
2803
+ changed = true
2804
+ when :error
2805
+ next if ignore_stream_shutdown_error?(event[:error])
2806
+ if (buf = @editor.buffers[@stream_buffer_id])
2807
+ buf.stream_state = :error
2808
+ end
2809
+ @editor.echo_error("[stdin] stream error: #{event[:error]}")
2810
+ changed = true
2811
+ when :file_data
2812
+ changed = apply_async_file_chunk!(event[:buffer_id], event[:data]) || changed
2813
+ when :file_eof
2814
+ changed = finish_async_file_load!(event[:buffer_id], ended_with_newline: event[:ended_with_newline]) || changed
2815
+ when :file_error
2816
+ changed = fail_async_file_load!(event[:buffer_id], event[:error]) || changed
2817
+ end
2818
+ end
2819
+ rescue ThreadError
2820
+ changed
2821
+ end
2822
+
2823
+ def apply_stream_chunk!(text)
2824
+ return false if text.to_s.empty?
2825
+
2826
+ buf = @editor.buffers[@stream_buffer_id]
2827
+ return false unless buf
2828
+
2829
+ follow_window_ids = @editor.windows.values.filter_map do |win|
2830
+ next unless win.buffer_id == buf.id
2831
+ next unless stream_window_following_end?(win, buf)
2832
+
2833
+ win.id
2834
+ end
2835
+
2836
+ buf.append_stream_text!(text)
2837
+
2838
+ follow_window_ids.each do |win_id|
2839
+ win = @editor.windows[win_id]
2840
+ move_window_to_stream_end!(win, buf) if win
2841
+ end
2842
+
2843
+ true
2844
+ end
2845
+
2846
+ def apply_async_file_chunk!(buffer_id, text)
2847
+ return false if text.to_s.empty?
2848
+
2849
+ buf = @editor.buffers[buffer_id]
2850
+ return false unless buf
2851
+
2852
+ buf.append_stream_text!(text)
2853
+ true
2854
+ end
2855
+
2856
+ def finish_async_file_load!(buffer_id, ended_with_newline:)
2857
+ @async_file_loads.delete(buffer_id)
2858
+ buf = @editor.buffers[buffer_id]
2859
+ return false unless buf
2860
+
2861
+ buf.finalize_async_file_load!(ended_with_newline: !!ended_with_newline)
2862
+ buf.loading_state = :closed
2863
+ true
2864
+ end
2865
+
2866
+ def fail_async_file_load!(buffer_id, error)
2867
+ state = @async_file_loads.delete(buffer_id)
2868
+ buf = @editor.buffers[buffer_id]
2869
+ if buf
2870
+ buf.loading_state = :error
2871
+ end
2872
+ @editor.echo_error("\"#{(state && state[:path]) || (buf && buf.display_name) || buffer_id}\" load error: #{error}")
2873
+ true
2874
+ end
2875
+
2876
+ def stream_window_following_end?(win, buf)
2877
+ return false unless win
2878
+
2879
+ last_row = buf.line_count - 1
2880
+ win.cursor_y >= last_row
2881
+ end
2882
+
2883
+ def move_window_to_stream_end!(win, buf)
2884
+ return unless win && buf
2885
+
2886
+ last_row = buf.line_count - 1
2887
+ win.cursor_y = last_row
2888
+ win.cursor_x = buf.line_length(last_row)
2889
+ win.clamp_to_buffer(buf)
2890
+ end
2891
+
2892
+ def shutdown_stream_reader!
2893
+ thread = @stream_reader_thread
2894
+ @stream_reader_thread = nil
2895
+ @stream_stop_requested = true
2896
+ return unless thread
2897
+ return unless thread.alive?
2898
+
2899
+ thread.kill
2900
+ thread.join(0.05)
2901
+ rescue StandardError
2902
+ nil
2903
+ end
2904
+
2905
+ def shutdown_async_file_loaders!
2906
+ loaders = @async_file_loads
2907
+ @async_file_loads = {}
2908
+ loaders.each_value do |state|
2909
+ io = state[:io]
2910
+ thread = state[:thread]
2911
+ begin
2912
+ io.close if io && !io.closed?
2913
+ rescue StandardError
2914
+ nil
2915
+ end
2916
+ next unless thread&.alive?
2917
+
2918
+ thread.kill
2919
+ thread.join(0.05)
2920
+ rescue StandardError
2921
+ nil
2922
+ end
2923
+ end
2924
+
2925
+ def shutdown_background_readers!
2926
+ shutdown_stream_reader!
2927
+ shutdown_async_file_loaders!
2928
+ end
2929
+
2930
+ def ignore_stream_shutdown_error?(message)
2931
+ buf = @editor.buffers[@stream_buffer_id]
2932
+ return false unless buf&.kind == :stream
2933
+ return false unless (buf.stream_state || :live) == :closed
2934
+
2935
+ msg = message.to_s.downcase
2936
+ msg.include?("stream closed") || msg.include?("closed in another thread")
2937
+ end
2938
+
2939
+ def ensure_stream_event_queue!
2940
+ @stream_event_queue ||= Queue.new
2941
+ end
2942
+
1572
2943
  def move_cursor_to_line(line_number)
1573
2944
  win = @editor.current_window
1574
2945
  buf = @editor.current_buffer