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
@@ -0,0 +1,201 @@
1
+ require_relative "test_helper"
2
+
3
+ class RubyIndentTest < Minitest::Test
4
+ def calc(lines, target_row, sw = 2)
5
+ RuVim::Lang::Ruby.calculate_indent(lines, target_row, sw)
6
+ end
7
+
8
+ def test_first_line_is_zero
9
+ assert_equal 0, calc(["hello"], 0)
10
+ end
11
+
12
+ def test_after_def
13
+ lines = ["def foo", " bar"]
14
+ assert_equal 2, calc(lines, 1)
15
+ end
16
+
17
+ def test_end_returns_to_zero
18
+ lines = ["def foo", " bar", "end"]
19
+ assert_equal 0, calc(lines, 2)
20
+ end
21
+
22
+ def test_class_def_end_nesting
23
+ lines = [
24
+ "class Foo",
25
+ " def bar",
26
+ " baz",
27
+ " end",
28
+ "end"
29
+ ]
30
+ assert_equal 2, calc(lines, 1) # def bar
31
+ assert_equal 4, calc(lines, 2) # baz
32
+ assert_equal 2, calc(lines, 3) # end (inner)
33
+ assert_equal 0, calc(lines, 4) # end (outer)
34
+ end
35
+
36
+ def test_if_else_end
37
+ lines = [
38
+ "if cond",
39
+ " a",
40
+ "else",
41
+ " b",
42
+ "end"
43
+ ]
44
+ assert_equal 2, calc(lines, 1) # a
45
+ assert_equal 0, calc(lines, 2) # else
46
+ assert_equal 2, calc(lines, 3) # b
47
+ assert_equal 0, calc(lines, 4) # end
48
+ end
49
+
50
+ def test_if_elsif_else_end
51
+ lines = [
52
+ "if a",
53
+ " x",
54
+ "elsif b",
55
+ " y",
56
+ "else",
57
+ " z",
58
+ "end"
59
+ ]
60
+ assert_equal 2, calc(lines, 1) # x
61
+ assert_equal 0, calc(lines, 2) # elsif
62
+ assert_equal 2, calc(lines, 3) # y
63
+ assert_equal 0, calc(lines, 4) # else
64
+ assert_equal 2, calc(lines, 5) # z
65
+ assert_equal 0, calc(lines, 6) # end
66
+ end
67
+
68
+ def test_modifier_if_does_not_increase_indent
69
+ lines = [
70
+ "def foo",
71
+ " return if true",
72
+ " bar",
73
+ "end"
74
+ ]
75
+ assert_equal 2, calc(lines, 1) # return if true
76
+ assert_equal 2, calc(lines, 2) # bar
77
+ assert_equal 0, calc(lines, 3) # end
78
+ end
79
+
80
+ def test_do_end_block
81
+ lines = [
82
+ "items.each do |x|",
83
+ " puts x",
84
+ "end"
85
+ ]
86
+ assert_equal 2, calc(lines, 1) # puts x
87
+ assert_equal 0, calc(lines, 2) # end
88
+ end
89
+
90
+ def test_brace_block
91
+ lines = [
92
+ "items.map {",
93
+ " |x| x + 1",
94
+ "}"
95
+ ]
96
+ assert_equal 2, calc(lines, 1)
97
+ assert_equal 0, calc(lines, 2)
98
+ end
99
+
100
+ def test_case_when
101
+ lines = [
102
+ "case x",
103
+ "when 1",
104
+ " a",
105
+ "when 2",
106
+ " b",
107
+ "end"
108
+ ]
109
+ assert_equal 0, calc(lines, 1) # when 1
110
+ assert_equal 2, calc(lines, 2) # a
111
+ assert_equal 0, calc(lines, 3) # when 2
112
+ assert_equal 2, calc(lines, 4) # b
113
+ assert_equal 0, calc(lines, 5) # end
114
+ end
115
+
116
+ def test_rescue_ensure
117
+ lines = [
118
+ "begin",
119
+ " risky",
120
+ "rescue => e",
121
+ " handle",
122
+ "ensure",
123
+ " cleanup",
124
+ "end"
125
+ ]
126
+ assert_equal 2, calc(lines, 1) # risky
127
+ assert_equal 0, calc(lines, 2) # rescue
128
+ assert_equal 2, calc(lines, 3) # handle
129
+ assert_equal 0, calc(lines, 4) # ensure
130
+ assert_equal 2, calc(lines, 5) # cleanup
131
+ assert_equal 0, calc(lines, 6) # end
132
+ end
133
+
134
+ def test_unless_until_while_for
135
+ %w[unless until while for].each do |kw|
136
+ lines = ["#{kw} cond", " body", "end"]
137
+ assert_equal 2, calc(lines, 1), "body after #{kw}"
138
+ assert_equal 0, calc(lines, 2), "end after #{kw}"
139
+ end
140
+ end
141
+
142
+ def test_module_nesting
143
+ lines = [
144
+ "module A",
145
+ " module B",
146
+ " def foo",
147
+ " bar",
148
+ " end",
149
+ " end",
150
+ "end"
151
+ ]
152
+ assert_equal 2, calc(lines, 1) # module B
153
+ assert_equal 4, calc(lines, 2) # def foo
154
+ assert_equal 6, calc(lines, 3) # bar
155
+ assert_equal 4, calc(lines, 4) # end (inner)
156
+ assert_equal 2, calc(lines, 5) # end (mid)
157
+ assert_equal 0, calc(lines, 6) # end (outer)
158
+ end
159
+
160
+ def test_comment_lines_are_skipped
161
+ lines = [
162
+ "def foo",
163
+ " # comment",
164
+ " bar",
165
+ "end"
166
+ ]
167
+ assert_equal 2, calc(lines, 1) # comment
168
+ assert_equal 2, calc(lines, 2) # bar
169
+ assert_equal 0, calc(lines, 3) # end
170
+ end
171
+
172
+ def test_shiftwidth_4
173
+ lines = ["def foo", " bar", "end"]
174
+ assert_equal 4, calc(lines, 1, 4)
175
+ assert_equal 0, calc(lines, 2, 4)
176
+ end
177
+
178
+ def test_paren_bracket_nesting
179
+ lines = [
180
+ "x = [",
181
+ " 1,",
182
+ " 2",
183
+ "]"
184
+ ]
185
+ assert_equal 2, calc(lines, 1)
186
+ assert_equal 2, calc(lines, 2)
187
+ assert_equal 0, calc(lines, 3)
188
+ end
189
+
190
+ def test_empty_lines_preserve_depth
191
+ lines = [
192
+ "def foo",
193
+ "",
194
+ " bar",
195
+ "end"
196
+ ]
197
+ assert_equal 2, calc(lines, 1) # empty line inside def
198
+ assert_equal 2, calc(lines, 2) # bar
199
+ assert_equal 0, calc(lines, 3) # end
200
+ end
201
+ end
@@ -30,6 +30,21 @@ class InputScreenIntegrationTest < Minitest::Test
30
30
  end
