ruvim 0.2.0 → 0.4.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +96 -0
  4. data/CLAUDE.md +1 -0
  5. data/README.md +15 -1
  6. data/docs/binding.md +39 -0
  7. data/docs/command.md +163 -4
  8. data/docs/config.md +12 -4
  9. data/docs/done.md +21 -0
  10. data/docs/spec.md +214 -18
  11. data/docs/todo.md +1 -5
  12. data/docs/tutorial.md +24 -0
  13. data/docs/vim_diff.md +105 -173
  14. data/lib/ruvim/app.rb +1165 -70
  15. data/lib/ruvim/buffer.rb +47 -1
  16. data/lib/ruvim/cli.rb +18 -3
  17. data/lib/ruvim/clipboard.rb +2 -0
  18. data/lib/ruvim/command_invocation.rb +3 -1
  19. data/lib/ruvim/command_line.rb +2 -0
  20. data/lib/ruvim/command_registry.rb +2 -0
  21. data/lib/ruvim/config_dsl.rb +2 -0
  22. data/lib/ruvim/config_loader.rb +2 -0
  23. data/lib/ruvim/context.rb +2 -0
  24. data/lib/ruvim/dispatcher.rb +143 -13
  25. data/lib/ruvim/display_width.rb +3 -0
  26. data/lib/ruvim/editor.rb +466 -71
  27. data/lib/ruvim/ex_command_registry.rb +2 -0
  28. data/lib/ruvim/file_watcher.rb +243 -0
  29. data/lib/ruvim/git/blame.rb +245 -0
  30. data/lib/ruvim/git/branch.rb +97 -0
  31. data/lib/ruvim/git/commit.rb +102 -0
  32. data/lib/ruvim/git/diff.rb +129 -0
  33. data/lib/ruvim/git/handler.rb +84 -0
  34. data/lib/ruvim/git/log.rb +41 -0
  35. data/lib/ruvim/git/status.rb +103 -0
  36. data/lib/ruvim/global_commands.rb +1066 -105
  37. data/lib/ruvim/highlighter.rb +19 -22
  38. data/lib/ruvim/input.rb +40 -28
  39. data/lib/ruvim/keymap_manager.rb +83 -0
  40. data/lib/ruvim/keyword_chars.rb +2 -0
  41. data/lib/ruvim/lang/base.rb +25 -0
  42. data/lib/ruvim/lang/csv.rb +18 -0
  43. data/lib/ruvim/lang/diff.rb +41 -0
  44. data/lib/ruvim/lang/json.rb +52 -0
  45. data/lib/ruvim/lang/markdown.rb +170 -0
  46. data/lib/ruvim/lang/ruby.rb +236 -0
  47. data/lib/ruvim/lang/scheme.rb +44 -0
  48. data/lib/ruvim/lang/tsv.rb +19 -0
  49. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  50. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  51. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  52. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  53. data/lib/ruvim/rich_view.rb +109 -0
  54. data/lib/ruvim/screen.rb +503 -109
  55. data/lib/ruvim/terminal.rb +18 -1
  56. data/lib/ruvim/text_metrics.rb +2 -0
  57. data/lib/ruvim/version.rb +1 -1
  58. data/lib/ruvim/window.rb +2 -0
  59. data/lib/ruvim.rb +24 -0
  60. data/test/app_completion_test.rb +98 -0
  61. data/test/app_dot_repeat_test.rb +13 -0
  62. data/test/app_motion_test.rb +13 -0
  63. data/test/app_scenario_test.rb +898 -1
  64. data/test/app_startup_test.rb +187 -0
  65. data/test/arglist_test.rb +113 -0
  66. data/test/buffer_test.rb +49 -30
  67. data/test/cli_test.rb +14 -0
  68. data/test/clipboard_test.rb +67 -0
  69. data/test/command_line_test.rb +118 -0
  70. data/test/config_dsl_test.rb +87 -0
  71. data/test/dispatcher_test.rb +322 -0
  72. data/test/display_width_test.rb +41 -0
  73. data/test/editor_register_test.rb +23 -0
  74. data/test/file_watcher_test.rb +197 -0
  75. data/test/follow_test.rb +199 -0
  76. data/test/git_blame_test.rb +713 -0
  77. data/test/highlighter_test.rb +165 -0
  78. data/test/indent_test.rb +287 -0
  79. data/test/input_screen_integration_test.rb +40 -2
  80. data/test/markdown_renderer_test.rb +279 -0
  81. data/test/on_save_hook_test.rb +150 -0
  82. data/test/rich_view_test.rb +734 -0
  83. data/test/screen_test.rb +304 -0
  84. data/test/search_option_test.rb +19 -0
  85. data/test/test_helper.rb +9 -0
  86. metadata +49 -2
data/lib/ruvim/screen.rb CHANGED
@@ -1,11 +1,15 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class Screen
3
5
  DEFAULT_TABSTOP = 2
4
6
  SYNTAX_CACHE_LIMIT = 2048
7
+ WRAP_SEGMENTS_CACHE_LIMIT = 1024
5
8
  def initialize(terminal:)
6
9
  @terminal = terminal
7
10
  @last_frame = nil
8
11
  @syntax_color_cache = {}
12
+ @wrapped_segments_cache = {}
9
13
  end
10
14
 
11
15
  def invalidate_cache!
@@ -13,6 +17,8 @@ module RuVim
13
17
  end
14
18
 
15
19
  def render(editor)
20
+ @rich_render_info = nil
21
+
16
22
  rows, cols = @terminal.winsize
