ruvim 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +84 -0
  4. data/CLAUDE.md +1 -0
  5. data/docs/binding.md +29 -0
  6. data/docs/command.md +101 -0
  7. data/docs/config.md +203 -84
  8. data/docs/done.md +21 -0
  9. data/docs/lib_cleanup_report.md +79 -0
  10. data/docs/plugin.md +13 -15
  11. data/docs/spec.md +195 -33
  12. data/docs/todo.md +183 -10
  13. data/docs/tutorial.md +1 -1
  14. data/docs/vim_diff.md +94 -171
  15. data/lib/ruvim/app.rb +1543 -172
  16. data/lib/ruvim/buffer.rb +35 -1
  17. data/lib/ruvim/cli.rb +12 -3
  18. data/lib/ruvim/clipboard.rb +2 -0
  19. data/lib/ruvim/command_invocation.rb +3 -1
  20. data/lib/ruvim/command_line.rb +2 -0
  21. data/lib/ruvim/command_registry.rb +2 -0
  22. data/lib/ruvim/config_dsl.rb +2 -0
  23. data/lib/ruvim/config_loader.rb +21 -5
  24. data/lib/ruvim/context.rb +2 -7
  25. data/lib/ruvim/dispatcher.rb +153 -13
  26. data/lib/ruvim/display_width.rb +28 -2
  27. data/lib/ruvim/editor.rb +622 -69
  28. data/lib/ruvim/ex_command_registry.rb +2 -0
  29. data/lib/ruvim/global_commands.rb +1386 -114
  30. data/lib/ruvim/highlighter.rb +16 -21
  31. data/lib/ruvim/input.rb +52 -29
  32. data/lib/ruvim/keymap_manager.rb +83 -0
  33. data/lib/ruvim/keyword_chars.rb +48 -0
  34. data/lib/ruvim/lang/base.rb +25 -0
  35. data/lib/ruvim/lang/csv.rb +18 -0
  36. data/lib/ruvim/lang/json.rb +18 -0
  37. data/lib/ruvim/lang/markdown.rb +170 -0
  38. data/lib/ruvim/lang/ruby.rb +236 -0
  39. data/lib/ruvim/lang/scheme.rb +44 -0
  40. data/lib/ruvim/lang/tsv.rb +19 -0
  41. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  42. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  43. data/lib/ruvim/rich_view.rb +93 -0
  44. data/lib/ruvim/screen.rb +851 -119
  45. data/lib/ruvim/terminal.rb +18 -1
  46. data/lib/ruvim/text_metrics.rb +28 -0
  47. data/lib/ruvim/version.rb +2 -2
  48. data/lib/ruvim/window.rb +37 -10
  49. data/lib/ruvim.rb +15 -0
  50. data/test/app_completion_test.rb +174 -0
  51. data/test/app_dot_repeat_test.rb +13 -0
  52. data/test/app_motion_test.rb +110 -2
  53. data/test/app_scenario_test.rb +998 -0
  54. data/test/app_startup_test.rb +197 -0
  55. data/test/arglist_test.rb +113 -0
  56. data/test/buffer_test.rb +49 -30
  57. data/test/config_loader_test.rb +37 -0
  58. data/test/dispatcher_test.rb +438 -0
  59. data/test/display_width_test.rb +18 -0
  60. data/test/editor_register_test.rb +23 -0
  61. data/test/fixtures/render_basic_snapshot.txt +7 -8
  62. data/test/fixtures/render_basic_snapshot_nonumber.txt +1 -2
  63. data/test/fixtures/render_unicode_scrolled_snapshot.txt +6 -7
  64. data/test/highlighter_test.rb +121 -0
  65. data/test/indent_test.rb +201 -0
  66. data/test/input_screen_integration_test.rb +65 -14
  67. data/test/markdown_renderer_test.rb +279 -0
  68. data/test/on_save_hook_test.rb +150 -0
  69. data/test/rich_view_test.rb +478 -0
  70. data/test/screen_test.rb +470 -0
  71. data/test/window_test.rb +26 -0
  72. metadata +37 -2
data/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,24 +17,48 @@ 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
19
25
  text_cols = [text_cols, 1].max
20
26
 
21
27
  rects = window_rects(editor, text_rows:, text_cols:)
28
+ if (current_rect = rects[editor.current_window_id])
29
+ editor.current_window_view_height_hint = [current_rect[:height].to_i, 1].max
30
+ end
22
31
  editor.window_order.each do |win_id|
23
32
  win = editor.windows.fetch(win_id)
