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/test/screen_test.rb CHANGED
@@ -55,6 +55,38 @@ class ScreenTest < Minitest::Test
55
55
  assert_equal " 3 ", screen.send(:line_number_prefix, editor, win, buf, 2, 3) # current line is absolute when both enabled
56
56
  end
57
57
 
58
+ def test_signcolumn_yes_reserves_one_column_in_gutter
59
+ editor = RuVim::Editor.new
60
+ buf = editor.add_empty_buffer
61
+ win = editor.add_window(buffer_id: buf.id)
62
+ editor.set_option("number", true, scope: :window, window: win, buffer: buf)
63
+ editor.set_option("signcolumn", "yes", scope: :window, window: win, buffer: buf)
64
+ term = TerminalStub.new([8, 20])
65
+ screen = RuVim::Screen.new(terminal: term)
66
+
67
+ w = screen.send(:number_column_width, editor, win, buf)
68
+ prefix = screen.send(:line_number_prefix, editor, win, buf, 0, w)
69
+
70
+ assert_equal 6, w # sign(1) + default numberwidth(4) + trailing space
71
+ assert_equal " 1 ", prefix
72
+ end
73
+
74
+ def test_signcolumn_yes_with_width_reserves_multiple_columns
75
+ editor = RuVim::Editor.new
76
+ buf = editor.add_empty_buffer
77
+ win = editor.add_window(buffer_id: buf.id)
78
+ editor.set_option("number", true, scope: :window, window: win, buffer: buf)
79
+ editor.set_option("signcolumn", "yes:2", scope: :window, window: win, buffer: buf)
80
+ term = TerminalStub.new([8, 20])
81
+ screen = RuVim::Screen.new(terminal: term)
82
+
83
+ w = screen.send(:number_column_width, editor, win, buf)
84
+ prefix = screen.send(:line_number_prefix, editor, win, buf, 0, w)
85
+
86
+ assert_equal 7, w # sign(2) + default numberwidth(4) + trailing space
87
+ assert_equal " 1 ", prefix
88
+ end
89
+
58
90
  def test_render_shows_error_message_on_command_line_row_with_highlight
59
91
  editor = RuVim::Editor.new
60
92
  buf = editor.add_empty_buffer
@@ -69,6 +101,36 @@ class ScreenTest < Minitest::Test
69
101
  assert_includes out, "boom"
70
102
  end
71
103
 
104
+ def test_status_line_shows_stream_state_for_stdin_buffer
105
+ editor = RuVim::Editor.new
106
+ buf = editor.add_virtual_buffer(kind: :stream, name: "[stdin]", lines: [""], readonly: true, modifiable: false)
107
+ buf.stream_state = :closed
108
+ editor.add_window(buffer_id: buf.id)
109
+
110
+ term = TerminalStub.new([6, 60])
111
+ screen = RuVim::Screen.new(terminal: term)
112
+ line = screen.send(:status_line, editor, 60)
113
+
114
+ assert_includes line, "[stdin/closed]"
115
+ end
116
+
117
+ def test_render_uses_dim_and_brighter_line_number_gutter_colors
118
+ editor = RuVim::Editor.new
119
+ buf = editor.add_empty_buffer
120
+ win = editor.add_window(buffer_id: buf.id)
121
+ buf.replace_all_lines!(["foo", "bar"])
122
+ editor.set_option("number", true, scope: :window, window: win, buffer: buf)
123
+ win.cursor_y = 1
124
+
125
+ term = TerminalStub.new([6, 20])
126
+ screen = RuVim::Screen.new(terminal: term)
127
+ screen.render(editor)
128
+ out = term.writes.last
129
+
130
+ assert_includes out, "\e[90m"
131
+ assert_includes out, "\e[37m"
132
+ end
133
+
72
134
  def test_render_reuses_syntax_highlight_cache_for_same_line
73
135
  editor = RuVim::Editor.new
74
136
  buf = editor.add_empty_buffer
@@ -100,6 +162,40 @@ class ScreenTest < Minitest::Test
100
162
  assert_equal 1, calls
101
163
  end
102
164
 