17
23
  text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
18
24
  text_rows = [text_rows, 1].max
@@ -28,15 +34,31 @@ module RuVim
28
34
  rect = rects[win_id]
29
35
  next unless rect
30
36
  content_width = [rect[:width] - number_column_width(editor, win, buf), 1].max
31
- win.col_offset = 0 if wrap_enabled?(editor, win, buf)
32
- win.ensure_visible(
33
- buf,
34
- height: [rect[:height], 1].max,
35
- width: content_width,
36
- tabstop: tabstop_for(editor, win, buf),
37
- scrolloff: editor.effective_option("scrolloff", window: win, buffer: buf),
38
- sidescrolloff: editor.effective_option("sidescrolloff", window: win, buffer: buf)
39
- )
37
+ if RuVim::RichView.active?(editor)
38
+ # Vertical scrolling only — keep raw col_offset untouched
39
+ win.ensure_visible(
40
+ buf,
41
+ height: [rect[:height], 1].max,
42
+ width: content_width,
43
+ tabstop: tabstop_for(editor, win, buf),
44
+ scrolloff: editor.effective_option("scrolloff", window: win, buffer: buf),
45
+ sidescrolloff: editor.effective_option("sidescrolloff", window: win, buffer: buf)
46
+ )
47
+ ensure_visible_rich(editor, win, buf, rect, content_width)
48
+ else
49
+ win.col_offset = 0 if wrap_enabled?(editor, win, buf)
50
+ win.ensure_visible(
51
+ buf,
52
+ height: [rect[:height], 1].max,
53
+ width: content_width,
54
+ tabstop: tabstop_for(editor, win, buf),
55
+ scrolloff: editor.effective_option("scrolloff", window: win, buffer: buf),
56
+ sidescrolloff: editor.effective_option("sidescrolloff", window: win, buffer: buf)
57
+ )
58
+ if wrap_enabled?(editor, win, buf)
59
+ ensure_visible_under_wrap(editor, win, buf, height: [rect[:height], 1].max, content_w: content_width)
60
+ end
61
+ end
40
62
  end
41
63
 
42
64
  frame = build_frame(editor, rows:, cols:, text_rows:, text_cols:, rects:)
@@ -71,15 +93,19 @@ module RuVim
71
93
  lines = {}
72
94
  render_window_area(editor, lines, rects, text_rows:, text_cols:)
73
95
 
74
- status_row = text_rows + 1
75
- lines[status_row] = "\e[7m#{truncate(status_line(editor, cols), cols)}\e[m"
76
- lines[status_row + 1] = ""
77
-
78
- if editor.command_line_active?
79
- cmd = editor.command_line
80
- lines[status_row + 1] = truncate("#{cmd.prefix}#{cmd.text}", cols)
81
- elsif editor.message_error?
82
- lines[status_row + 1] = error_message_line(editor.message.to_s, cols)
96
+ if editor.hit_enter_active? && editor.hit_enter_lines
97
+ render_hit_enter_overlay(editor, lines, text_rows:, cols:)
98
+ else
99
+ status_row = text_rows + 1
100
+ lines[status_row] = "\e[7m#{truncate(status_line(editor, cols), cols)}\e[m"
101
+ lines[status_row + 1] = ""
102
+
103
+ if editor.command_line_active?
104
+ cmd = editor.command_line
105
+ lines[status_row + 1] = truncate("#{cmd.prefix}#{cmd.text}", cols)
106
+ elsif editor.message_error?
107
+ lines[status_row + 1] = error_message_line(editor.message.to_s, cols)
108
+ end
83
109
  end
84
110
 
85
111
  {
@@ -90,66 +116,129 @@ module RuVim
90
116
  }
91
117
  end
92
118
 
93
- def render_window_area(editor, lines, rects, text_rows:, text_cols:)
94
- if rects.values.any? { |r| r[:separator] == :vertical }
95
- render_vertical_windows(editor, lines, rects, text_rows:, text_cols:)
96
- else
97
- render_horizontal_windows(editor, lines, rects, text_rows:, text_cols:)
119
+ def render_hit_enter_overlay(editor, lines, text_rows:, cols:)
120
+ msg_lines = editor.hit_enter_lines
121
+ prompt = "Press ENTER or type command to continue"
122
+ # Total rows available: text_rows (text area) + 1 (status) + 1 (command) = text_rows + 2
123
+ total_rows = text_rows + 2
124
+ # We need msg_lines.length rows for messages + 1 row for the prompt
125
+ overlay_count = msg_lines.length + 1
126
+ start_row = [total_rows - overlay_count + 1, 1].max
127
+ msg_lines.each_with_index do |line, i|
128
+ row_no = start_row + i
129
+ break if row_no > total_rows
130
+ lines[row_no] = truncate(line.to_s, cols)
98
131
  end
132
+ prompt_row = start_row + msg_lines.length
133
+ prompt_row = [prompt_row, total_rows].min
134
+ lines[prompt_row] = "\e[7m#{truncate(prompt, cols)}\e[m"
99
135
  end
100
136
 
101
- def render_horizontal_windows(editor, lines, rects, text_rows:, text_cols:)
102
- 1.upto(text_rows) { |row_no| lines[row_no] = " " * text_cols }
137
+ def render_window_area(editor, lines, rects, text_rows:, text_cols:)
138
+ render_tree_windows(editor, lines, rects, text_rows:, text_cols:)
139
+ end
103
140
 