24
33
  buf = editor.buffers.fetch(win.buffer_id)
25
34
  rect = rects[win_id]
26
35
  next unless rect
27
36
  content_width = [rect[:width] - number_column_width(editor, win, buf), 1].max
28
- win.ensure_visible(
29
- buf,
30
- height: [rect[:height], 1].max,
31
- width: content_width,
32
- tabstop: tabstop_for(editor, win, buf)
33
- )
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
34
62
  end
35
63
 
36
64
  frame = build_frame(editor, rows:, cols:, text_rows:, text_cols:, rects:)
@@ -52,7 +80,9 @@ module RuVim
52
80
  text_rows = [text_rows, 1].max
53
81
  text_cols = [text_cols, 1].max
54
82
  rect = window_rects(editor, text_rows:, text_cols:)[editor.current_window_id]
55
- [rect ? rect[:height].to_i : text_rows, 1].max
83
+ height = [rect ? rect[:height].to_i : text_rows, 1].max
84
+ editor.current_window_view_height_hint = height if editor.respond_to?(:current_window_view_height_hint=)
85
+ height
56
86
  rescue StandardError
57
87
  1
58
88
  end
@@ -63,14 +93,19 @@ module RuVim
63
93
  lines = {}
64
94
  render_window_area(editor, lines, rects, text_rows:, text_cols:)
65
95
 
66
- status_row = text_rows + 1
67
- lines[status_row] = "\e[7m#{truncate(status_line(editor, cols), cols)}\e[m"
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] = ""
68
102
 
69
- if editor.command_line_active?
70
- cmd = editor.command_line
71
- lines[status_row + 1] = truncate("#{cmd.prefix}#{cmd.text}", cols)
72
- elsif editor.message_error?
73
- lines[status_row + 1] = error_message_line(editor.message.to_s, cols)
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
74
109
  end
75
110
 
76
111
  {
@@ -81,70 +116,128 @@ module RuVim
81
116
  }
82
117
  end
83
118
 
84
- def render_window_area(editor, lines, rects, text_rows:, text_cols:)
85
- if rects.values.any? { |r| r[:separator] == :vertical }
86
- render_vertical_windows(editor, lines, rects, text_rows:, text_cols:)
87
- else
88
- 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)
89
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"
90
135
  end
91
136
 
92
- def render_horizontal_windows(editor, lines, rects, text_rows:, text_cols:)
93
- 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
94
140
 
141
+ def render_tree_windows(editor, lines, rects, text_rows:, text_cols:)
142
+ # Pre-render each window's rows
143
+ window_rows_cache = {}
95
144
  editor.window_order.each do |win_id|
96
145
  rect = rects[win_id]
97
146
  next unless rect
147
+ window = editor.windows.fetch(win_id)
148
+ buffer = editor.buffers.fetch(window.buffer_id)
149
+ gutter_w = number_column_width(editor, window, buffer)
150
+ content_w = [rect[:width] - gutter_w, 1].max
151
+ window_rows_cache[win_id] = window_render_rows(editor, window, buffer, height: rect[:height], gutter_w:, content_w:)
152
+ end
98
153
 
99
- window = editor.windows.fetch(win_id)
100
- buffer = editor.buffers.fetch(window.buffer_id)
101
- gutter_w = number_column_width(editor, window, buffer)
102
- content_w = [rect[:width] - gutter_w, 1].max
103
- rect[:height].times do |dy|
104
- row_no = rect[:top] + dy
105
- buffer_row = window.row_offset + dy
106
- text =
107
- if buffer_row < buffer.line_count
108
- render_window_row(editor, window, buffer, buffer_row, gutter_w:, content_w:)
109
- else
110
- line_number_prefix(editor, window, buffer, nil, gutter_w) + pad_plain_display("~", content_w)
111
- end
112
- lines[row_no] = text
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)
156
+
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
+
164
+ pieces = +""
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]
113
179
  end
180
+ end
181
+ lines[row_no] = pieces
114
182
  end
183
+ end
115
184
 
116
- rects.each_value do |rect|
117
- next unless rect[:separator] == :horizontal
118
- lines[rect[:sep_row]] = ("-" * text_cols)
119
- end
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
120
191
  end
121
192
 
