ruvim 0.2.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 (63) 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 +23 -0
  6. data/docs/command.md +85 -0
  7. data/docs/config.md +2 -2
  8. data/docs/done.md +21 -0
  9. data/docs/spec.md +157 -12
  10. data/docs/todo.md +1 -5
  11. data/docs/vim_diff.md +94 -172
  12. data/lib/ruvim/app.rb +882 -69
  13. data/lib/ruvim/buffer.rb +35 -1
  14. data/lib/ruvim/cli.rb +12 -3
  15. data/lib/ruvim/clipboard.rb +2 -0
  16. data/lib/ruvim/command_invocation.rb +3 -1
  17. data/lib/ruvim/command_line.rb +2 -0
  18. data/lib/ruvim/command_registry.rb +2 -0
  19. data/lib/ruvim/config_dsl.rb +2 -0
  20. data/lib/ruvim/config_loader.rb +2 -0
  21. data/lib/ruvim/context.rb +2 -0
  22. data/lib/ruvim/dispatcher.rb +143 -13
  23. data/lib/ruvim/display_width.rb +3 -0
  24. data/lib/ruvim/editor.rb +455 -71
  25. data/lib/ruvim/ex_command_registry.rb +2 -0
  26. data/lib/ruvim/global_commands.rb +890 -63
  27. data/lib/ruvim/highlighter.rb +16 -21
  28. data/lib/ruvim/input.rb +39 -28
  29. data/lib/ruvim/keymap_manager.rb +83 -0
  30. data/lib/ruvim/keyword_chars.rb +2 -0
  31. data/lib/ruvim/lang/base.rb +25 -0
  32. data/lib/ruvim/lang/csv.rb +18 -0
  33. data/lib/ruvim/lang/json.rb +18 -0
  34. data/lib/ruvim/lang/markdown.rb +170 -0
  35. data/lib/ruvim/lang/ruby.rb +236 -0
  36. data/lib/ruvim/lang/scheme.rb +44 -0
  37. data/lib/ruvim/lang/tsv.rb +19 -0
  38. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  39. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  40. data/lib/ruvim/rich_view.rb +93 -0
  41. data/lib/ruvim/screen.rb +503 -106
  42. data/lib/ruvim/terminal.rb +18 -1
  43. data/lib/ruvim/text_metrics.rb +2 -0
  44. data/lib/ruvim/version.rb +1 -1
  45. data/lib/ruvim/window.rb +2 -0
  46. data/lib/ruvim.rb +14 -0
  47. data/test/app_completion_test.rb +73 -0
  48. data/test/app_dot_repeat_test.rb +13 -0
  49. data/test/app_motion_test.rb +13 -0
  50. data/test/app_scenario_test.rb +729 -1
  51. data/test/app_startup_test.rb +187 -0
  52. data/test/arglist_test.rb +113 -0
  53. data/test/buffer_test.rb +49 -30
  54. data/test/dispatcher_test.rb +322 -0
  55. data/test/editor_register_test.rb +23 -0
  56. data/test/highlighter_test.rb +121 -0
  57. data/test/indent_test.rb +201 -0
  58. data/test/input_screen_integration_test.rb +40 -2
  59. data/test/markdown_renderer_test.rb +279 -0
  60. data/test/on_save_hook_test.rb +150 -0
  61. data/test/rich_view_test.rb +478 -0
  62. data/test/screen_test.rb +304 -0
  63. metadata +33 -2
data/test/screen_test.rb CHANGED
@@ -101,6 +101,36 @@ class ScreenTest < Minitest::Test
101
101
  assert_includes out, "boom"
102
102
  end
103
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
+
104
134
  def test_render_reuses_syntax_highlight_cache_for_same_line
105
135
  editor = RuVim::Editor.new
106
136
  buf = editor.add_empty_buffer
@@ -132,6 +162,40 @@ class ScreenTest < Minitest::Test
132
162
  assert_equal 1, calls
133
163
  end
134
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
+
135
199
  def test_render_text_line_with_cursor_search_and_syntax_highlights_fits_width
136
200
  editor = RuVim::Editor.new
137
201
  buf = editor.add_empty_buffer
@@ -286,4 +350,244 @@ class ScreenTest < Minitest::Test
286
350
  assert_includes row2, ">"
287
351
  assert_match(/\s>|\>\s/, row2)
288
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
289
593
  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.2.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,6 +32,8 @@ 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
@@ -51,6 +67,16 @@ files:
51
67
  - lib/ruvim/input.rb
52
68
  - lib/ruvim/keymap_manager.rb
53
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
54
80
  - lib/ruvim/screen.rb
55
81
  - lib/ruvim/terminal.rb
56
82
  - lib/ruvim/text_metrics.rb
@@ -65,6 +91,7 @@ files:
65
91
  - test/app_startup_test.rb
66
92
  - test/app_text_object_test.rb
67
93
  - test/app_unicode_behavior_test.rb
94
+ - test/arglist_test.rb
68
95
  - test/buffer_test.rb
69
96
  - test/cli_test.rb
70
97
  - test/config_dsl_test.rb
@@ -77,9 +104,13 @@ files:
77
104
  - test/fixtures/render_basic_snapshot_nonumber.txt
78
105
  - test/fixtures/render_unicode_scrolled_snapshot.txt
79
106
  - test/highlighter_test.rb
107
+ - test/indent_test.rb
80
108
  - test/input_screen_integration_test.rb
81
109
  - test/keymap_manager_test.rb
110
+ - test/markdown_renderer_test.rb
111
+ - test/on_save_hook_test.rb
82
112
  - test/render_snapshot_test.rb
113
+ - test/rich_view_test.rb
83
114
  - test/screen_test.rb
84
115
  - test/search_option_test.rb
85
116
  - test/test_helper.rb