141
+ def render_tree_windows(editor, lines, rects, text_rows:, text_cols:)
142
+ # Pre-render each window's rows
143
+ window_rows_cache = {}
104
144
  editor.window_order.each do |win_id|
105
145
  rect = rects[win_id]
106
146
  next unless rect
107
-
108
147
  window = editor.windows.fetch(win_id)
109
148
  buffer = editor.buffers.fetch(window.buffer_id)
110
149
  gutter_w = number_column_width(editor, window, buffer)
111
150
  content_w = [rect[:width] - gutter_w, 1].max
112
- rows = window_render_rows(editor, window, buffer, height: rect[:height], gutter_w:, content_w:)
113
- rect[:height].times do |dy|
114
- row_no = rect[:top] + dy
115
- lines[row_no] = rows[dy] || (" " * rect[:width])
116
- end
151
+ window_rows_cache[win_id] = window_render_rows(editor, window, buffer, height: rect[:height], gutter_w:, content_w:)
117
152
  end
118
153
 
119
- rects.each_value do |rect|
120
- next unless rect[:separator] == :horizontal
121
- lines[rect[:sep_row]] = ("-" * text_cols)
122
- end
123
- end
154
+ # Build a row-plan: for each screen row, collect the pieces to concatenate
155
+ row_plans = build_row_plans(editor.layout_tree, rects, text_rows, text_cols)
124
156
 
125
- def render_vertical_windows(editor, lines, rects, text_rows:, text_cols:)
126
157
  1.upto(text_rows) do |row_no|
158
+ plan = row_plans[row_no]
159
+ unless plan
160
+ lines[row_no] = " " * text_cols
161
+ next
162
+ end
163
+
127
164
  pieces = +""
128
- editor.window_order.each_with_index do |win_id, idx|
129
- rect = rects[win_id]
130
- next unless rect
131
- window = editor.windows.fetch(win_id)
132
- buffer = editor.buffers.fetch(window.buffer_id)
133
- gutter_w = number_column_width(editor, window, buffer)
134
- content_w = [rect[:width] - gutter_w, 1].max
135
- dy = row_no - rect[:top]
136
- text =
137
- if dy >= 0 && dy < rect[:height]
138
- @__window_rows_cache ||= {}
139
- key = [window.id, rect[:height], gutter_w, content_w, window.row_offset, window.col_offset, window.cursor_y, window.cursor_x,
140
- editor.effective_option("wrap", window:, buffer:), editor.effective_option("linebreak", window:, buffer:),
141
- editor.effective_option("breakindent", window:, buffer:), editor.effective_option("showbreak", window:, buffer:)]
142
- @__window_rows_cache[key] ||= window_render_rows(editor, window, buffer, height: rect[:height], gutter_w:, content_w:)
143
- @__window_rows_cache[key][dy] || (" " * rect[:width])
144
- else
145
- " " * rect[:width]
146
- end
147
- pieces << text
148
- pieces << "|" if idx < editor.window_order.length - 1
165
+ plan.each do |piece|
166
+ case piece[:type]
167
+ when :window
168
+ rect = rects[piece[:id]]
169
+ dy = row_no - rect[:top]
170
+ rows = window_rows_cache[piece[:id]]
171
+ text = (rows && dy >= 0 && dy < rect[:height]) ? (rows[dy] || " " * rect[:width]) : " " * rect[:width]
172
+ pieces << text
173
+ when :vsep
174
+ pieces << "|"
175
+ when :hsep
176
+ pieces << "-" * piece[:width]
177
+ when :blank
178
+ pieces << " " * piece[:width]
179
+ end
149
180
  end
150
181
  lines[row_no] = pieces
151
182
  end
152
- @__window_rows_cache = nil
183
+ end
184
+
185
+ # Build a row-by-row plan for compositing. Each row's plan is an array of
186
+ # pieces to concatenate left-to-right.
187
+ def build_row_plans(node, rects, text_rows, text_cols)
188
+ plans = {}
189
+ fill_row_plans(node, rects, plans, 1, text_rows)
190
+ plans
191
+ end
192
+
193
+ def fill_row_plans(node, rects, plans, row_start, row_end)
194
+ return unless node
195
+
196
+ if node[:type] == :window
197
+ rect = rects[node[:id]]
198
+ return unless rect
199
+ rect[:height].times do |dy|
200
+ row_no = rect[:top] + dy
201
+ next if row_no < row_start || row_no > row_end
202
+ plans[row_no] ||= []
203
+ plans[row_no] << { type: :window, id: node[:id] }
204
+ end
205
+ return
206
+ end
207
+
208
+ children = node[:children]
209
+ if node[:type] == :vsplit
210
+ children.each_with_index do |child, i|
211
+ fill_row_plans(child, rects, plans, row_start, row_end)
212
+ if i < children.length - 1
213
+ # Insert vsep marker for the rows spanned by these children
214
+ child_leaves = tree_leaves_for_rects(child)
215
+ child_rects_list = child_leaves.filter_map { |id| rects[id] }
216
+ next if child_rects_list.empty?
217
+ top = child_rects_list.map { |r| r[:top] }.min
218
+ bottom = child_rects_list.map { |r| r[:top] + r[:height] - 1 }.max
219
+ top.upto(bottom) do |row_no|
220
+ next if row_no < row_start || row_no > row_end
221
+ plans[row_no] ||= []
222
+ plans[row_no] << { type: :vsep }
223
+ end
224
+ end
225
+ end
226
+ elsif node[:type] == :hsplit
227
+ children.each_with_index do |child, i|
228
+ fill_row_plans(child, rects, plans, row_start, row_end)
229
+ if i < children.length - 1
230
+ child_leaves = tree_leaves_for_rects(child)
231
+ child_rects_list = child_leaves.filter_map { |id| rects[id] }
232
+ next if child_rects_list.empty?
233
+ sep_row = child_rects_list.map { |r| r[:top] + r[:height] }.max
234
+ left = child_rects_list.map { |r| r[:left] }.min
235
+ right = child_rects_list.map { |r| r[:left] + r[:width] - 1 }.max
236
+ next unless sep_row >= row_start && sep_row <= row_end
237
+ plans[sep_row] ||= []
238
+ plans[sep_row] << { type: :hsep, width: right - left + 1 }
239
+ end
240
+ end
241
+ end
153
242
  end