122
- def render_vertical_windows(editor, lines, rects, text_rows:, text_cols:)
123
- 1.upto(text_rows) do |row_no|
124
- pieces = +""
125
- editor.window_order.each_with_index do |win_id, idx|
126
- rect = rects[win_id]
127
- next unless rect
128
- window = editor.windows.fetch(win_id)
129
- buffer = editor.buffers.fetch(window.buffer_id)
130
- gutter_w = number_column_width(editor, window, buffer)
131
- content_w = [rect[:width] - gutter_w, 1].max
132
- dy = row_no - rect[:top]
133
- text =
134
- if dy >= 0 && dy < rect[:height]
135
- buffer_row = window.row_offset + dy
136
- if buffer_row < buffer.line_count
137
- render_window_row(editor, window, buffer, buffer_row, gutter_w:, content_w:)
138
- else
139
- line_number_prefix(editor, window, buffer, nil, gutter_w) + pad_plain_display("~", content_w)
140
- end
141
- else
142
- " " * rect[:width]
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 }
143
223
  end
144
- pieces << text
145
- pieces << "|" if idx < editor.window_order.length - 1
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
146
240
  end
147
- lines[row_no] = pieces
148
241
  end
149
242
  end
150
243
 
@@ -184,43 +277,502 @@ module RuVim
184
277
  def render_text_line(text, editor, buffer_row:, window:, buffer:, width:)
185
278
  tabstop = tabstop_for(editor, window, buffer)
186
279
  cells, display_col = RuVim::TextMetrics.clip_cells_for_width(text, width, source_col_start: window.col_offset, tabstop:)
280
+ render_cells(cells, display_col, editor, buffer_row:, window:, buffer:, width:, source_line: buffer.line_at(buffer_row),
281
+ source_col_offset: window.col_offset, leading_display_prefix: "")
282
+ end
283
+
284
+ def render_text_segment(source_line, editor, buffer_row:, window:, buffer:, width:, source_col_start:, display_prefix: "")
285
+ tabstop = tabstop_for(editor, window, buffer)
286
+ prefix = display_prefix.to_s
287
+ prefix_w = RuVim::DisplayWidth.display_width(prefix, tabstop:)
288
+ avail = [width - prefix_w, 0].max
289
+ cells, display_col = RuVim::TextMetrics.clip_cells_for_width(source_line[source_col_start..].to_s, avail, source_col_start:, tabstop:)
290
+ body = render_cells(cells, display_col, editor, buffer_row:, window:, buffer:, width: avail, source_line: source_line,
291
+ source_col_offset: source_col_start, leading_display_prefix: prefix)
292
+ if width <= 0
293
+ ""
294
+ elsif prefix_w <= 0
295
+ body
296
+ else
297
+ prefix_render = RuVim::TextMetrics.pad_plain_to_screen_width(prefix, [width, 0].max, tabstop:)[0...prefix.length].to_s
298
+ # body already includes padding for avail; prepend the visible prefix and trim to width.
299
+ out = prefix_render + body
300
+ out
301
+ end
302
+ end
303
+
304
+ def render_cells(cells, display_col, editor, buffer_row:, window:, buffer:, width:, source_line:, source_col_offset:, leading_display_prefix:)
187
305
  highlighted = +""
306
+ tabstop = tabstop_for(editor, window, buffer)
188
307
  visual = (editor.current_window_id == window.id && editor.visual_active?) ? editor.visual_selection(window) : nil
189
- search_cols = search_highlight_source_cols(editor, text, source_col_offset: window.col_offset)
190
- syntax_cols = syntax_highlight_source_cols(editor, window, buffer, text, source_col_offset: window.col_offset)
308
+ text_for_highlight = source_line[source_col_offset..].to_s
309
+ search_cols = search_highlight_source_cols(editor, text_for_highlight, source_col_offset: source_col_offset)
310
+ syntax_cols = syntax_highlight_source_cols(editor, window, buffer, text_for_highlight, source_col_offset: source_col_offset)
311
+ list_enabled = !!editor.effective_option("list", window:, buffer:)
312
+ listchars = parse_listchars(editor.effective_option("listchars", window:, buffer:))
313
+ tab_seen = {}
314
+ trail_from = source_line.rstrip.length
315
+ cursorline = !!editor.effective_option("cursorline", window:, buffer:)
316
+ current_line = (editor.current_window_id == window.id && window.cursor_y == buffer_row)
317
+ cursorline_enabled = cursorline && current_line
318
+ colorcolumns = colorcolumn_display_cols(editor, window, buffer)
319
+ leading_prefix_width = RuVim::DisplayWidth.display_width(leading_display_prefix.to_s, tabstop:)
320
+ display_pos = leading_prefix_width
191
321
 