31
31
  end
32
32
 
33
+ def with_fake_select
34
+ io_sc = IO.singleton_class
35
+ verbose, $VERBOSE = $VERBOSE, nil
36
+ io_sc.alias_method(:__orig_select_for_input_screen_test, :select)
37
+ io_sc.define_method(:select) do |readers, *_rest|
38
+ ready = Array(readers).select { |io| io.respond_to?(:ready?) && io.ready? }
39
+ ready.empty? ? nil : [ready, [], []]
40
+ end
41
+ yield
42
+ ensure
43
+ io_sc.alias_method(:select, :__orig_select_for_input_screen_test)
44
+ io_sc.remove_method(:__orig_select_for_input_screen_test) rescue nil
45
+ $VERBOSE = verbose
46
+ end
47
+
33
48
  def test_input_pagedown_to_app_and_screen_render
34
49
  app = RuVim::App.new(clean: true)
35
50
  editor = app.instance_variable_get(:@editor)
@@ -41,17 +56,9 @@ class InputScreenIntegrationTest < Minitest::Test
41
56
  app.instance_variable_set(:@screen, screen)
42
57
 
43
58
  stdin = FakeTTY.new("\e[6~")
44
- input = RuVim::Input.new(stdin: stdin)
45
-
46
- io_sc = IO.singleton_class
47
- verbose, $VERBOSE = $VERBOSE, nil
48
- io_sc.alias_method(:__orig_select_for_input_screen_test, :select)
49
- io_sc.define_method(:select) do |readers, *_rest|
50
- ready = Array(readers).select { |io| io.respond_to?(:ready?) && io.ready? }
51
- ready.empty? ? nil : [ready, [], []]
52
- end
59
+ input = RuVim::Input.new(stdin)
53
60
 
54
- begin
61
+ with_fake_select do
55
62
  key = input.read_key(timeout: 0.2)
56
63
  assert_equal :pagedown, key
57
64
 
@@ -60,10 +67,54 @@ class InputScreenIntegrationTest < Minitest::Test
60
67
 