154
243
 
155
244
  def can_diff_render?(frame)
@@ -301,22 +390,144 @@ module RuVim
301
390
  end
302
391
 
303
392
  def plain_window_render_rows(editor, window, buffer, height:, gutter_w:, content_w:)
393
+ if RuVim::RichView.active?(editor)
394
+ return rich_view_render_rows(editor, window, buffer, height:, gutter_w:, content_w:)
395
+ end
396
+
304
397
  Array.new(height) do |dy|
305
398
  buffer_row = window.row_offset + dy
306
399
  if buffer_row < buffer.line_count
307
400
  render_window_row(editor, window, buffer, buffer_row, gutter_w:, content_w:)
308
401
  else
309
- line_number_prefix(editor, window, buffer, nil, gutter_w) + pad_plain_display("~", content_w)
402
+ render_gutter_prefix(editor, window, buffer, nil, gutter_w) + pad_plain_display("~", content_w)
310
403
  end
311
404
  end
312
405
  end
313
406
 
407
+ def rich_view_render_rows(editor, window, buffer, height:, gutter_w:, content_w:)
408
+ raw_lines = []
409
+ height.times do |dy|
410
+ row = window.row_offset + dy
411
+ raw_lines << (row < buffer.line_count ? buffer.line_at(row) : nil)
412
+ end
413
+
414
+ non_nil = raw_lines.compact
415
+ context = rich_view_context(editor, window, buffer)
416
+ formatted = RuVim::RichView.render_visible_lines(editor, non_nil, context: context)
417
+ fmt_idx = 0
418
+ col_offset_sc = @rich_render_info ? @rich_render_info[:col_offset_sc] : 0
419
+
420
+ Array.new(height) do |dy|
421
+ buffer_row = window.row_offset + dy
422
+ prefix = render_gutter_prefix(editor, window, buffer, buffer_row < buffer.line_count ? buffer_row : nil, gutter_w)
423
+ if buffer_row < buffer.line_count
424
+ line = formatted[fmt_idx] || ""
425
+ fmt_idx += 1
426
+ body = render_rich_view_line_sc(line, width: content_w, skip_sc: col_offset_sc)
427
+ prefix + body
428
+ else
429
+ prefix + pad_plain_display("~", content_w)
430
+ end
431
+ end
432
+ end
433
+
434
+ def rich_view_context(editor, window, buffer)
435
+ state = editor.rich_state
436
+ return {} unless state
437
+
438
+ format = state[:format]
439
+ renderer = RuVim::RichView.renderer_for(format)
440
+ return {} unless renderer && renderer.respond_to?(:needs_pre_context?) && renderer.needs_pre_context?
441
+
442
+ # Collect lines before the visible area for state tracking (e.g., code fences)
443
+ pre_lines = []
444
+ (0...window.row_offset).each do |row|
445
+ pre_lines << buffer.line_at(row) if row < buffer.line_count
446
+ end
447
+ { pre_context_lines: pre_lines }
448
+ end
449
+
450
+ # Render a formatted rich-view line by skipping `skip_sc` display columns
451
+ # then showing the next `width` display columns. Using screen columns
452
+ # instead of character indices keeps alignment correct when lines mix
453
+ # CJK and ASCII characters with different char-to-display-width ratios.
454
+ # ANSI escape sequences (\e[...m) are treated as zero-width and passed
455
+ # through to the output unchanged.
456
+ def render_rich_view_line_sc(text, width:, skip_sc:)
457
+ # Phase 1: skip `skip_sc` display columns
458
+ # Collect ANSI sequences encountered during skip so active styles carry over.
459
+ chars = text.to_s
460
+ pos = 0
461
+ skipped = 0
462
+ len = chars.length
463
+ pending_ansi = +""
464
+ while pos < len
465
+ if chars[pos] == "\e"
466
+ end_pos = find_ansi_end(chars, pos)
467
+ pending_ansi << chars[pos...end_pos]
468
+ pos = end_pos
469
+ next
470
+ end
471
+ ch = chars[pos]
472
+ cw = RuVim::DisplayWidth.cell_width(ch, col: skipped, tabstop: 8)
473
+ break if skipped + cw > skip_sc
474
+ skipped += cw
475
+ pos += 1
476
+ end
477
+ # If a wide char straddles the skip boundary, pad with a space
478
+ leading_pad = skip_sc - skipped
479
+
480
+ # Phase 2: collect `width` display columns
481
+ out = +""
482
+ out << pending_ansi unless pending_ansi.empty?
483
+ col = 0
484
+ if leading_pad > 0
485
+ out << " " * leading_pad
486
+ col += leading_pad
487
+ pos += 1 if pos < len && chars[pos] != "\e"
488
+ end
489
+ while pos < len
490
+ if chars[pos] == "\e"
491
+ end_pos = find_ansi_end(chars, pos)
492
+ out << chars[pos...end_pos]
493
+ pos = end_pos
494
+ next
495
+ end
496
+ ch = chars[pos]
497
+ cw = RuVim::DisplayWidth.cell_width(ch, col: skipped + col, tabstop: 8)
498
+ break if col + cw > width
499
+ out << ch
500
+ col += cw
501
+ pos += 1
502
+ end
503
+ out << " " * [width - col, 0].max
504
+ out << "\e[m"
505
+ out
506
+ end
507
+
508
+ # Find the end position of an ANSI escape sequence starting at `pos`.
509
+ # Handles CSI sequences (\e[...X) where X is a letter.
510
+ def find_ansi_end(str, pos)
511
+ i = pos + 1 # skip \e
512
+ return i if i >= str.length
513
+ if str[i] == "["
514
+ i += 1
515
+ # Skip parameter bytes and intermediate bytes
516
+ i += 1 while i < str.length && str[i].ord >= 0x20 && str[i].ord <= 0x3F
517
+ # Final byte
518
+ i += 1 if i < str.length && str[i].ord >= 0x40 && str[i].ord <= 0x7E
519
+ else
520
+ i += 1
521
+ end
522
+ i
523
+ end
524
+
314
525
  def wrapped_window_render_rows(editor, window, buffer, height:, gutter_w:, content_w:)