192
- cells.each_with_index do |cell, idx|
193
- ch = cell.glyph
322
+ cells.each do |cell|
323
+ ch = display_glyph_for_cell(cell, source_line, list_enabled:, listchars:, tab_seen:, trail_from:)
194
324
  buffer_col = cell.source_col
195
325
  selected = selected_in_visual?(visual, buffer_row, buffer_col)
196
326
  cursor_here = (editor.current_window_id == window.id && window.cursor_y == buffer_row && window.cursor_x == buffer_col)
197
- if selected || cursor_here
327
+ colorcolumn_here = colorcolumns[display_pos]
328
+ if cursor_here
329
+ highlighted << cursor_cell_render(editor, ch)
330
+ elsif selected
198
331
  highlighted << "\e[7m#{ch}\e[m"
199
332
  elsif search_cols[buffer_col]
200
- highlighted << "\e[43m#{ch}\e[m"
333
+ highlighted << "#{search_bg_seq(editor)}#{ch}\e[m"
334
+ elsif colorcolumn_here
335
+ highlighted << "#{colorcolumn_bg_seq(editor)}#{ch}\e[m"
336
+ elsif cursorline_enabled
337
+ highlighted << "#{cursorline_bg_seq(editor)}#{ch}\e[m"
201
338
  elsif (syntax_color = syntax_cols[buffer_col])
202
339
  highlighted << "#{syntax_color}#{ch}\e[m"
203
340
  else
204
341
  highlighted << ch
205
342
  end
343
+ display_pos += [cell.display_width.to_i, 1].max
206
344
  end
207
345
 
208
346
  if editor.current_window_id == window.id && window.cursor_y == buffer_row
209
- col = window.cursor_x - window.col_offset
210
- if col >= cells.length && col >= 0 && display_col < width
211
- highlighted << "\e[7m \e[m"
347
+ cursor_target = virtual_cursor_display_pos(source_line, window.cursor_x, source_col_offset:, tabstop:, leading_prefix_width:)
348
+ if cursor_target && cursor_target >= display_pos && cursor_target < width
349
+ gap = cursor_target - display_pos
350
+ if gap.positive?
351
+ highlighted << (" " * gap)
352
+ display_col += gap
353
+ display_pos += gap
354
+ end
355
+ highlighted << cursor_cell_render(editor, " ")
212
356
  display_col += 1
357
+ display_pos += 1
213
358
  end
214
359
  end
215
360
 
216
- highlighted << (" " * [width - display_col, 0].max)
361
+ trailing = [width - display_col, 0].max
362
+ if trailing.positive? && cursorline_enabled
363
+ trailing.times do
364
+ if colorcolumns[display_pos]
365
+ highlighted << "#{colorcolumn_bg_seq(editor)} \e[m"
366
+ else
367
+ highlighted << "#{cursorline_bg_seq(editor)} \e[m"
368
+ end
369
+ display_pos += 1
370
+ end
371
+ else
372
+ highlighted << (" " * trailing)
373
+ end
217
374
  highlighted
218
375
  end
219
376
 
