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
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+ require "open3"
5
+
6
+ module RuVim
7
+ module Lang
8
+ module Ruby
9
+ PRISM_KEYWORD_TYPES = %i[
10
+ KEYWORD_ALIAS
11
+ KEYWORD_AND
12
+ KEYWORD_BEGIN
13
+ KEYWORD_BEGIN_UPCASE
14
+ KEYWORD_BREAK
15
+ KEYWORD_CASE
16
+ KEYWORD_CLASS
17
+ KEYWORD_DEF
18
+ KEYWORD_DEFINED
19
+ KEYWORD_DO
20
+ KEYWORD_ELSE
21
+ KEYWORD_ELSIF
22
+ KEYWORD_END
23
+ KEYWORD_ENSURE
24
+ KEYWORD_FALSE
25
+ KEYWORD_FOR
26
+ KEYWORD_IF
27
+ KEYWORD_IF_MODIFIER
28
+ KEYWORD_IN
29
+ KEYWORD_MODULE
30
+ KEYWORD_NEXT
31
+ KEYWORD_NIL
32
+ KEYWORD_NOT
33
+ KEYWORD_OR
34
+ KEYWORD_REDO
35
+ KEYWORD_RESCUE
36
+ KEYWORD_RESCUE_MODIFIER
37
+ KEYWORD_RETRY
38
+ KEYWORD_RETURN
39
+ KEYWORD_SELF
40
+ KEYWORD_SUPER
41
+ KEYWORD_THEN
42
+ KEYWORD_TRUE
43
+ KEYWORD_UNDEF
44
+ KEYWORD_UNLESS
45
+ KEYWORD_UNLESS_MODIFIER
46
+ KEYWORD_UNTIL
47
+ KEYWORD_UNTIL_MODIFIER
48
+ KEYWORD_WHEN
49
+ KEYWORD_WHILE
50
+ KEYWORD_WHILE_MODIFIER
51
+ KEYWORD_YIELD
52
+ MISSING
53
+ ].freeze
54
+
55
+ PRISM_STRING_TYPES = %i[
56
+ STRING_BEGIN
57
+ STRING_CONTENT
58
+ STRING_END
59
+ SYMBOL_BEGIN
60
+ REGEXP_BEGIN
61
+ REGEXP_CONTENT
62
+ REGEXP_END
63
+ XSTRING_BEGIN
64
+ XSTRING_CONTENT
65
+ XSTRING_END
66
+ WORDS_BEGIN
67
+ QWORDS_BEGIN
68
+ WORDS_SEPARATOR
69
+ STRING
70
+ CHARACTER_LITERAL
71
+ ].freeze
72
+
73
+ PRISM_NUMBER_TYPES = %i[
74
+ INTEGER
75
+ FLOAT
76
+ RATIONAL_NUMBER
77
+ IMAGINARY_NUMBER
78
+ UINTEGER
79
+ ].freeze
80
+
81
+ PRISM_COMMENT_TYPES = %i[
82
+ COMMENT
83
+ EMBDOC_BEGIN
84
+ EMBDOC_LINE
85
+ EMBDOC_END
86
+ ].freeze
87
+
88
+ PRISM_VARIABLE_TYPES = %i[
89
+ INSTANCE_VARIABLE
90
+ CLASS_VARIABLE
91
+ GLOBAL_VARIABLE
92
+ ].freeze
93
+
94
+ PRISM_CONSTANT_TYPES = %i[
95
+ CONSTANT
96
+ ].freeze
97
+
98
+ # Keywords that open a new indentation level
99
+ INDENT_OPEN_RE = /\A\s*(?:def|class|module|if|unless|while|until|for|begin|case)\b/
100
+ # `do` at end of line (block form)
101
+ INDENT_DO_RE = /\bdo\s*(\|[^|]*\|)?\s*$/
102
+ # Opening brackets at end of line
103
+ INDENT_BRACKET_OPEN_RE = /[\[({]\s*$/
104
+ # Keywords that close an indentation level
105
+ INDENT_CLOSE_RE = /\A\s*(?:end|[}\])])/
106
+ # Keywords at same level as their opening keyword (dedent for the line itself)
107
+ INDENT_MID_RE = /\A\s*(?:else|elsif|when|rescue|ensure|in)\b/
108
+ # Modifier keywords that do NOT open indentation
109
+ MODIFIER_RE = /\b(?:if|unless|while|until|rescue)\b/
110
+
111
+ module_function
112
+
113
+ def calculate_indent(lines, target_row, shiftwidth)
114
+ depth = 0
115
+ (0...target_row).each do |row|
116
+ line = lines[row]
117
+ stripped = line.to_s.lstrip
118
+
119
+ # Skip blank and comment lines for indent computation
120
+ next if stripped.empty? || stripped.start_with?("#")
121
+
122
+ # Check if the line opens a new level
123
+ if stripped.match?(INDENT_OPEN_RE)
124
+ # Check for modifier form: something before the keyword on the same line
125
+ # e.g. "return if true" — if keyword is not at start, it's a modifier
126
+ first_word = stripped[/\A(\w+)/, 1]
127
+ if %w[if unless while until rescue].include?(first_word)
128
+ depth += 1
129
+ elsif %w[def class module begin case for].include?(first_word)
130
+ depth += 1
131
+ end
132
+ elsif stripped.match?(INDENT_DO_RE)
133
+ depth += 1
134
+ end
135
+
136
+ # Count opening brackets (not at end-of-line pattern, but individual)
137
+ stripped.each_char do |ch|
138
+ case ch
139
+ when "{", "[", "("
140
+ depth += 1
141
+ when "}", "]", ")"
142
+ depth -= 1
143
+ end
144
+ end if !stripped.match?(INDENT_OPEN_RE) && !stripped.match?(INDENT_DO_RE)
145
+
146
+ # Handle closing keywords
147
+ if stripped.match?(/\A\s*end\b/)
148
+ depth -= 1
149
+ end
150
+
151
+ # Handle mid keywords (else/elsif/when/rescue/ensure) — they don't change depth for following lines
152
+ end
153
+
154
+ # Now compute indent for target_row
155
+ target_line = lines[target_row].to_s.lstrip
156
+
157
+ # If target line is a closing keyword, dedent
158
+ if target_line.match?(INDENT_CLOSE_RE)
159
+ depth -= 1
160
+ elsif target_line.match?(INDENT_MID_RE)
161
+ depth -= 1
162
+ end
163
+
164
+ depth = 0 if depth < 0
165
+ depth * shiftwidth
166
+ end
167
+
168
+ # Returns true if the line should increase indent for the next line
169
+ def indent_trigger?(line)
170
+ stripped = line.to_s.rstrip.lstrip
171
+ first_word = stripped[/\A(\w+)/, 1].to_s
172
+ return true if %w[def class module if unless while until for begin case].include?(first_word)
173
+ return true if stripped.match?(/\bdo\s*(\|[^|]*\|)?\s*$/)
174
+ false
175
+ end
176
+
177
+ # Dedent trigger patterns keyed by the last character typed
178
+ DEDENT_TRIGGERS = {
179
+ "d" => /\A(\s*)end\z/,
180
+ "e" => /\A(\s*)(?:else|rescue|ensure)\z/,
181
+ "f" => /\A(\s*)elsif\z/,
182
+ "n" => /\A(\s*)(?:when|in)\z/
183
+ }.freeze
184
+
185
+ # Returns the dedent pattern for the given character, or nil
186
+ def dedent_trigger(char)
187
+ DEDENT_TRIGGERS[char]
188
+ end
189
+
190
+ def on_save(ctx, path)
191
+ return unless path && File.exist?(path)
192
+ output, status = Open3.capture2e("ruby", "-wc", path)
193
+ message = output.sub(/^Syntax OK\n?\z/m, "").strip
194
+
195
+ if !status.success? || !message.empty?
196
+ buffer_id = ctx.buffer.id
197
+ items = message.lines.filter_map { |line|
198
+ if line =~ /\A.+?:(\d+):/
199
+ { buffer_id: buffer_id, row: $1.to_i - 1, col: 0, text: line.strip }
200
+ end
201
+ }
202
+ items = [{ buffer_id: buffer_id, row: 0, col: 0, text: message }] if items.empty?
203
+ ctx.editor.set_quickfix_list(items)
204
+ first = message.lines.first.to_s.strip
205
+ hint = items.size > 1 ? " (Q to see all, #{items.size} total)" : ""
206
+ ctx.editor.echo_error("#{first}#{hint}")
207
+ else
208
+ ctx.editor.set_quickfix_list([])
209
+ end
210
+ end
211
+
212
+ def color_columns(text)
213
+ cols = {}
214
+ Prism.lex(text).value.each do |entry|
215
+ token = entry[0]
216
+ type = token.type
217
+ range = token.location.start_offset...token.location.end_offset
218
+ if PRISM_STRING_TYPES.include?(type)
219
+ range.each { |idx| cols[idx] = Highlighter::STRING_COLOR unless cols.key?(idx) }
220
+ elsif PRISM_KEYWORD_TYPES.include?(type)
221
+ range.each { |idx| cols[idx] = Highlighter::KEYWORD_COLOR unless cols.key?(idx) }
222
+ elsif PRISM_NUMBER_TYPES.include?(type)
223
+ range.each { |idx| cols[idx] = Highlighter::NUMBER_COLOR unless cols.key?(idx) }
224
+ elsif PRISM_VARIABLE_TYPES.include?(type)
225
+ range.each { |idx| cols[idx] = Highlighter::VARIABLE_COLOR unless cols.key?(idx) }
226
+ elsif PRISM_CONSTANT_TYPES.include?(type)
227
+ range.each { |idx| cols[idx] = Highlighter::CONSTANT_COLOR unless cols.key?(idx) }
228
+ elsif PRISM_COMMENT_TYPES.include?(type)
229
+ range.each { |idx| cols[idx] = Highlighter::COMMENT_COLOR }
230
+ end
231
+ end
232
+ cols
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Scheme
6
+ KEYWORDS = %w[
7
+ define define-syntax define-record-type define-values define-library
8
+ lambda let let* letrec letrec* let-values let-syntax letrec-syntax
9
+ if cond case when unless and or not
10
+ begin do set! quote quasiquote unquote unquote-splicing
11
+ syntax-rules syntax-case
12
+ import export library
13
+ else =>
14
+ call-with-current-continuation call/cc
15
+ call-with-values values
16
+ dynamic-wind
17
+ guard raise raise-continuable with-exception-handler
18
+ parameterize make-parameter
19
+ include include-ci
20
+ ].freeze
21
+
22
+ KEYWORD_RE = /(?<=[\s(])(#{KEYWORDS.map { |k| Regexp.escape(k) }.join("|")})(?=[\s()\]]|$)/
23
+ COMMENT_RE = /;.*/
24
+ STRING_RE = /"(?:\\.|[^"\\])*"/
25
+ CHAR_RE = /#\\(?:space|newline|tab|alarm|backspace|delete|escape|null|return|[^\s()])/
26
+ BOOLEAN_RE = /#[tf]\b/
27
+ NUMBER_RE = /(?<=[\s(])[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?(?=[\s()\]]|$)/
28
+
29
+ module_function
30
+
31
+ def color_columns(text)
32
+ cols = {}
33
+ # Order matters: comment overrides all, then strings, then others
34
+ Highlighter.apply_regex(cols, text, CHAR_RE, Highlighter::STRING_COLOR)
35
+ Highlighter.apply_regex(cols, text, STRING_RE, Highlighter::STRING_COLOR)
36
+ Highlighter.apply_regex(cols, text, KEYWORD_RE, Highlighter::KEYWORD_COLOR)
37
+ Highlighter.apply_regex(cols, text, BOOLEAN_RE, "\e[35m")
38
+ Highlighter.apply_regex(cols, text, NUMBER_RE, Highlighter::NUMBER_COLOR)
39
+ Highlighter.apply_regex(cols, text, COMMENT_RE, Highlighter::COMMENT_COLOR, override: true)
40
+ cols
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module Lang
5
+ module Tsv
6
+ module_function
7
+
8
+ # Detect TSV from buffer content: tabs >= commas and tabs > 0
9
+ def detect?(buffer)
10
+ sample = (0...[buffer.line_count, 20].min).map { |i| buffer.line_at(i) }
11
+ tabs = sample.sum { |l| l.count("\t") }
12
+ commas = sample.sum { |l| l.count(",") }
13
+ tabs > 0 && tabs >= commas
14
+ end
15
+ end
16
+ end
17
+
18
+ RichView.register("tsv", RichView::TableRenderer, detector: Lang::Tsv.method(:detect?))
19
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ module RichView
5
+ module MarkdownRenderer
6
+ module_function
7
+
8
+ def delimiter_for(_format)
9
+ nil
10
+ end
11
+
12
+ def needs_pre_context?
13
+ true
14
+ end
15
+
16
+ def render_visible(lines, delimiter:, context: {})
17
+ return lines if lines.nil? || lines.empty?
18
+
19
+ state = Lang::Markdown::FenceState.new
20
+ pre = context[:pre_context_lines]
21
+ if pre
22
+ pre.each { |l| state.scan_line(l) }
23
+ end
24
+
25
+ # Identify table groups for column-width alignment
26
+ table_groups = identify_table_groups(lines)
27
+
28
+ lines.each_with_index.map do |line, idx|
29
+ rendered = render_line(line, state, table_groups[idx])
30
+ state.scan_line(line)
31
+ rendered
32
+ end
33
+ end
34
+
35
+ def cursor_display_col(raw_line, cursor_x, visible_lines:, delimiter:)
36
+ if Lang::Markdown.table_line?(raw_line)
37
+ table_lines = visible_lines.select { |l| Lang::Markdown.table_line?(l) }
38
+ group = build_table_group(table_lines)
39
+ if group
40
+ return table_cursor_display_col(raw_line, cursor_x, group)
41
+ end
42
+ end
43
+ RuVim::TextMetrics.screen_col_for_char_index(raw_line, cursor_x)
44
+ end
45
+
46
+ # --- Line rendering ---
47
+
48
+ def render_line(line, state, table_group)
49
+ stripped = line.to_s.strip
50
+
51
+ # Code fence open/close
52
+ if !state.in_code_block && Lang::Markdown.fence_line?(stripped)
53
+ return "\e[90m#{line}\e[m"
54
+ end
55
+
56
+ if state.in_code_block
57
+ if Lang::Markdown.fence_line?(stripped)
58
+ return "\e[90m#{line}\e[m"
59
+ end
60
+ return "\e[38;5;223m#{line}\e[m"
61
+ end
62
+
63
+ # HR
64
+ if Lang::Markdown.horizontal_rule?(stripped)
65
+ return render_hr(line)
66
+ end
67
+
68
+ # Heading
69
+ level = Lang::Markdown.heading_level(line)
70
+ if level > 0
71
+ return render_heading(line, level)
72
+ end
73
+
74
+ # Block quote
75
+ if Lang::Markdown.block_quote?(line)
76
+ return "\e[36m#{apply_inline(line)}\e[m"
77
+ end
78
+
79
+ # Table
80
+ if table_group
81
+ return render_table_line(line, stripped, table_group)
82
+ end
83
+
84
+ # Default: inline decoration only
85
+ apply_inline(line)
86
+ end
87
+
88
+ # --- Heading rendering ---
89
+
90
+ HEADING_STYLES = Lang::Markdown::HEADING_COLORS
91
+
92
+ def render_heading(line, level)
93
+ style = HEADING_STYLES[level] || HEADING_STYLES[6]
94
+ "#{style}#{line}\e[m"
95
+ end
96
+
97
+ # --- HR rendering ---
98
+
99
+ def render_hr(line)
100
+ # Replace the HR marker with box-drawing horizontal line
101
+ width = [line.length, 3].max
102
+ "\e[90m#{"─" * width}\e[m"
103
+ end
104
+
105
+ # --- Inline decoration ---
106
+
107
+ def apply_inline(line)
108
+ result = line.to_s.dup
109
+
110
+ # Checkbox (must come before bold/italic to avoid interference)
111
+ result = result.gsub(Lang::Markdown::CHECKBOX_CHECKED_RE) { "#{$1}\e[32m[x]\e[m" }
112
+ result = result.gsub(Lang::Markdown::CHECKBOX_UNCHECKED_RE) { "#{$1}\e[90m[ ]\e[m" }
113
+
114
+ # Bold **text**
115
+ result = result.gsub(Lang::Markdown::BOLD_RE) { "\e[1m**#{$1}**\e[22m" }
116
+
117
+ # Italic *text* (but not ** which is bold)
118
+ result = result.gsub(Lang::Markdown::ITALIC_RE) { "\e[3m*#{$1}*\e[23m" }
119
+
120
+ # Inline code `text`
121
+ result = result.gsub(Lang::Markdown::INLINE_CODE_RE) { "\e[33m`#{$1}`\e[m" }
122
+
123
+ # Links [text](url)
124
+ result = result.gsub(Lang::Markdown::LINK_RE) { "\e[4m#{$1}\e[24m(\e[2m#{$2}\e[22m)" }
125
+
126
+ result
127
+ end
128
+
129
+ # --- Table rendering ---
130
+
131
+ def identify_table_groups(lines)
132
+ groups = {}
133
+ i = 0
134
+ while i < lines.length
135
+ if Lang::Markdown.table_line?(lines[i])
136
+ start = i
137
+ table_lines = []
138
+ while i < lines.length && Lang::Markdown.table_line?(lines[i])
139
+ table_lines << lines[i]
140
+ i += 1
141
+ end
142
+ group = build_table_group(table_lines)
143
+ (start...(start + table_lines.length)).each { |j| groups[j] = group }
144
+ else
145
+ i += 1
146
+ end
147
+ end
148
+ groups
149
+ end
150
+
151
+ def build_table_group(table_lines)
152
+ return nil if table_lines.nil? || table_lines.empty?
153
+
154
+ cells = table_lines.map { |l| Lang::Markdown.parse_table_cells(l) }
155
+ max_cols = cells.map(&:length).max
156
+ return nil if max_cols.nil? || max_cols.zero?
157
+
158
+ col_widths = Array.new(max_cols, 0)
159
+ cells.each do |row|
160
+ row.each_with_index do |cell, ci|
161
+ w = RuVim::DisplayWidth.display_width(cell)
162
+ col_widths[ci] = w if w > col_widths[ci]
163
+ end
164
+ end
165
+
166
+ { col_widths: col_widths, max_cols: max_cols }
167
+ end
168
+
169
+ def render_table_line(line, stripped, group)
170
+ if Lang::Markdown.table_separator?(stripped)
171
+ render_table_separator(group)
172
+ else
173
+ render_table_data_row(line, group)
174
+ end
175
+ end
176
+
177
+ def render_table_separator(group)
178
+ col_widths = group[:col_widths]
179
+ cells = col_widths.map { |w| "─" * (w + 2) }
180
+ "├#{cells.join("┼")}┤"
181
+ end
182
+
183
+ def render_table_data_row(line, group)
184
+ col_widths = group[:col_widths]
185
+ cells = Lang::Markdown.parse_table_cells(line)
186
+ padded = cells.each_with_index.map do |cell, i|
187
+ target = col_widths[i] || 0
188
+ pad_cell(cell, target)
189
+ end
190
+ # Fill missing columns
191
+ (cells.length...group[:max_cols]).each do |i|
192
+ padded << " " * (col_widths[i] || 0)
193
+ end
194
+ "│ #{padded.join(" │ ")} │"
195
+ end
196
+
197
+ def pad_cell(cell, target_width)
198
+ current = RuVim::DisplayWidth.display_width(cell)
199
+ gap = target_width - current
200
+ gap > 0 ? "#{cell}#{" " * gap}" : cell
201
+ end
202
+
203
+ # --- Table cursor mapping ---
204
+
205
+ def table_cursor_display_col(raw_line, cursor_x, group)
206
+ col_widths = group[:col_widths]
207
+ cells = Lang::Markdown.parse_table_cells(raw_line)
208
+
209
+ # Map cursor_x (in raw line) to display col in formatted line
210
+ # Raw line: "| cell1 | cell2 |"
211
+ # Formatted: "│ cell1 │ cell2 │"
212
+
213
+ # Walk through the raw line to find which cell cursor_x is in
214
+ pos = 0
215
+ raw = raw_line.to_s
216
+ # Skip leading whitespace
217
+ pos += 1 while pos < raw.length && raw[pos] == " "
218
+ # Skip leading |
219
+ pos += 1 if pos < raw.length && raw[pos] == "|"
220
+
221
+ display_col = 2 # "│ " prefix
222
+ cells.each_with_index do |cell, ci|
223
+ # Skip whitespace before cell content
224
+ pos += 1 while pos < raw.length && raw[pos] == " "
225
+
226
+ cell_start = pos
227
+ cell_end = cell_start + cell.length
228
+
229
+ if cursor_x < cell_end || ci == cells.length - 1
230
+ # Cursor is in this cell
231
+ offset_in_cell = [cursor_x - cell_start, 0].max
232
+ offset_in_cell = [offset_in_cell, cell.length].min
233
+ return display_col + RuVim::DisplayWidth.display_width(cell[0...offset_in_cell])
234
+ end
235
+
236
+ display_col += (col_widths[ci] || 0) + 3 # " │ "
237
+ pos = cell_end
238
+ # Skip whitespace after cell
239
+ pos += 1 while pos < raw.length && raw[pos] == " "
240
+ # Skip |
241
+ pos += 1 if pos < raw.length && raw[pos] == "|"
242
+ end
243
+
244
+ display_col
245
+ end
246
+ end
247
+ end
248
+ end