61
68
  assert_operator editor.current_window.cursor_y, :>, 0
62
69
  assert_includes term.writes.last, "line "
63
- ensure
64
- io_sc.alias_method(:select, :__orig_select_for_input_screen_test)
65
- io_sc.remove_method(:__orig_select_for_input_screen_test) rescue nil
66
- $VERBOSE = verbose
70
+ end
71
+ end
72
+
73
+ def test_input_keeps_repeated_arrow_sequences_separate
74
+ stdin = FakeTTY.new("\e[A\e[A")
75
+ input = RuVim::Input.new(stdin)
76
+
77
+ with_fake_select do
78
+ assert_equal :up, input.read_key(timeout: 0.2)
79
+ assert_equal :up, input.read_key(timeout: 0.2)
80
+ end
81
+ end
82
+
83
+ def test_input_reads_ctrl_z
84
+ stdin = FakeTTY.new("\u001a")
85
+ input = RuVim::Input.new(stdin)
86
+
87
+ with_fake_select do
88
+ assert_equal :ctrl_z, input.read_key(timeout: 0.2)
89
+ end
90
+ end
91
+
92
+ def test_input_shift_arrow_sequences
93
+ {
94
+ "\e[1;2A" => :shift_up,
95
+ "\e[1;2B" => :shift_down,
96
+ "\e[1;2C" => :shift_right,
97
+ "\e[1;2D" => :shift_left
98
+ }.each do |seq, expected|
99
+ stdin = FakeTTY.new(seq)
100
+ input = RuVim::Input.new(stdin)
101
+
102
+ with_fake_select do
103
+ assert_equal expected, input.read_key(timeout: 0.2), "Expected #{expected} for sequence #{seq.inspect}"
104
+ end
105
+ end
106
+ end
107
+
108
+ def test_has_pending_input_returns_true_when_data_available
109
+ stdin = FakeTTY.new("abc")
110
+ input = RuVim::Input.new(stdin)
111
+
112
+ with_fake_select do
113
+ assert input.has_pending_input?
114
+ input.read_key(timeout: 0)
115
+ input.read_key(timeout: 0)
116
+ input.read_key(timeout: 0)
117
+ refute input.has_pending_input?
67
118
  end
68
119
  end
69
120
  end