377
+ def virtual_cursor_display_pos(source_line, cursor_x, source_col_offset:, tabstop:, leading_prefix_width:)
378
+ return nil if cursor_x < source_col_offset
379
+
380
+ base = RuVim::TextMetrics.screen_col_for_char_index(source_line, cursor_x, tabstop:) -
381
+ RuVim::TextMetrics.screen_col_for_char_index(source_line, source_col_offset, tabstop:)
382
+ extra = [cursor_x.to_i - source_line.to_s.length, 0].max
383
+ leading_prefix_width + [base, 0].max + extra
384
+ end
385
+
386
+ def window_render_rows(editor, window, buffer, height:, gutter_w:, content_w:)
387
+ return plain_window_render_rows(editor, window, buffer, height:, gutter_w:, content_w:) unless wrap_enabled?(editor, window, buffer)
388
+
389
+ wrapped_window_render_rows(editor, window, buffer, height:, gutter_w:, content_w:)
390
+ end
391
+
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
+
397
+ Array.new(height) do |dy|
398
+ buffer_row = window.row_offset + dy
399
+ if buffer_row < buffer.line_count
400
+ render_window_row(editor, window, buffer, buffer_row, gutter_w:, content_w:)
401
+ else
402
+ render_gutter_prefix(editor, window, buffer, nil, gutter_w) + pad_plain_display("~", content_w)
403
+ end
404
+ end
405
+ end
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
+
525
+ def wrapped_window_render_rows(editor, window, buffer, height:, gutter_w:, content_w:)
526
+ rows = []
527
+ row_idx = window.row_offset
528
+ while rows.length < height
529
+ if row_idx >= buffer.line_count
530
+ rows << (render_gutter_prefix(editor, window, buffer, nil, gutter_w) + pad_plain_display("~", content_w))
531
+ next
532
+ end
533
+
534
+ line = buffer.line_at(row_idx)
535
+ segments = wrapped_segments_for_line(editor, window, buffer, line, width: content_w)
536
+ segments.each_with_index do |seg, seg_i|
537
+ break if rows.length >= height
538
+
539
+ gutter = render_gutter_prefix(editor, window, buffer, seg_i.zero? ? row_idx : nil, gutter_w)
540
+ rows << gutter + render_text_segment(line, editor, buffer_row: row_idx, window:, buffer:, width: content_w,
541
+ source_col_start: seg[:source_col_start], display_prefix: seg[:display_prefix])
542
+ end
543
+ row_idx += 1
544
+ end
545
+ rows
546
+ end
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
+
631
+ def wrap_enabled?(editor, window, buffer)
632
+ return false if editor.rich_state
633
+
634
+ !!editor.effective_option("wrap", window:, buffer:)
635
+ end
636
+
637
+ def wrapped_segments_for_line(editor, window, buffer, line, width:)
638
+ return [{ source_col_start: 0, display_prefix: "" }] if width <= 0
639
+
640
+ tabstop = tabstop_for(editor, window, buffer)
641
+ linebreak = !!editor.effective_option("linebreak", window:, buffer:)
642
+ showbreak = editor.effective_option("showbreak", window:, buffer:).to_s
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
+
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
658
+
659
+ def compute_wrapped_segments(line, width:, tabstop:, linebreak:, showbreak:, indent_prefix:)
660
+ segs = []
661
+ start_col = 0
662
+ first = true
663
+
664
+ while start_col < line.length
665
+ display_prefix = first ? "" : "#{showbreak}#{indent_prefix}"
666
+ prefix_w = RuVim::DisplayWidth.display_width(display_prefix, tabstop:)
667
+ avail = [width - prefix_w, 1].max
668
+ cells, = RuVim::TextMetrics.clip_cells_for_width(line[start_col..].to_s, avail, source_col_start: start_col, tabstop:)
669
+ if cells.empty?
670
+ segs << { source_col_start: start_col, display_prefix: display_prefix }.freeze
671
+ break
672
+ end
673
+
674
+ if linebreak && cells.length > 1
675
+ break_idx = linebreak_break_index(cells, line)
676
+ if break_idx && break_idx < cells.length - 1
677
+ cells = cells[0..break_idx]
678
+ end
679
+ end
680
+
681
+ segs << { source_col_start: start_col, display_prefix: display_prefix }.freeze
682
+ next_start = cells.last.source_col.to_i + 1
683
+ if linebreak
684
+ next_start += 1 while next_start < line.length && line[next_start] == " "
685
+ end
686
+ break if next_start <= start_col
687
+
688
+ start_col = next_start
689
+ first = false
690
+ end
691
+
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
706
+ end
707
+
708
+ def linebreak_break_index(cells, line)
709
+ idx = nil
710
+ cells.each_with_index do |cell, i|
711
+ ch = line[cell.source_col]
712
+ idx = i if ch =~ /\s/
713
+ end
714
+ idx
715
+ end
716
+
717
+ def wrapped_indent_prefix(line, tabstop:, max_width:)
718
+ indent = line.to_s[/\A[ \t]*/].to_s
719
+ return "" if indent.empty? || max_width <= 0
720
+
721
+ RuVim::TextMetrics.pad_plain_to_screen_width(indent, max_width, tabstop:)[0...indent.length].to_s
722
+ rescue StandardError
723
+ ""
724
+ end
725
+
726
+ def display_glyph_for_cell(cell, source_line, list_enabled:, listchars:, tab_seen:, trail_from:)
727
+ return cell.glyph unless list_enabled
728
+
729
+ src = source_line[cell.source_col]
730
+ case src
731
+ when "\t"
732
+ first = !tab_seen[cell.source_col]
733
+ tab_seen[cell.source_col] = true
734
+ first ? listchars[:tab_head] : listchars[:tab_fill]
735
+ when " "
736
+ cell.source_col >= trail_from ? listchars[:trail] : cell.glyph
737
+ when "\u00A0"
738
+ listchars[:nbsp]
739
+ else
740
+ cell.glyph
741
+ end
742
+ end
743
+
744
+ def parse_listchars(raw)
745
+ raw_key = raw.to_s
746
+ @listchars_cache ||= {}
747
+ return @listchars_cache[raw_key] if @listchars_cache.key?(raw_key)
748
+
749
+ cfg = { tab_head: ">", tab_fill: "-", trail: "-", nbsp: "+" }
750
+ raw_key.split(",").each do |entry|
751
+ entry_key, val = entry.split(":", 2)
752
+ next unless entry_key && val
753
+
754
+ case entry_key.strip
755
+ when "tab"
756
+ chars = val.to_s.each_char.to_a
757
+ cfg[:tab_head] = chars[0] if chars[0]
758
+ cfg[:tab_fill] = chars[1] if chars[1]
759
+ when "trail"
760
+ ch = val.to_s.each_char.first
761
+ cfg[:trail] = ch if ch
762
+ when "nbsp"
763
+ ch = val.to_s.each_char.first
764
+ cfg[:nbsp] = ch if ch
765
+ end
766
+ end
767
+ @listchars_cache[raw_key] = cfg.freeze
768
+ rescue StandardError
769
+ { tab_head: ">", tab_fill: "-", trail: "-", nbsp: "+" }
770
+ end
771
+
220
772
  def render_window_row(editor, window, buffer, buffer_row, gutter_w:, content_w:)