315
526
  rows = []
316
527
  row_idx = window.row_offset
317
528
  while rows.length < height
318
529
  if row_idx >= buffer.line_count
319
- rows << (line_number_prefix(editor, window, buffer, nil, gutter_w) + pad_plain_display("~", content_w))
530
+ rows << (render_gutter_prefix(editor, window, buffer, nil, gutter_w) + pad_plain_display("~", content_w))
320
531
  next
321
532
  end
322
533
 
@@ -325,7 +536,7 @@ module RuVim
325
536
  segments.each_with_index do |seg, seg_i|
326
537
  break if rows.length >= height
327
538
 
328
- gutter = line_number_prefix(editor, window, buffer, seg_i.zero? ? row_idx : nil, gutter_w)
539
+ gutter = render_gutter_prefix(editor, window, buffer, seg_i.zero? ? row_idx : nil, gutter_w)
329
540
  rows << gutter + render_text_segment(line, editor, buffer_row: row_idx, window:, buffer:, width: content_w,
330
541
  source_col_start: seg[:source_col_start], display_prefix: seg[:display_prefix])
331
542
  end
@@ -334,7 +545,92 @@ module RuVim
334
545
  rows
335
546
  end
336
547
 
548
+ def ensure_visible_under_wrap(editor, window, buffer, height:, content_w:)
549
+ return if height.to_i <= 0 || buffer.line_count <= 0
550
+
551
+ window.row_offset = [[window.row_offset.to_i, 0].max, buffer.line_count - 1].min
552
+ return if window.cursor_y < window.row_offset
553
+
554
+ cursor_line = buffer.line_at(window.cursor_y)
555
+ cursor_segs = wrapped_segments_for_line(editor, window, buffer, cursor_line, width: content_w)
556
+ cursor_seg_index = wrapped_segment_index(cursor_segs, window.cursor_x)
557
+
558
+ visual_rows_before = 0
559
+ row = window.row_offset
560
+ while row < window.cursor_y
561
+ visual_rows_before += wrapped_segments_for_line(editor, window, buffer, buffer.line_at(row), width: content_w).length
562
+ row += 1
563
+ end
564
+
565
+ cursor_visual_row = visual_rows_before + cursor_seg_index
566
+ while cursor_visual_row >= height && window.row_offset < window.cursor_y
567
+ dropped = wrapped_segments_for_line(editor, window, buffer, buffer.line_at(window.row_offset), width: content_w).length
568
+ window.row_offset += 1
569
+ cursor_visual_row -= dropped
570
+ end
571
+ rescue StandardError
572
+ nil
573
+ end
574
+
575
+ # Compute a screen-column-based horizontal scroll offset for rich mode.
576
+ # Unlike normal mode (which stores a char index in window.col_offset),
577
+ # rich mode must scroll by display columns because CJK-padded formatted
578
+ # lines have different character counts for the same display width.
579
+ def ensure_visible_rich(editor, win, buf, rect, content_w)
580
+ state = editor.rich_state
581
+ unless state
582
+ @rich_render_info = nil
583
+ @rich_col_offset_sc = 0
584
+ return
585
+ end
586
+
587
+ format = state[:format]
588
+ delimiter = state[:delimiter]
589
+ renderer = RuVim::RichView.renderer_for(format)
590
+ height = [rect[:height], 1].max
591
+
592
+ raw_lines = height.times.map { |dy|
593
+ row = win.row_offset + dy
594
+ row < buf.line_count ? buf.line_at(row) : nil
595
+ }.compact
596
+
597
+ cursor_raw_line = buf.line_at(win.cursor_y)
598
+
599
+ if renderer && renderer.respond_to?(:cursor_display_col)
600
+ cursor_sc = renderer.cursor_display_col(
601
+ cursor_raw_line, win.cursor_x, visible_lines: raw_lines, delimiter: delimiter
602
+ )
603
+ else
604
+ cursor_sc = RuVim::TextMetrics.screen_col_for_char_index(cursor_raw_line, win.cursor_x)
605
+ end
606
+
607
+ # Use persisted screen-column offset from previous frame
608
+ offset_sc = @rich_col_offset_sc || 0
609
+
610
+ sso = editor.effective_option("sidescrolloff", window: win, buffer: buf).to_i
611
+ sso = [[sso, 0].max, [content_w - 1, 0].max].min
612
+
613
+ if cursor_sc < offset_sc + sso
614
+ offset_sc = [cursor_sc - sso, 0].max
615
+ elsif cursor_sc >= offset_sc + content_w - sso
616
+ offset_sc = cursor_sc - content_w + sso + 1
617
+ end
618
+ offset_sc = [offset_sc, 0].max
619
+
620
+ @rich_col_offset_sc = offset_sc
621
+
622
+ if win == editor.current_window
623
+ @rich_render_info = {
624
+ col_offset_sc: offset_sc,
625
+ cursor_sc: cursor_sc,
626
+ delimiter: delimiter
627
+ }
628
+ end
629
+ end
630
+
337
631
  def wrap_enabled?(editor, window, buffer)