165
+ def test_render_reuses_wrap_segment_cache_for_same_line
166
+ editor = RuVim::Editor.new
167
+ buf = editor.add_empty_buffer
168
+ win = editor.add_window(buffer_id: buf.id)
169
+ buf.replace_all_lines!(["x" * 400])
170
+ editor.set_option("wrap", true, scope: :window, window: win, buffer: buf)
171
+
172
+ term = TerminalStub.new([8, 20])
173
+ screen = RuVim::Screen.new(terminal: term)
174
+
175
+ calls = [0, 0]
176
+ render_index = 0
177
+ mod = RuVim::TextMetrics.singleton_class
178
+ verbose, $VERBOSE = $VERBOSE, nil
179
+ mod.alias_method(:__orig_clip_cells_for_width_for_screen_test, :clip_cells_for_width)
180
+ mod.define_method(:clip_cells_for_width) do |*args, **kwargs|
181
+ calls[render_index] += 1
182
+ __orig_clip_cells_for_width_for_screen_test(*args, **kwargs)
183
+ end
184
+
185
+ begin
186
+ render_index = 0
187
+ screen.render(editor)
188
+ render_index = 1
189
+ screen.render(editor)
190
+ ensure
191
+ mod.alias_method(:clip_cells_for_width, :__orig_clip_cells_for_width_for_screen_test)
192
+ mod.remove_method(:__orig_clip_cells_for_width_for_screen_test) rescue nil
193
+ $VERBOSE = verbose
194
+ end
195
+
196
+ assert_operator calls[1], :<, calls[0]
197
+ end
198
+
103
199
  def test_render_text_line_with_cursor_search_and_syntax_highlights_fits_width
104
200
  editor = RuVim::Editor.new
105
201
  buf = editor.add_empty_buffer
@@ -120,4 +216,378 @@ class ScreenTest < Minitest::Test
120
216
  assert_equal 10, RuVim::DisplayWidth.display_width(visible, tabstop: 2)
121
217
  refute_includes out, "\n"
122
218
  end