221
773
  line = buffer.line_at(buffer_row)
222
774
  line = line[window.col_offset..] || ""
223
- prefix = line_number_prefix(editor, window, buffer, buffer_row, gutter_w)
775
+ prefix = render_gutter_prefix(editor, window, buffer, buffer_row, gutter_w)
224
776
  body = render_text_line(line, editor, buffer_row:, window:, buffer:, width: content_w)
225
777
  prefix + body
226
778
  end
@@ -234,18 +786,24 @@ module RuVim
234
786
  end
235
787
 
236
788
  def number_column_width(editor, window, buffer)
789
+ sign_w = sign_column_width(editor, window, buffer)
237
790
  enabled = editor.effective_option("number", window:, buffer:) || editor.effective_option("relativenumber", window:, buffer:)
238
- return 0 unless enabled
791
+ return sign_w unless enabled
239
792
 
240
- [buffer.line_count.to_s.length, 1].max + 1
793
+ base = [buffer.line_count.to_s.length, 1].max
794
+ minw = editor.effective_option("numberwidth", window:, buffer:).to_i
795
+ sign_w + ([[base, minw].max, 1].max + 1)
241
796
  end
242
797
 
243
798
  def line_number_prefix(editor, window, buffer, buffer_row, width)
244
799
  return "" if width <= 0
800
+ sign_w = sign_column_width(editor, window, buffer)
801
+ sign = " " * sign_w
802
+ num_width = [width - sign_w, 0].max
245
803
  show_abs = editor.effective_option("number", window:, buffer:)
246
804
  show_rel = editor.effective_option("relativenumber", window:, buffer:)
247
- return " " * width unless show_abs || show_rel
248
- return " " * (width - 1) + " " if buffer_row.nil?
805
+ return sign + (" " * num_width) unless show_abs || show_rel
806
+ return sign + (" " * num_width) if buffer_row.nil?
249
807
 
250
808
  num =
251
809
  if show_rel && buffer_row != window.cursor_y
@@ -255,13 +813,89 @@ module RuVim
255
813
  else
256
814
  "0"
257
815
  end
258
- num.rjust(width - 1) + " "
816
+ sign + num.rjust([num_width - 1, 0].max) + (num_width.positive? ? " " : "")
817
+ end
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
+
828
+ def sign_column_width(editor, window, buffer)
829
+ raw = editor.effective_option("signcolumn", window:, buffer:).to_s
830
+ case raw
831
+ when "", "auto", "number"
832
+ 0
833
+ when "no"
834
+ 0
835
+ else
836
+ if (m = /\Ayes(?::(\d+))?\z/.match(raw))
837
+ n = m[1].to_i
838
+ n = 1 if n <= 0
839
+ n
840
+ else
841
+ 1
842
+ end
843
+ end
844
+ rescue StandardError
845
+ 0
846
+ end
847
+
848
+ def colorcolumn_display_cols(editor, window, buffer)
849
+ raw = editor.effective_option("colorcolumn", window:, buffer:).to_s
850
+ return {} if raw.empty?
851
+
852
+ @colorcolumn_cache ||= {}
853
+ return @colorcolumn_cache[raw] if @colorcolumn_cache.key?(raw)
854
+
855
+ cols = {}
856
+ raw.split(",").each do |tok|
857
+ t = tok.strip
858
+ next if t.empty?
859
+ next unless t.match?(/\A\d+\z/)
860
+ n = t.to_i
861
+ next if n <= 0
862
+ cols[n - 1] = true
863
+ end
864
+ @colorcolumn_cache[raw] = cols.freeze
865
+ rescue StandardError
866
+ {}
259
867
  end