632
+ return false if editor.rich_state
633
+
338
634
  !!editor.effective_option("wrap", window:, buffer:)
339
635
  end
340
636
 
@@ -345,15 +641,25 @@ module RuVim
345
641
  linebreak = !!editor.effective_option("linebreak", window:, buffer:)
346
642
  showbreak = editor.effective_option("showbreak", window:, buffer:).to_s
347
643
  breakindent = !!editor.effective_option("breakindent", window:, buffer:)
644
+ line = line.to_s
645
+ return [{ source_col_start: 0, display_prefix: "" }] if line.empty?
646
+
647
+ cache_key = [line.object_id, line.length, line.hash, width, tabstop, linebreak, showbreak, breakindent]
648
+ if (cached = @wrapped_segments_cache[cache_key])
649
+ return cached
650
+ end
651
+
348
652
  indent_prefix = breakindent ? wrapped_indent_prefix(line, tabstop:, max_width: [width - RuVim::DisplayWidth.display_width(showbreak, tabstop:), 0].max) : ""
653
+ segs = compute_wrapped_segments(line, width:, tabstop:, linebreak:, showbreak:, indent_prefix:)
654
+ @wrapped_segments_cache[cache_key] = segs
655
+ @wrapped_segments_cache.shift while @wrapped_segments_cache.length > WRAP_SEGMENTS_CACHE_LIMIT
656
+ segs
657
+ end
349
658
 
659
+ def compute_wrapped_segments(line, width:, tabstop:, linebreak:, showbreak:, indent_prefix:)
350
660
  segs = []
351
661
  start_col = 0
352
662
  first = true
353
- line = line.to_s
354
- if line.empty?
355
- return [{ source_col_start: 0, display_prefix: "" }]
356
- end
357
663
 
358
664
  while start_col < line.length
359
665
  display_prefix = first ? "" : "#{showbreak}#{indent_prefix}"
@@ -361,7 +667,7 @@ module RuVim
361
667
  avail = [width - prefix_w, 1].max
362
668
  cells, = RuVim::TextMetrics.clip_cells_for_width(line[start_col..].to_s, avail, source_col_start: start_col, tabstop:)
363
669
  if cells.empty?
364
- segs << { source_col_start: start_col, display_prefix: display_prefix }
670
+ segs << { source_col_start: start_col, display_prefix: display_prefix }.freeze
365
671
  break
366
672
  end
367
673
 
@@ -372,7 +678,7 @@ module RuVim
372
678
  end
373
679
  end
374
680
 
375
- segs << { source_col_start: start_col, display_prefix: display_prefix }
681
+ segs << { source_col_start: start_col, display_prefix: display_prefix }.freeze
376
682
  next_start = cells.last.source_col.to_i + 1
377
683
  if linebreak
378
684
  next_start += 1 while next_start < line.length && line[next_start] == " "
@@ -383,7 +689,20 @@ module RuVim
383
689
  first = false
384
690
  end
385
691
 
386
- segs
692
+ segs.freeze
693
+ end
694
+
695
+ def wrapped_segment_index(segs, cursor_x)
696
+ x = cursor_x.to_i
697
+ seg_index = 0
698
+ segs.each_with_index do |seg, i|
699
+ nxt = segs[i + 1]
700
+ if nxt.nil? || x < nxt[:source_col_start]
701
+ seg_index = i
702
+ break
703
+ end
704
+ end
705
+ seg_index
387
706
  end
388
707
 
389
708
  def linebreak_break_index(cells, line)
@@ -453,7 +772,7 @@ module RuVim
453
772
  def render_window_row(editor, window, buffer, buffer_row, gutter_w:, content_w:)
454
773
  line = buffer.line_at(buffer_row)
455
774
  line = line[window.col_offset..] || ""
456
- prefix = line_number_prefix(editor, window, buffer, buffer_row, gutter_w)
775
+ prefix = render_gutter_prefix(editor, window, buffer, buffer_row, gutter_w)
457
776
  body = render_text_line(line, editor, buffer_row:, window:, buffer:, width: content_w)
458
777
  prefix + body
459
778
  end
@@ -497,6 +816,15 @@ module RuVim
497
816
  sign + num.rjust([num_width - 1, 0].max) + (num_width.positive? ? " " : "")
498
817
  end
499
818
 
819
+ def render_gutter_prefix(editor, window, buffer, buffer_row, width)
820
+ prefix = line_number_prefix(editor, window, buffer, buffer_row, width)
821
+ return prefix if prefix.empty?
822
+ return prefix if buffer_row.nil?
823
+
824
+ current_line = (buffer_row == window.cursor_y)
825
+ "#{line_number_fg_seq(editor, current_line: current_line)}#{prefix}\e[m"
826
+ end
827
+
500
828
  def sign_column_width(editor, window, buffer)
