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/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
@@ -554,6 +882,14 @@ module RuVim
554
882
  truecolor_enabled?(editor) ? "\e[48;2;58;58;58m" : "\e[48;5;236m"
555
883
  end
556
884
 
885
+ def line_number_fg_seq(editor, current_line: false)
886
+ if truecolor_enabled?(editor)
887
+ current_line ? "\e[38;2;190;190;190m" : "\e[38;2;120;120;120m"
888
+ else
889
+ current_line ? "\e[37m" : "\e[90m"
890
+ end
891
+ end
892
+
557
893
  def truecolor_enabled?(editor)
558
894
  !!editor.effective_option("termguicolors")
559
895
  rescue StandardError
@@ -569,18 +905,47 @@ module RuVim
569
905
  when :visual_char then "-- VISUAL --"
570
906
  when :visual_line then "-- VISUAL LINE --"
571
907
  when :visual_block then "-- VISUAL BLOCK --"
908
+ when :rich then "-- RICH --"
572
909
  else "-- NORMAL --"
573
910
  end
574
911
 
575
912
  path = buffer.display_name
576
913
  mod = buffer.modified? ? " [+]" : ""
914
+ stream = stream_status_token(buffer)
915
+ loading = file_loading_status_token(buffer)
916
+ tab = tab_status_token(editor)
577
917
  msg = editor.message_error? ? "" : editor.message.to_s
578
- left = "#{mode} #{path}#{mod}"
579
- right = " #{window.cursor_y + 1}:#{window.cursor_x + 1} "
918
+ left = "#{mode} #{path}#{mod}#{stream}#{loading}"
919
+ right = " #{window.cursor_y + 1}:#{window.cursor_x + 1}#{tab} "
580
920
  body_width = [width - right.length, 0].max
581
921
  "#{compose_status_body(left, msg, body_width)}#{right}"
582
922
  end
583
923
 
924
+ def stream_status_token(buffer)
925
+ return "" unless buffer.respond_to?(:stream_state)
926
+ return "" unless buffer.kind == :stream
927
+
928
+ state = (buffer.stream_state || :live).to_s
929
+ " [stdin/#{state}]"
930
+ end
931
+
932
+ def file_loading_status_token(buffer)
933
+ return "" unless buffer.respond_to?(:loading_state)
934
+ return "" unless buffer.file_buffer?
935
+
936
+ state = buffer.loading_state
937
+ return "" unless state
938
+ return "" if state.to_sym == :closed
939
+
940
+ " [load/#{state}]"
941
+ end
942
+
943
+ def tab_status_token(editor)
944
+ return "" if editor.tabpage_count <= 1
945
+
946
+ " tab:#{editor.current_tabpage_number}/#{editor.tabpage_count}"
947
+ end
948
+
584
949
  def compose_status_body(left, msg, width)
585
950
  w = [width.to_i, 0].max
586
951
  return "" if w.zero?
@@ -596,7 +961,8 @@ module RuVim
596
961
  end
597
962
 
598
963
  def truncate(str, width)
599
- RuVim::TextMetrics.terminal_safe_text(str).ljust(width)[0, width]
964
+ safe = RuVim::TextMetrics.terminal_safe_text(str)
965
+ RuVim::TextMetrics.pad_plain_to_screen_width(safe, width)
600
966
  end
601
967
 
602
968
  def error_message_line(msg, cols)
@@ -614,6 +980,16 @@ module RuVim
614
980
  def cursor_screen_position(editor, text_rows, rects)
615
981
  window = editor.current_window
616
982
 
983
+ if editor.hit_enter_active? && editor.hit_enter_lines
984
+ total_rows = text_rows + 2
985
+ msg_count = editor.hit_enter_lines.length
986
+ prompt_row = [total_rows - msg_count, 1].max + msg_count
987
+ prompt_row = [prompt_row, total_rows].min
988
+ prompt_text = "Press ENTER or type command to continue"
989
+ col = [prompt_text.length + 1, total_rows].min
990
+ return [prompt_row, col]
991
+ end
992
+
617
993
  if editor.command_line_active?
618
994
  row = text_rows + 2
619
995
  col = 1 + editor.command_line.prefix.length + editor.command_line.cursor
@@ -634,14 +1010,7 @@ module RuVim
634
1010
  row += 1