260
868
 
261
869
  def pad_plain_display(text, width)
262
870
  RuVim::TextMetrics.pad_plain_to_screen_width(text, width, tabstop: DEFAULT_TABSTOP)
263
871
  end
264
872
 
873
+ def search_bg_seq(editor)
874
+ truecolor_enabled?(editor) ? "\e[48;2;255;215;0m" : "\e[43m"
875
+ end
876
+
877
+ def colorcolumn_bg_seq(editor)
878
+ truecolor_enabled?(editor) ? "\e[48;2;72;72;72m" : "\e[48;5;238m"
879
+ end
880
+
881
+ def cursorline_bg_seq(editor)
882
+ truecolor_enabled?(editor) ? "\e[48;2;58;58;58m" : "\e[48;5;236m"
883
+ end
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
+
893
+ def truecolor_enabled?(editor)
894
+ !!editor.effective_option("termguicolors")
895
+ rescue StandardError
896
+ false
897
+ end
898
+
265
899
  def status_line(editor, width)
266
900
  buffer = editor.current_buffer
267
901
  window = editor.current_window
@@ -271,23 +905,47 @@ module RuVim
271
905
  when :visual_char then "-- VISUAL --"
272
906
  when :visual_line then "-- VISUAL LINE --"
273
907
  when :visual_block then "-- VISUAL BLOCK --"
908
+ when :rich then "-- RICH --"
274
909
  else "-- NORMAL --"
275
910
  end
276
911
 
277
912
  path = buffer.display_name
278
- ft = editor.effective_option("filetype", buffer:, window:) || File.extname(buffer.path.to_s).delete_prefix(".")
279
- ft = "-" if ft.empty?
280
913
  mod = buffer.modified? ? " [+]" : ""
914
+ stream = stream_status_token(buffer)
915
+ loading = file_loading_status_token(buffer)
916
+ tab = tab_status_token(editor)
281
917
  msg = editor.message_error? ? "" : editor.message.to_s
282
- win_idx = (editor.window_order.index(editor.current_window_id) || 0) + 1
283
- win_total = editor.window_order.length
284
- tab_info = "t#{editor.current_tabpage_number}/#{editor.tabpage_count}"
285
- left = "#{mode} #{tab_info} w#{win_idx}/#{win_total} b#{buffer.id} #{path} [ft=#{ft}]#{mod}"
286
- 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} "
287
920
  body_width = [width - right.length, 0].max
288
921
  "#{compose_status_body(left, msg, body_width)}#{right}"
289
922
  end
290
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
+
291
949
  def compose_status_body(left, msg, width)
292
950
  w = [width.to_i, 0].max
293
951
  return "" if w.zero?
@@ -303,16 +961,35 @@ module RuVim
303
961
  end
304
962
 
305
963
  def truncate(str, width)
306
- str.to_s.ljust(width)[0, width]
964
+ safe = RuVim::TextMetrics.terminal_safe_text(str)
965
+ RuVim::TextMetrics.pad_plain_to_screen_width(safe, width)
307
966
  end
308
967
 
309
968
  def error_message_line(msg, cols)
310
969
  "\e[97;41m#{truncate(msg, cols)}\e[m"
311
970
  end
312
971
 
972
+ def cursor_cell_render(editor, ch)
973
+ "#{cursor_cell_seq(editor)}#{ch}\e[m"
974
+ end
975
+
976
+ def cursor_cell_seq(editor)
977
+ "\e[7m"
978
+ end
979
+
313
980
  def cursor_screen_position(editor, text_rows, rects)
314
981
  window = editor.current_window
315
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
+
316
993
  if editor.command_line_active?
317
994
  row = text_rows + 2
318
995
  col = 1 + editor.command_line.prefix.length + editor.command_line.cursor
@@ -320,50 +997,105 @@ module RuVim
320
997
  end
321
998
 
322
999
  rect = rects[window.id] || { top: 1, left: 1 }