219
+
220
+ def test_termguicolors_uses_truecolor_sequences_for_search_highlight
221
+ editor = RuVim::Editor.new
222
+ buf = editor.add_empty_buffer
223
+ win = editor.add_window(buffer_id: buf.id)
224
+ buf.replace_all_lines!(["foo"])
225
+ editor.set_last_search(pattern: "foo", direction: :forward)
226
+ editor.set_option("termguicolors", true, scope: :global)
227
+
228
+ term = TerminalStub.new([6, 20])
229
+ screen = RuVim::Screen.new(terminal: term)
230
+ out = screen.send(:render_text_line, buf.line_at(0), editor, buffer_row: 0, window: win, buffer: buf, width: 6)
231
+
232
+ assert_includes out, "\e[48;2;255;215;0m"
233
+ end
234
+
235
+ def test_render_text_line_respects_list_and_listchars_for_tab_trail_and_nbsp
236
+ editor = RuVim::Editor.new
237
+ buf = editor.add_empty_buffer
238
+ win = editor.add_window(buffer_id: buf.id)
239
+ buf.replace_all_lines!(["\tA \u00A0 "])
240
+ editor.set_option("list", true, scope: :window, window: win, buffer: buf)
241
+ editor.set_option("listchars", "tab:>.,trail:~,nbsp:*", scope: :window, window: win, buffer: buf)
242
+
243
+ term = TerminalStub.new([6, 20])
244
+ screen = RuVim::Screen.new(terminal: term)
245
+ out = screen.send(:render_text_line, buf.line_at(0), editor, buffer_row: 0, window: win, buffer: buf, width: 12)
246
+ visible = out.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
247
+
248
+ assert_includes visible, ">."
249
+ assert_includes visible, "*"
250
+ assert_includes visible, "~"
251
+ end
252
+
253
+ def test_render_text_line_sanitizes_terminal_escape_controls
254
+ editor = RuVim::Editor.new
255
+ buf = editor.add_empty_buffer
256
+ win = editor.add_window(buffer_id: buf.id)
257
+ buf.replace_all_lines!(["A\e]52;c;owned\aB"])
258
+ win.cursor_y = 1 # avoid cursor highlight on the tested row
259
+
260
+ term = TerminalStub.new([6, 40])
261
+ screen = RuVim::Screen.new(terminal: term)
262
+ out = screen.send(:render_text_line, buf.line_at(0), editor, buffer_row: 0, window: win, buffer: buf, width: 20)
263
+
264
+ refute_includes out, "\e]52"
265
+ visible = out.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
266
+ assert_includes visible, "A"
267
+ assert_includes visible, "B"
268
+ assert_includes visible, "?"
269
+ end
270
+
271
+ def test_wrap_and_showbreak_render_continuation_rows
272
+ editor = RuVim::Editor.new
273
+ buf = editor.add_empty_buffer
274
+ win = editor.add_window(buffer_id: buf.id)
275
+ buf.replace_all_lines!(["abcdef ghijkl"])
276
+ editor.set_option("wrap", true, scope: :window, window: win, buffer: buf)
277
+ editor.set_option("showbreak", ">>", scope: :window, window: win, buffer: buf)
278
+
279
+ term = TerminalStub.new([6, 8])
280
+ screen = RuVim::Screen.new(terminal: term)
281
+ rows, cols = term.winsize
282
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
283
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
284
+ frame = screen.send(:build_frame, editor, rows:, cols:, text_rows:, text_cols:, rects:)
285
+
286
+ row1 = frame[:lines][1].to_s.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
287
+ row2 = frame[:lines][2].to_s.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
288
+ assert_includes row1, "abcdef"
289
+ assert_includes row2, ">>"
290
+ end
291
+
292
+ def test_cursor_screen_position_supports_virtualedit_past_eol
293
+ editor = RuVim::Editor.new
294
+ buf = editor.add_empty_buffer
295
+ win = editor.add_window(buffer_id: buf.id)
296
+ buf.replace_all_lines!(["abc"])
297
+ editor.set_option("virtualedit", "onemore", scope: :global)
298
+ win.cursor_y = 0
299
+ win.cursor_x = 4
300
+
301
+ term = TerminalStub.new([6, 20])
302
+ screen = RuVim::Screen.new(terminal: term)
303
+ rows, cols = term.winsize
304
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
305
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
306
+ pos = screen.send(:cursor_screen_position, editor, text_rows, rects)
307
+
308
+ # col 1-based; "abc" places cursor at 4, one-past-eol should be 5
309
+ assert_equal [1, 5], pos
310
+ end
311
+
312
+ def test_cursor_screen_position_is_clamped_to_text_area_under_wrap
313
+ editor = RuVim::Editor.new
314
+ buf = editor.add_empty_buffer
315
+ win = editor.add_window(buffer_id: buf.id)
316
+ buf.replace_all_lines!(["x" * 40, "tail"])
317
+ editor.set_option("wrap", true, scope: :window, window: win, buffer: buf)
318
+ win.row_offset = 0
319
+ win.cursor_y = 1
320
+ win.cursor_x = 0
321
+
322
+ term = TerminalStub.new([6, 8]) # text_rows = 4 (footer 2 rows)
323
+ screen = RuVim::Screen.new(terminal: term)
324
+ rows, cols = term.winsize
325
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
326
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
327
+ row, _col = screen.send(:cursor_screen_position, editor, text_rows, rects)
328
+
329
+ assert_operator row, :<=, text_rows
330
+ end
331
+
332
+ def test_linebreak_and_breakindent_prefer_space_wrap
333
+ editor = RuVim::Editor.new
334
+ buf = editor.add_empty_buffer
335
+ win = editor.add_window(buffer_id: buf.id)
336
+ buf.replace_all_lines!([" foo bar baz"])
337
+ editor.set_option("wrap", true, scope: :window, window: win, buffer: buf)
338
+ editor.set_option("linebreak", true, scope: :window, window: win, buffer: buf)
339
+ editor.set_option("breakindent", true, scope: :window, window: win, buffer: buf)
340
+ editor.set_option("showbreak", ">", scope: :window, window: win, buffer: buf)
341
+
342
+ term = TerminalStub.new([6, 10])
343
+ screen = RuVim::Screen.new(terminal: term)
344
+ rows, cols = term.winsize
345
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
346
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
347
+ frame = screen.send(:build_frame, editor, rows:, cols:, text_rows:, text_cols:, rects:)
348
+
349
+ row2 = frame[:lines][2].to_s.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
350
+ assert_includes row2, ">"
351
+ assert_match(/\s>|\>\s/, row2)
352
+ end
353
+
354
+ def test_wrap_keeps_cursor_visible_after_very_long_previous_line
355
+ editor = RuVim::Editor.new
356
+ buf = editor.add_empty_buffer
357
+ win = editor.add_window(buffer_id: buf.id)
358
+ buf.replace_all_lines!(["x" * 200, "tail"])
359
+ editor.set_option("wrap", true, scope: :window, window: win, buffer: buf)
360
+ win.cursor_y = 1
361
+ win.cursor_x = 0
362
+
363
+ term = TerminalStub.new([6, 8]) # text_rows=4, content width ~8 (no gutter)
364
+ screen = RuVim::Screen.new(terminal: term)
365
+ screen.render(editor)
366
+
367
+ assert_equal 1, win.row_offset
368
+
369
+ rows, cols = term.winsize
370
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
371
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
372
+ row, _col = screen.send(:cursor_screen_position, editor, text_rows, rects)
373
+ assert_equal 1, row
374
+ end
375
+
376
+ def test_rich_mode_horizontal_scroll_preserves_column_alignment
377
+ editor = RuVim::Editor.new
378
+ buf = editor.add_empty_buffer
379
+ win = editor.add_window(buffer_id: buf.id)
380
+ # Create rows where raw lines differ in length but formatted lines are aligned
381
+ buf.replace_all_lines!(["Short\tSecond\tThird", "LongerField\tB\tC"])
382
+ buf.options["filetype"] = "tsv"
383
+ RuVim::RichView.open!(editor, format: "tsv")
384
+
385
+ # Move cursor to end of line (like pressing $)
386
+ win.cursor_y = 0
387
+ win.cursor_x = buf.line_length(0)
388
+
389
+ # Use a narrow terminal so that the formatted line won't fit
390
+ term = TerminalStub.new([6, 20])
391
+ screen = RuVim::Screen.new(terminal: term)
392
+ screen.render(editor)
393
+
394
+ # After render, col_offset should be in formatted space.
395
+ # Both rendered lines should use the same col_offset, keeping separators aligned.
396
+ rows_key, cols_key = term.winsize
397
+ text_rows, text_cols = editor.text_viewport_size(rows: rows_key, cols: cols_key)
398
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
399
+ frame = screen.send(:build_frame, editor, rows: rows_key, cols: cols_key, text_rows:, text_cols:, rects:)
400
+
401
+ # Strip ANSI and check that "|" separators are at the same column in both rows
402
+ row1 = frame[:lines][1].to_s.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
403
+ row2 = frame[:lines][2].to_s.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
404
+
405
+ sep_positions_row1 = row1.enum_for(:scan, /\|/).map { Regexp.last_match.begin(0) }
406
+ sep_positions_row2 = row2.enum_for(:scan, /\|/).map { Regexp.last_match.begin(0) }
407
+
408
+ # They should have the same separator positions (column alignment preserved)
409
+ assert_equal sep_positions_row1, sep_positions_row2,
410
+ "Separators should align: row1=#{row1.inspect} row2=#{row2.inspect}"
411
+ end
412
+
413
+ def test_rich_mode_cjk_horizontal_scroll_preserves_display_column_alignment
414
+ editor = RuVim::Editor.new
415
+ buf = editor.add_empty_buffer
416
+ win = editor.add_window(buffer_id: buf.id)
417
+ buf.replace_all_lines!([
418
+ "色\t果物名\t産地",
419
+ "赤\tりんご\t青森",
420
+ "緑\tキウイフルーツジャム\t静岡"
421
+ ])
422
+ buf.options["filetype"] = "tsv"
423
+ RuVim::RichView.open!(editor, format: "tsv")
424
+
425
+ # Move to end of line to trigger horizontal scroll
426
+ win.cursor_y = 2
427
+ win.cursor_x = buf.line_length(2)
428
+
429
+ # Narrow terminal so formatted line doesn't fit
430
+ term = TerminalStub.new([6, 30])
431
+ screen = RuVim::Screen.new(terminal: term)
432
+ screen.render(editor)
433
+
434
+ rows_key, cols_key = term.winsize
435
+ text_rows, text_cols = editor.text_viewport_size(rows: rows_key, cols: cols_key)
436
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
437
+ frame = screen.send(:build_frame, editor, rows: rows_key, cols: cols_key, text_rows:, text_cols:, rects:)
438
+
439
+ # Strip ANSI, collect visible lines
440
+ visible_rows = (1..3).map do |r|
441
+ frame[:lines][r].to_s.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
442
+ end
443
+
444
+ # All rows should have separator "|" at the same display column
445
+ pipe_display_cols = visible_rows.map do |row|
446
+ col = 0
447
+ pipe_col = nil
448
+ row.each_char do |ch|
449
+ if ch == "|"
450
+ pipe_col = col
451
+ break
452
+ end
453
+ col += RuVim::DisplayWidth.cell_width(ch, col: col, tabstop: 2)
454
+ end
455
+ pipe_col
456
+ end
457
+
458
+ # Filter out rows where pipe might not be visible (scrolled away)
459
+ visible_pipes = pipe_display_cols.compact
460
+ assert(visible_pipes.length >= 2, "At least 2 rows should show a pipe separator")
461
+ assert_equal 1, visible_pipes.uniq.length,
462
+ "Pipe separators should align at same display column: #{pipe_display_cols.inspect} in #{visible_rows.inspect}"
463
+ end
464
+
465
+ def test_truncate_respects_display_width_for_wide_chars
466
+ editor = RuVim::Editor.new
467
+ buf = editor.add_empty_buffer
468
+ editor.add_window(buffer_id: buf.id)
469
+ term = TerminalStub.new([6, 20])
470
+ screen = RuVim::Screen.new(terminal: term)
471
+
472
+ # "Unknown key: あ" => 13 ASCII + 1 wide char (display width 2) = 15 display cols
473
+ result = screen.send(:truncate, "Unknown key: あ", 20)
474
+ dw = RuVim::DisplayWidth.display_width(result)
475
+ assert_equal 20, dw, "truncate must produce exactly 20 display columns, got #{dw}"
476
+ end
477
+
478
+ def test_error_message_with_wide_char_does_not_exceed_terminal_width
479
+ editor = RuVim::Editor.new
480
+ buf = editor.add_empty_buffer
481
+ editor.add_window(buffer_id: buf.id)
482
+
483
+ editor.echo_error("Unknown key: あ")
484
+
485
+ term = TerminalStub.new([6, 20])
486
+ screen = RuVim::Screen.new(terminal: term)
487
+
488
+ rows, cols = term.winsize
489
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
490
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
491
+ frame = screen.send(:build_frame, editor, rows:, cols:, text_rows:, text_cols:, rects:)
492
+
493
+ error_row = frame[:lines][text_rows + 2]
494
+ # Strip ANSI codes and measure display width
495
+ plain = error_row.to_s.gsub(/\e\[[0-9;?]*[A-Za-z]/, "")
496
+ dw = RuVim::DisplayWidth.display_width(plain)
497
+ assert_operator dw, :<=, cols, "error line display width #{dw} exceeds terminal cols #{cols}"
498
+ end
499
+
500
+ def test_rich_mode_cursor_visible_after_end_of_line
501
+ editor = RuVim::Editor.new
502
+ buf = editor.add_empty_buffer
503
+ win = editor.add_window(buffer_id: buf.id)
504
+ buf.replace_all_lines!(["A\tB\tC", "DD\tEE\tFF"])
505
+ buf.options["filetype"] = "tsv"
506
+ RuVim::RichView.open!(editor, format: "tsv")
507
+
508
+ # Move cursor to end of raw line
509
+ win.cursor_y = 0
510
+ win.cursor_x = buf.line_length(0) # last char
511
+
512
+ # Narrow terminal
513
+ term = TerminalStub.new([6, 15])
514
+ screen = RuVim::Screen.new(terminal: term)
515
+ screen.render(editor)
516
+
517
+ rows_key, cols_key = term.winsize
518
+ text_rows, text_cols = editor.text_viewport_size(rows: rows_key, cols: cols_key)
519
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
520
+ _cursor_row, cursor_col = screen.send(:cursor_screen_position, editor, text_rows, rects)
521
+
522
+ # Cursor should be within the visible area
523
+ assert_operator cursor_col, :>=, 1
524
+ assert_operator cursor_col, :<=, cols_key
525
+ end
526
+
527
+ def test_status_line_shows_tab_indicator_when_multiple_tabs
528
+ editor = RuVim::Editor.new
529
+ buf = editor.add_empty_buffer
530
+ editor.add_window(buffer_id: buf.id)
531
+ editor.tabnew
532
+ editor.tabnew
533
+
534
+ term = TerminalStub.new([6, 60])
535
+ screen = RuVim::Screen.new(terminal: term)
536
+ line = screen.send(:status_line, editor, 60)
537
+
538
+ assert_includes line, "tab:3/3"
539
+ end
540
+
541
+ def test_status_line_hides_tab_indicator_when_single_tab
542
+ editor = RuVim::Editor.new
543
+ buf = editor.add_empty_buffer
544
+ editor.add_window(buffer_id: buf.id)
545
+
546
+ term = TerminalStub.new([6, 60])
547
+ screen = RuVim::Screen.new(terminal: term)
548
+ line = screen.send(:status_line, editor, 60)
549
+
550
+ refute_includes line, "tab:"
551
+ end
552
+
553
+ def test_window_rects_nested_vsplit_hsplit
554
+ editor = RuVim::Editor.new
555
+ buf = editor.add_empty_buffer
556
+ # win1
557
+ editor.add_window(buffer_id: buf.id)
558
+ win1 = editor.current_window
559
+ # vsplit → win2
560
+ editor.split_current_window(layout: :vertical)
561
+ win2 = editor.current_window
562
+ # split win2 → win3
563
+ editor.split_current_window(layout: :horizontal)
564
+ win3 = editor.current_window
565
+
566
+ term = TerminalStub.new([22, 80])
567
+ screen = RuVim::Screen.new(terminal: term)
568
+ rows, cols = term.winsize
569
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
570
+ rects = screen.send(:window_rects, editor, text_rows:, text_cols:)
571
+
572
+ # All three windows should have rects
573
+ assert rects.key?(win1.id), "win1 should have a rect"
574
+ assert rects.key?(win2.id), "win2 should have a rect"
575
+ assert rects.key?(win3.id), "win3 should have a rect"
576
+
577
+ # win1 should be on the left, win2/win3 should be on the right
578
+ assert_operator rects[win1.id][:left], :<, rects[win2.id][:left]
579
+ assert_operator rects[win1.id][:left], :<, rects[win3.id][:left]
580
+
581
+ # win2 and win3 should share the same left position (same column)
582
+ assert_equal rects[win2.id][:left], rects[win3.id][:left]
583
+
584
+ # win2 should be above win3
585
+ assert_operator rects[win2.id][:top], :<, rects[win3.id][:top]
586
+
587
+ # win1 should span the full height
588
+ assert_equal rects[win1.id][:height], text_rows
589
+
590
+ # win2 + win3 heights + separator should equal text_rows
591
+ assert_equal text_rows, rects[win2.id][:height] + rects[win3.id][:height] + 1
592
+ end
123
593
  end
data/test/window_test.rb CHANGED
@@ -18,4 +18,30 @@ class WindowTest < Minitest::Test
18
18
  assert_operator cursor_col, :<, offset_col + 4
19
19
  assert_equal 3, win.col_offset
20
20
  end
21
+
22
+ def test_ensure_visible_respects_scrolloff_and_sidescrolloff
23
+ buffer = RuVim::Buffer.new(id: 1, lines: ["0123456789", "aaaaa", "bbbbb", "ccccc", "0123456789", "eeeee"])
24
+ win = RuVim::Window.new(id: 1, buffer_id: 1)
25
+ win.cursor_y = 4
26
+ win.cursor_x = 8
27
+
28
+ win.ensure_visible(buffer, height: 3, width: 5, tabstop: 2, scrolloff: 1, sidescrolloff: 1)
29
+
30
+ assert_equal 3, win.row_offset
31
+ assert_operator win.col_offset, :>, 0
32
+ end
33
+
34
+ def test_move_down_preserves_preferred_column_across_empty_line
35
+ long = "x" * 80
36
+ buffer = RuVim::Buffer.new(id: 1, lines: [long, "", long])
37
+ win = RuVim::Window.new(id: 1, buffer_id: 1)
38
+ win.cursor_y = 0
39
+ win.cursor_x = 50
40
+
41
+ win.move_down(buffer)
42
+ assert_equal [1, 0], [win.cursor_y, win.cursor_x]
43
+
44
+ win.move_down(buffer)
45
+ assert_equal [2, 50], [win.cursor_y, win.cursor_x]
46
+ end
21
47
  end
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruvim
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Koichi Sasada
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  description: vim clone on Ruby by codex
13
27
  email:
14
28
  - ko1@atdot.net
@@ -18,12 +32,15 @@ extensions: []
18
32
  extra_rdoc_files: []
19
33
  files:
20
34
  - ".github/workflows/test.yml"
35
+ - AGENTS.md
36
+ - CLAUDE.md
21
37
  - README.md
22
38
  - Rakefile
23
39
  - docs/binding.md
24
40
  - docs/command.md
25
41
  - docs/config.md
26
42
  - docs/done.md
43
+ - docs/lib_cleanup_report.md
27
44
  - docs/plugin.md
28
45
  - docs/spec.md
29
46
  - docs/todo.md
@@ -49,6 +66,17 @@ files:
49
66
  - lib/ruvim/highlighter.rb
50
67
  - lib/ruvim/input.rb
51
68
  - lib/ruvim/keymap_manager.rb
69
+ - lib/ruvim/keyword_chars.rb
70
+ - lib/ruvim/lang/base.rb
71
+ - lib/ruvim/lang/csv.rb
72
+ - lib/ruvim/lang/json.rb
73
+ - lib/ruvim/lang/markdown.rb
74
+ - lib/ruvim/lang/ruby.rb
75
+ - lib/ruvim/lang/scheme.rb
76
+ - lib/ruvim/lang/tsv.rb
77
+ - lib/ruvim/rich_view.rb
78
+ - lib/ruvim/rich_view/markdown_renderer.rb
79
+ - lib/ruvim/rich_view/table_renderer.rb
52
80
  - lib/ruvim/screen.rb
53
81
  - lib/ruvim/terminal.rb
54
82
  - lib/ruvim/text_metrics.rb
@@ -63,19 +91,26 @@ files:
63
91
  - test/app_startup_test.rb
64
92
  - test/app_text_object_test.rb
65
93
  - test/app_unicode_behavior_test.rb
94
+ - test/arglist_test.rb
66
95
  - test/buffer_test.rb
67
96
  - test/cli_test.rb
68
97
  - test/config_dsl_test.rb
98
+ - test/config_loader_test.rb
69
99
  - test/dispatcher_test.rb
100
+ - test/display_width_test.rb
70
101
  - test/editor_mark_test.rb
71
102
  - test/editor_register_test.rb
72
103
  - test/fixtures/render_basic_snapshot.txt
73
104
  - test/fixtures/render_basic_snapshot_nonumber.txt
74
105
  - test/fixtures/render_unicode_scrolled_snapshot.txt
75
106
  - test/highlighter_test.rb
107
+ - test/indent_test.rb
76
108
  - test/input_screen_integration_test.rb
77
109
  - test/keymap_manager_test.rb
110
+ - test/markdown_renderer_test.rb
111
+ - test/on_save_hook_test.rb
78
112
  - test/render_snapshot_test.rb
113
+ - test/rich_view_test.rb
79
114
  - test/screen_test.rb
80
115
  - test/search_option_test.rb
81
116
  - test/test_helper.rb