635
1011
  end
636
1012
  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
1013
+ seg_index = wrapped_segment_index(segs, window.cursor_x)
645
1014
  seg = segs[seg_index] || { source_col_start: 0, display_prefix: "" }
646
1015
  row = rect[:top] + visual_rows_before + seg_index
647
1016
  seg_prefix_w = RuVim::DisplayWidth.display_width(seg[:display_prefix].to_s, tabstop:)
@@ -649,6 +1018,9 @@ module RuVim
649
1018
  cursor_sc = RuVim::TextMetrics.screen_col_for_char_index(line, window.cursor_x, tabstop:) + extra_virtual
650
1019
  seg_sc = RuVim::TextMetrics.screen_col_for_char_index(line, seg[:source_col_start], tabstop:)
651
1020
  col = rect[:left] + gutter_w + seg_prefix_w + [cursor_sc - seg_sc, 0].max
1021
+ elsif @rich_render_info
1022
+ row = rect[:top] + (window.cursor_y - window.row_offset)
1023
+ col = rect[:left] + gutter_w + [@rich_render_info[:cursor_sc] - @rich_render_info[:col_offset_sc], 0].max
652
1024
  else
653
1025
  row = rect[:top] + (window.cursor_y - window.row_offset)
654
1026
  extra_virtual = [window.cursor_x - line.length, 0].max
@@ -666,39 +1038,64 @@ module RuVim
666
1038
  end
667
1039
 
668
1040
  def window_rects(editor, text_rows:, text_cols:)
1041
+ tree = editor.layout_tree
1042
+ return {} if tree.nil?
669
1043
  ids = editor.window_order
670
1044
  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|
1045
+ return { ids.first => { top: 1, left: 1, height: text_rows, width: text_cols } } if ids.length == 1
1046
+
1047
+ compute_tree_rects(tree, top: 1, left: 1, height: text_rows, width: text_cols)
1048
+ end
1049
+
1050
+ def compute_tree_rects(node, top:, left:, height:, width:)
1051
+ if node[:type] == :window
1052
+ return { node[:id] => { top: top, left: left, height: height, width: width } }
1053
+ end
1054
+
1055
+ children = node[:children]
1056
+ n = children.length
1057
+ rects = {}
1058
+
1059
+ case node[:type]
1060
+ when :vsplit
1061
+ sep_count = n - 1
1062
+ usable = [width - sep_count, n].max
1063
+ widths = split_sizes(usable, n)
1064
+ cur_left = left
1065
+ children.each_with_index do |child, i|
680
1066
  w = widths[i]
681
- rects[id] = { top: 1, left: left, height: text_rows, width: w, separator: :vertical }
682
- left += w + 1
1067
+ child_rects = compute_tree_rects(child, top: top, left: cur_left, height: height, width: w)
1068
+ child_rects.each_value { |r| r[:separator] = :vertical }
1069
+ rects.merge!(child_rects)
1070
+ cur_left += w + 1
683
1071
  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|
1072
+ when :hsplit
1073
+ sep_count = n - 1
1074
+ usable = [height - sep_count, n].max
1075
+ heights = split_sizes(usable, n)
1076
+ cur_top = top
1077
+ children.each_with_index do |child, i|
692
1078
  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
1079
+ child_rects = compute_tree_rects(child, top: cur_top, left: left, height: h, width: width)
1080
+ child_rects.each_value { |r| r[:separator] = :horizontal }
1081
+ rects.merge!(child_rects)
1082
+ if i < n - 1
1083
+ # Mark separator row for the last window in this child
1084
+ child_leaves = tree_leaves_for_rects(child)
1085
+ last_leaf = child_leaves.last
1086
+ rects[last_leaf][:sep_row] = cur_top + h if last_leaf && rects[last_leaf]
698
1087
  end
1088
+ cur_top += h + 1
699
1089
  end
700
- rects
701
1090
  end
1091
+
1092
+ rects
1093
+ end
1094
+
1095
+ def tree_leaves_for_rects(node)
1096
+ return [node[:id]] if node[:type] == :window
1097
+
1098
+ node[:children].flat_map { |c| tree_leaves_for_rects(c) }
702
1099
  end
703
1100
 
704
1101
  def split_sizes(total, n)