323
- row = rect[:top] + (window.cursor_y - window.row_offset)
324
- line = editor.current_buffer.line_at(window.cursor_y)
325
- gutter_w = number_column_width(editor, window, editor.current_buffer)
326
- tabstop = tabstop_for(editor, window, editor.current_buffer)
327
- prefix_screen_col = RuVim::TextMetrics.screen_col_for_char_index(line, window.cursor_x, tabstop:) -
328
- RuVim::TextMetrics.screen_col_for_char_index(line, window.col_offset, tabstop:)
329
- col = rect[:left] + gutter_w + [prefix_screen_col, 0].max
1000
+ buffer = editor.current_buffer
1001
+ line = buffer.line_at(window.cursor_y)
1002
+ gutter_w = number_column_width(editor, window, buffer)
1003
+ content_w = [rect[:width] - gutter_w, 1].max
1004
+ tabstop = tabstop_for(editor, window, buffer)
1005
+ if wrap_enabled?(editor, window, buffer)
1006
+ visual_rows_before = 0
1007
+ row = window.row_offset
1008
+ while row < window.cursor_y
1009
+ visual_rows_before += wrapped_segments_for_line(editor, window, buffer, buffer.line_at(row), width: content_w).length
1010
+ row += 1
1011
+ end
1012
+ segs = wrapped_segments_for_line(editor, window, buffer, line, width: content_w)
1013
+ seg_index = wrapped_segment_index(segs, window.cursor_x)
1014
+ seg = segs[seg_index] || { source_col_start: 0, display_prefix: "" }
1015
+ row = rect[:top] + visual_rows_before + seg_index
1016
+ seg_prefix_w = RuVim::DisplayWidth.display_width(seg[:display_prefix].to_s, tabstop:)
1017
+ extra_virtual = [window.cursor_x - line.length, 0].max
1018
+ cursor_sc = RuVim::TextMetrics.screen_col_for_char_index(line, window.cursor_x, tabstop:) + extra_virtual
1019
+ seg_sc = RuVim::TextMetrics.screen_col_for_char_index(line, seg[:source_col_start], tabstop:)
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
1024
+ else
1025
+ row = rect[:top] + (window.cursor_y - window.row_offset)
1026
+ extra_virtual = [window.cursor_x - line.length, 0].max
1027
+ prefix_screen_col = RuVim::TextMetrics.screen_col_for_char_index(line, window.cursor_x, tabstop:) -
1028
+ RuVim::TextMetrics.screen_col_for_char_index(line, window.col_offset, tabstop:)
1029
+ col = rect[:left] + gutter_w + [prefix_screen_col, 0].max + extra_virtual
1030
+ end
1031
+ min_row = [rect[:top].to_i, 1].max
1032
+ max_row = [rect[:top].to_i + [rect[:height].to_i, 1].max - 1, min_row].max
1033
+ min_col = [rect[:left].to_i, 1].max
1034
+ max_col = [rect[:left].to_i + [rect[:width].to_i, 1].max - 1, min_col].max
1035
+ row = [[row.to_i, min_row].max, max_row].min
1036
+ col = [[col.to_i, min_col].max, max_col].min
330
1037
  [row, col]
331
1038
  end
332
1039
 
333
1040
  def window_rects(editor, text_rows:, text_cols:)
1041
+ tree = editor.layout_tree
1042
+ return {} if tree.nil?
334
1043
  ids = editor.window_order
335
1044
  return {} if ids.empty?
336
- return { ids.first => { top: 1, left: 1, height: text_rows, width: text_cols } } if ids.length == 1 || editor.window_layout == :single
337
-
338
- if editor.window_layout == :vertical
339
- sep = ids.length - 1
340
- usable = [text_cols - sep, ids.length].max
341
- widths = split_sizes(usable, ids.length)
342
- left = 1
343
- rects = {}
344
- 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|
345
1066
  w = widths[i]
346
- rects[id] = { top: 1, left: left, height: text_rows, width: w, separator: :vertical }
347
- 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
348
1071
  end
349
- rects
350
- else
351
- sep = ids.length - 1
352
- usable = [text_rows - sep, ids.length].max
353
- heights = split_sizes(usable, ids.length)
354
- top = 1
355
- rects = {}
356
- 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|
357
1078
  h = heights[i]
358
- rects[id] = { top: top, left: 1, height: h, width: text_cols, separator: :horizontal }
359
- top += h
360
- if i < ids.length - 1
361
- rects[id][:sep_row] = top
362
- 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]
363
1087
  end
1088
+ cur_top += h + 1
364
1089
  end
365
- rects
366
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) }
367
1099
  end
368
1100
 
369
1101
  def split_sizes(total, n)