501
829
  raw = editor.effective_option("signcolumn", window:, buffer:).to_s
502
830
  case raw
@@ -543,15 +871,27 @@ module RuVim
543
871
  end
544
872
 
545
873
  def search_bg_seq(editor)
546
- truecolor_enabled?(editor) ? "\e[48;2;255;215;0m" : "\e[43m"
874
+ term_color(editor, "\e[48;2;255;215;0m", "\e[43m")
547
875
  end
548
876
 
549
877
  def colorcolumn_bg_seq(editor)
550
- truecolor_enabled?(editor) ? "\e[48;2;72;72;72m" : "\e[48;5;238m"
878
+ term_color(editor, "\e[48;2;72;72;72m", "\e[48;5;238m")
551
879
  end
552
880
 
553
881
  def cursorline_bg_seq(editor)
554
- truecolor_enabled?(editor) ? "\e[48;2;58;58;58m" : "\e[48;5;236m"
882
+ term_color(editor, "\e[48;2;58;58;58m", "\e[48;5;236m")
883
+ end
884
+
885
+ def term_color(editor, truecolor_seq, fallback_seq)
886
+ truecolor_enabled?(editor) ? truecolor_seq : fallback_seq
887
+ end
888
+
889
+ def line_number_fg_seq(editor, current_line: false)
890
+ if truecolor_enabled?(editor)
891
+ current_line ? "\e[38;2;190;190;190m" : "\e[38;2;120;120;120m"
892
+ else
893
+ current_line ? "\e[37m" : "\e[90m"
894
+ end
555
895
  end
556
896
 
557
897
  def truecolor_enabled?(editor)
@@ -569,18 +909,39 @@ module RuVim
569
909
  when :visual_char then "-- VISUAL --"
570
910
  when :visual_line then "-- VISUAL LINE --"
571
911
  when :visual_block then "-- VISUAL BLOCK --"
912
+ when :rich then "-- RICH --"
572
913
  else "-- NORMAL --"
573
914
  end
574
915
 
575
916
  path = buffer.display_name
576
917
  mod = buffer.modified? ? " [+]" : ""
918
+ stream = buffer.stream_status ? " [#{buffer.stream_status}]" : ""
919
+ loading = file_loading_status_token(buffer)
920
+ tab = tab_status_token(editor)
577
921
  msg = editor.message_error? ? "" : editor.message.to_s
578
- left = "#{mode} #{path}#{mod}"
579
- right = " #{window.cursor_y + 1}:#{window.cursor_x + 1} "
922
+ left = "#{mode} #{path}#{mod}#{stream}#{loading}"
923
+ right = " #{window.cursor_y + 1}:#{window.cursor_x + 1}#{tab} "
580
924
  body_width = [width - right.length, 0].max
581
925
  "#{compose_status_body(left, msg, body_width)}#{right}"
582
926
  end
583
927
 
928
+ def file_loading_status_token(buffer)
929
+ return "" unless buffer.respond_to?(:loading_state)
930
+ return "" unless buffer.file_buffer?
931
+
932
+ state = buffer.loading_state
933
+ return "" unless state
934
+ return "" if state.to_sym == :closed
935
+
936
+ " [load/#{state}]"
937
+ end
938
+
939
+ def tab_status_token(editor)
940
+ return "" if editor.tabpage_count <= 1
941
+
942
+ " tab:#{editor.current_tabpage_number}/#{editor.tabpage_count}"
943
+ end
944
+
584
945
  def compose_status_body(left, msg, width)
585
946
  w = [width.to_i, 0].max
586
947
  return "" if w.zero?
@@ -596,7 +957,8 @@ module RuVim
596
957
  end
597
958
 
598
959
  def truncate(str, width)
599
- RuVim::TextMetrics.terminal_safe_text(str).ljust(width)[0, width]
960
+ safe = RuVim::TextMetrics.terminal_safe_text(str)
961
+ RuVim::TextMetrics.pad_plain_to_screen_width(safe, width)
600
962
  end
601
963
 
602
964
  def error_message_line(msg, cols)
@@ -614,6 +976,16 @@ module RuVim
614
976
  def cursor_screen_position(editor, text_rows, rects)
615
977
  window = editor.current_window
616
978
 
979
+ if editor.hit_enter_active? && editor.hit_enter_lines
980
+ total_rows = text_rows + 2
981
+ msg_count = editor.hit_enter_lines.length
982
+ prompt_row = [total_rows - msg_count, 1].max + msg_count
983
+ prompt_row = [prompt_row, total_rows].min
984
+ prompt_text = "Press ENTER or type command to continue"
985
+ col = [prompt_text.length + 1, total_rows].min
986
+ return [prompt_row, col]
987
+ end
988
+
617
989
  if editor.command_line_active?
618
990
  row = text_rows + 2
619
991
  col = 1 + editor.command_line.prefix.length + editor.command_line.cursor
@@ -634,14 +1006,7 @@ module RuVim
634
1006
  row += 1
635
1007
  end
636
1008
  segs = wrapped_segments_for_line(editor, window, buffer, line, width: content_w)
637
- seg_index = 0
638
- segs.each_with_index do |seg, i|
639
- nxt = segs[i + 1]
640
- if nxt.nil? || window.cursor_x < nxt[:source_col_start]
641
- seg_index = i
642
- break
643
- end
644
- end
1009
+ seg_index = wrapped_segment_index(segs, window.cursor_x)
645
1010
  seg = segs[seg_index] || { source_col_start: 0, display_prefix: "" }