@@ -0,0 +1,279 @@
1
+ require_relative "test_helper"
2
+
3
+ class MarkdownRendererTest < Minitest::Test
4
+ def renderer
5
+ RuVim::RichView::MarkdownRenderer
6
+ end
7
+
8
+ # --- Registration ---
9
+
10
+ def test_renderer_registered_for_markdown
11
+ assert_equal renderer, RuVim::RichView.renderer_for("markdown")
12
+ end
13
+
14
+ def test_delimiter_for_markdown
15
+ assert_nil renderer.delimiter_for("markdown")
16
+ end
17
+
18
+ # --- Headings ---
19
+
20
+ def test_heading_h1_bold
21
+ lines = ["# Hello"]
22
+ result = renderer.render_visible(lines, delimiter: nil)
23
+ assert_equal 1, result.length
24
+ assert_match(/\e\[1[;m]/, result[0]) # bold (standalone or combined)
25
+ assert_includes result[0], "# Hello"
26
+ end
27
+
28
+ def test_heading_h2
29
+ lines = ["## Section"]
30
+ result = renderer.render_visible(lines, delimiter: nil)
31
+ assert_includes result[0], "## Section"
32
+ assert_match(/\e\[1[;m]/, result[0]) # bold
33
+ end
34
+
35
+ def test_heading_h3_to_h6
36
+ (3..6).each do |level|
37
+ hashes = "#" * level
38
+ lines = ["#{hashes} Title"]
39
+ result = renderer.render_visible(lines, delimiter: nil)
40
+ assert_includes result[0], "#{hashes} Title", "H#{level} text should be preserved"
41
+ assert_includes result[0], "\e[", "H#{level} should have ANSI styling"
42
+ end
43
+ end
44
+
45
+ # --- Inline elements ---
46
+
47
+ def test_inline_bold
48
+ lines = ["hello **bold** world"]
49
+ result = renderer.render_visible(lines, delimiter: nil)
50
+ assert_includes result[0], "\e[1m" # bold on
51
+ assert_includes result[0], "\e[22m" # bold off
52
+ assert_includes result[0], "**bold**"
53
+ end
54
+
55
+ def test_inline_italic
56
+ lines = ["hello *italic* world"]
57
+ result = renderer.render_visible(lines, delimiter: nil)
58
+ assert_includes result[0], "\e[3m" # italic on
59
+ assert_includes result[0], "\e[23m" # italic off
60
+ assert_includes result[0], "*italic*"
61
+ end
62
+
63
+ def test_inline_code
64
+ lines = ["use `foo()` here"]
65
+ result = renderer.render_visible(lines, delimiter: nil)
66
+ assert_includes result[0], "\e[33m" # yellow
67
+ assert_includes result[0], "`foo()`"
68
+ end
69
+
70
+ def test_inline_link
71
+ lines = ["click [here](http://example.com) now"]
72
+ result = renderer.render_visible(lines, delimiter: nil)
73
+ assert_includes result[0], "\e[4m" # underline for text
74
+ assert_includes result[0], "here"
75
+ assert_includes result[0], "http://example.com"
76
+ end
77
+
78
+ def test_checkbox_unchecked
79
+ lines = ["- [ ] todo item"]
80
+ result = renderer.render_visible(lines, delimiter: nil)
81
+ assert_includes result[0], "\e[90m" # dim
82
+ assert_includes result[0], "[ ]"
83
+ end
84
+
85
+ def test_checkbox_checked
86
+ lines = ["- [x] done item"]
87
+ result = renderer.render_visible(lines, delimiter: nil)
88
+ assert_includes result[0], "\e[32m" # green
89
+ assert_includes result[0], "[x]"
90
+ end
91
+
92
+ # --- Code blocks ---
93
+
94
+ def test_code_fence_styling
95
+ lines = ["```ruby", "puts 'hi'", "```"]
96
+ result = renderer.render_visible(lines, delimiter: nil)
97
+ # Fence lines should be dim
98
+ assert_includes result[0], "\e[90m"
99
+ # Content should be warm-colored
100
+ assert_includes result[1], "\e[38;5;223m"
101
+ # Closing fence should be dim
102
+ assert_includes result[2], "\e[90m"
103
+ end
104
+
105
+ def test_code_fence_tilde
106
+ lines = ["~~~", "code line", "~~~"]
107
+ result = renderer.render_visible(lines, delimiter: nil)
108
+ assert_includes result[0], "\e[90m"
109
+ assert_includes result[1], "\e[38;5;223m"
110
+ end
111
+
112
+ def test_code_block_context_from_pre_context
113
+ # Simulate rendering in the middle of a code block
114
+ # pre_context_lines should carry the open fence state
115
+ pre_context = ["```ruby", "line1"]
116
+ lines = ["line2", "```"]
117
+ result = renderer.render_visible(lines, delimiter: nil, context: { pre_context_lines: pre_context })
118
+ # line2 should be inside code block (warm color)
119
+ assert_includes result[0], "\e[38;5;223m"
120
+ # closing fence
121
+ assert_includes result[1], "\e[90m"
122
+ end
123
+
124
+ def test_needs_pre_context
125
+ assert renderer.needs_pre_context?
126
+ end
127
+
128
+ # --- HR ---
129
+
130
+ def test_hr_dashes
131
+ lines = ["---"]
132
+ result = renderer.render_visible(lines, delimiter: nil)
133
+ assert_includes result[0], "\u2500" # box drawing horizontal
134
+ end
135
+
136
+ def test_hr_asterisks
137
+ lines = ["***"]
138
+ result = renderer.render_visible(lines, delimiter: nil)
139
+ assert_includes result[0], "\u2500"
140
+ end
141
+
142
+ def test_hr_underscores
143
+ lines = ["___"]
144
+ result = renderer.render_visible(lines, delimiter: nil)
145
+ assert_includes result[0], "\u2500"
146
+ end
147
+
148
+ # --- Block quotes ---
149
+
150
+ def test_block_quote
151
+ lines = ["> quoted text"]
152
+ result = renderer.render_visible(lines, delimiter: nil)
153
+ assert_includes result[0], "\e[36m" # cyan
154
+ assert_includes result[0], "> quoted text"
155
+ end
156
+
157
+ # --- Tables ---
158
+
159
+ def test_table_basic
160
+ lines = [
161
+ "| Name | Age |",
162
+ "| ----- | --- |",
163
+ "| Alice | 30 |"
164
+ ]
165
+ result = renderer.render_visible(lines, delimiter: nil)
166
+ assert_equal 3, result.length
167
+ # Data rows should use box-drawing vertical bar
168
+ assert_includes result[0], "\u2502" # │
169
+ assert_includes result[2], "\u2502"
170
+ # Separator row should use box-drawing
171
+ assert_includes result[1], "\u2500" # ─
172
+ assert_includes result[1], "\u253c" # ┼
173
+ end
174
+
175
+ def test_table_column_alignment
176
+ lines = [
177
+ "| Short | Long column |",
178
+ "| ----- | ----------- |",
179
+ "| A | B |"
180
+ ]
181
+ result = renderer.render_visible(lines, delimiter: nil)
182
+ # Both data rows should have same display width
183
+ w0 = display_width_without_ansi(result[0])
184
+ w2 = display_width_without_ansi(result[2])
185
+ assert_equal w0, w2
186
+ end
187
+
188
+ # --- cursor_display_col ---
189
+
190
+ def test_cursor_display_col_non_table_line
191
+ # For non-table lines, should return screen_col_for_char_index
192
+ line = "hello world"
193
+ col = renderer.cursor_display_col(line, 5, visible_lines: [line], delimiter: nil)
194
+ expected = RuVim::TextMetrics.screen_col_for_char_index(line, 5)
195
+ assert_equal expected, col
196
+ end
197
+
198
+ def test_cursor_display_col_table_line
199
+ lines = [
200
+ "| Name | Age |",
201
+ "| ----- | --- |",
202
+ "| Alice | 30 |"
203
+ ]
204
+ # Cursor at start of "Alice" (index within raw line)
205
+ raw_line = lines[2]
206
+ col = renderer.cursor_display_col(raw_line, 2, visible_lines: lines, delimiter: nil)
207
+ # Should be > 0 (after the leading │ and padding)
208
+ assert col >= 0
209
+ end
210
+
211
+ # --- ANSI support in render_rich_view_line_sc ---
212
+
213
+ def test_render_rich_view_line_sc_with_ansi
214
+ screen = create_test_screen
215
+ # Line with ANSI bold: "\e[1m" is 4 chars but 0 display width
216
+ text = "\e[1mhello\e[m world"
217
+ result = screen.send(:render_rich_view_line_sc, text, width: 20, skip_sc: 0)
218
+ # Should contain the ANSI sequences and the text
219
+ assert_includes result, "\e[1m"
220
+ assert_includes result, "hello"
221
+ assert_includes result, "world"
222
+ # Should end with reset
223
+ assert result.end_with?("\e[m") || result.include?("\e[m")
224
+ end
225
+
226
+ def test_render_rich_view_line_sc_ansi_skip
227
+ screen = create_test_screen
228
+ # ANSI at start, skip some display columns
229
+ text = "\e[1mhello\e[m world"
230
+ result = screen.send(:render_rich_view_line_sc, text, width: 5, skip_sc: 3)
231
+ # Should show "lo wo" or similar (skipping 3 display cols of "hello")
232
+ assert_includes result, "lo"
233
+ end
234
+
235
+ # --- Integration test ---
236
+
237
+ def test_markdown_rich_mode_integration
238
+ editor = fresh_editor
239
+ buf = editor.current_buffer
240
+ buf.replace_all_lines!(["# Title", "", "Some **bold** text"])
241
+ buf.options["filetype"] = "markdown"
242
+
243
+ RuVim::RichView.open!(editor, format: "markdown")
244
+ assert_equal :rich, editor.mode
245
+ state = editor.rich_state
246
+ assert_equal "markdown", state[:format]
247
+
248
+ # Render visible lines
249
+ lines = (0...buf.line_count).map { |i| buf.line_at(i) }
250
+ rendered = RuVim::RichView.render_visible_lines(editor, lines)
251
+ assert_equal 3, rendered.length
252
+ # Heading should have styling
253
+ assert_includes rendered[0], "\e["
254
+
255
+ RuVim::RichView.close!(editor)
256
+ assert_equal :normal, editor.mode
257
+ end
258
+
259
+ def test_detect_format_markdown
260
+ editor = fresh_editor
261
+ buf = editor.current_buffer
262
+ buf.options["filetype"] = "markdown"
263
+ assert_equal "markdown", RuVim::RichView.detect_format(buf)
264
+ end
265
+
266
+ private
267
+
268
+ def display_width_without_ansi(str)
269
+ # Strip ANSI escape sequences for width calculation
270
+ clean = str.gsub(/\e\[[0-9;]*m/, "")
271
+ RuVim::DisplayWidth.display_width(clean)
272
+ end
273
+
274
+ def create_test_screen
275
+ terminal = Object.new
276
+ def terminal.winsize; [24, 80]; end
277
+ RuVim::Screen.new(terminal: terminal)
278
+ end
279
+ end