646
1011
  row = rect[:top] + visual_rows_before + seg_index
647
1012
  seg_prefix_w = RuVim::DisplayWidth.display_width(seg[:display_prefix].to_s, tabstop:)
@@ -649,6 +1014,9 @@ module RuVim
649
1014
  cursor_sc = RuVim::TextMetrics.screen_col_for_char_index(line, window.cursor_x, tabstop:) + extra_virtual
650
1015
  seg_sc = RuVim::TextMetrics.screen_col_for_char_index(line, seg[:source_col_start], tabstop:)
651
1016
  col = rect[:left] + gutter_w + seg_prefix_w + [cursor_sc - seg_sc, 0].max
1017
+ elsif @rich_render_info
1018
+ row = rect[:top] + (window.cursor_y - window.row_offset)
1019
+ col = rect[:left] + gutter_w + [@rich_render_info[:cursor_sc] - @rich_render_info[:col_offset_sc], 0].max
652
1020
  else
653
1021
  row = rect[:top] + (window.cursor_y - window.row_offset)
654
1022
  extra_virtual = [window.cursor_x - line.length, 0].max
@@ -666,39 +1034,64 @@ module RuVim
666
1034
  end
667
1035
 
668
1036
  def window_rects(editor, text_rows:, text_cols:)
1037
+ tree = editor.layout_tree
1038
+ return {} if tree.nil?
669
1039
  ids = editor.window_order
670
1040
  return {} if ids.empty?
671
- return { ids.first => { top: 1, left: 1, height: text_rows, width: text_cols } } if ids.length == 1 || editor.window_layout == :single
672
-
673
- if editor.window_layout == :vertical
674
- sep = ids.length - 1
675
- usable = [text_cols - sep, ids.length].max
676
- widths = split_sizes(usable, ids.length)
677
- left = 1
678
- rects = {}
679
- ids.each_with_index do |id, i|
1041
+ return { ids.first => { top: 1, left: 1, height: text_rows, width: text_cols } } if ids.length == 1
1042
+
1043
+ compute_tree_rects(tree, top: 1, left: 1, height: text_rows, width: text_cols)
1044
+ end
1045
+
1046
+ def compute_tree_rects(node, top:, left:, height:, width:)
1047
+ if node[:type] == :window
1048
+ return { node[:id] => { top: top, left: left, height: height, width: width } }
1049
+ end
1050
+
1051
+ children = node[:children]
1052
+ n = children.length
1053
+ rects = {}
1054
+
1055
+ case node[:type]
1056
+ when :vsplit
1057
+ sep_count = n - 1
1058
+ usable = [width - sep_count, n].max
1059
+ widths = split_sizes(usable, n)
1060
+ cur_left = left
1061
+ children.each_with_index do |child, i|
680
1062
  w = widths[i]
681
- rects[id] = { top: 1, left: left, height: text_rows, width: w, separator: :vertical }
682
- left += w + 1
1063
+ child_rects = compute_tree_rects(child, top: top, left: cur_left, height: height, width: w)
1064
+ child_rects.each_value { |r| r[:separator] = :vertical }
1065
+ rects.merge!(child_rects)
1066
+ cur_left += w + 1
683
1067
  end
684
- rects
685
- else
686
- sep = ids.length - 1
687
- usable = [text_rows - sep, ids.length].max
688
- heights = split_sizes(usable, ids.length)
689
- top = 1
690
- rects = {}
691
- ids.each_with_index do |id, i|
1068
+ when :hsplit
1069
+ sep_count = n - 1
1070
+ usable = [height - sep_count, n].max
1071
+ heights = split_sizes(usable, n)
1072
+ cur_top = top
1073
+ children.each_with_index do |child, i|
692
1074
  h = heights[i]
693
- rects[id] = { top: top, left: 1, height: h, width: text_cols, separator: :horizontal }
694
- top += h
695
- if i < ids.length - 1
696
- rects[id][:sep_row] = top
697
- top += 1
1075
+ child_rects = compute_tree_rects(child, top: cur_top, left: left, height: h, width: width)
1076
+ child_rects.each_value { |r| r[:separator] = :horizontal }
1077
+ rects.merge!(child_rects)
1078
+ if i < n - 1
1079
+ # Mark separator row for the last window in this child
1080
+ child_leaves = tree_leaves_for_rects(child)
1081
+ last_leaf = child_leaves.last
1082
+ rects[last_leaf][:sep_row] = cur_top + h if last_leaf && rects[last_leaf]
698
1083
  end
1084
+ cur_top += h + 1
699
1085
  end
700
- rects
701
1086
  end
1087
+
1088
+ rects
1089
+ end
1090
+
1091
+ def tree_leaves_for_rects(node)
1092
+ return [node[:id]] if node[:type] == :window
1093
+
1094
+ node[:children].flat_map { |c| tree_leaves_for_rects(c) }
702
1095
  end
703
1096
 
704
1097
  def split_sizes(total, n)
@@ -733,6 +1126,7 @@ module RuVim
733
1126
  search = editor.last_search
734
1127
  return {} unless search && search[:pattern]
735
1128
  return {} unless editor.effective_option("hlsearch")
1129
+ return {} if editor.hlsearch_suppressed?
736
1130
 
737
1131
  regex = build_screen_search_regex(editor, search[:pattern])
738
1132
  cols = {}