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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +4 -0
- data/AGENTS.md +84 -0
- data/CLAUDE.md +1 -0
- data/docs/binding.md +23 -0
- data/docs/command.md +85 -0
- data/docs/config.md +2 -2
- data/docs/done.md +21 -0
- data/docs/spec.md +157 -12
- data/docs/todo.md +1 -5
- data/docs/vim_diff.md +94 -172
- data/lib/ruvim/app.rb +882 -69
- data/lib/ruvim/buffer.rb +35 -1
- data/lib/ruvim/cli.rb +12 -3
- data/lib/ruvim/clipboard.rb +2 -0
- data/lib/ruvim/command_invocation.rb +3 -1
- data/lib/ruvim/command_line.rb +2 -0
- data/lib/ruvim/command_registry.rb +2 -0
- data/lib/ruvim/config_dsl.rb +2 -0
- data/lib/ruvim/config_loader.rb +2 -0
- data/lib/ruvim/context.rb +2 -0
- data/lib/ruvim/dispatcher.rb +143 -13
- data/lib/ruvim/display_width.rb +3 -0
- data/lib/ruvim/editor.rb +455 -71
- data/lib/ruvim/ex_command_registry.rb +2 -0
- data/lib/ruvim/global_commands.rb +890 -63
- data/lib/ruvim/highlighter.rb +16 -21
- data/lib/ruvim/input.rb +39 -28
- data/lib/ruvim/keymap_manager.rb +83 -0
- data/lib/ruvim/keyword_chars.rb +2 -0
- data/lib/ruvim/lang/base.rb +25 -0
- data/lib/ruvim/lang/csv.rb +18 -0
- data/lib/ruvim/lang/json.rb +18 -0
- data/lib/ruvim/lang/markdown.rb +170 -0
- data/lib/ruvim/lang/ruby.rb +236 -0
- data/lib/ruvim/lang/scheme.rb +44 -0
- data/lib/ruvim/lang/tsv.rb +19 -0
- data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
- data/lib/ruvim/rich_view/table_renderer.rb +176 -0
- data/lib/ruvim/rich_view.rb +93 -0
- data/lib/ruvim/screen.rb +503 -106
- data/lib/ruvim/terminal.rb +18 -1
- data/lib/ruvim/text_metrics.rb +2 -0
- data/lib/ruvim/version.rb +1 -1
- data/lib/ruvim/window.rb +2 -0
- data/lib/ruvim.rb +14 -0
- data/test/app_completion_test.rb +73 -0
- data/test/app_dot_repeat_test.rb +13 -0
- data/test/app_motion_test.rb +13 -0
- data/test/app_scenario_test.rb +729 -1
- data/test/app_startup_test.rb +187 -0
- data/test/arglist_test.rb +113 -0
- data/test/buffer_test.rb +49 -30
- data/test/dispatcher_test.rb +322 -0
- data/test/editor_register_test.rb +23 -0
- data/test/highlighter_test.rb +121 -0
- data/test/indent_test.rb +201 -0
- data/test/input_screen_integration_test.rb +40 -2
- data/test/markdown_renderer_test.rb +279 -0
- data/test/on_save_hook_test.rb +150 -0
- data/test/rich_view_test.rb +478 -0
- data/test/screen_test.rb +304 -0
- metadata +33 -2
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuVim
|
|
4
|
+
module RichView
|
|
5
|
+
module TableRenderer
|
|
6
|
+
SEPARATOR = " | "
|
|
7
|
+
SEPARATOR_WIDTH = 3
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def delimiter_for(format)
|
|
12
|
+
case format.to_s
|
|
13
|
+
when "csv" then ","
|
|
14
|
+
when "tsv" then "\t"
|
|
15
|
+
else "\t"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Compute max display width per column from visible lines.
|
|
20
|
+
# Returns an Array of column widths, or nil if single-column / empty.
|
|
21
|
+
def compute_col_widths(lines, delimiter:)
|
|
22
|
+
return nil if lines.nil? || lines.empty?
|
|
23
|
+
|
|
24
|
+
rows = lines.map { |line| split_fields(line, delimiter) }
|
|
25
|
+
max_cols = rows.map(&:length).max
|
|
26
|
+
return nil if max_cols.nil? || max_cols <= 1
|
|
27
|
+
|
|
28
|
+
col_widths = Array.new(max_cols, 0)
|
|
29
|
+
rows.each do |fields|
|
|
30
|
+
fields.each_with_index do |field, i|
|
|
31
|
+
w = RuVim::DisplayWidth.display_width(field)
|
|
32
|
+
col_widths[i] = w if w > col_widths[i]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
col_widths
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Format a single raw line using pre-computed column widths.
|
|
39
|
+
def format_line(raw_line, delimiter:, col_widths:)
|
|
40
|
+
fields = split_fields(raw_line, delimiter)
|
|
41
|
+
padded = fields.each_with_index.map do |field, i|
|
|
42
|
+
pad_field(field, col_widths[i] || 0)
|
|
43
|
+
end
|
|
44
|
+
(fields.length...col_widths.length).each do |i|
|
|
45
|
+
padded << " " * col_widths[i]
|
|
46
|
+
end
|
|
47
|
+
padded.join(SEPARATOR)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Map a character index in the raw line to the corresponding index
|
|
51
|
+
# in the formatted line. Needed for correct horizontal scrolling
|
|
52
|
+
# and cursor placement in rich mode.
|
|
53
|
+
def raw_to_formatted_char_index(raw_line, raw_char_index, delimiter:, col_widths:)
|
|
54
|
+
fields = split_fields(raw_line, delimiter)
|
|
55
|
+
pos = 0
|
|
56
|
+
formatted_pos = 0
|
|
57
|
+
fields.each_with_index do |field, i|
|
|
58
|
+
field_end = pos + field.length
|
|
59
|
+
if raw_char_index <= field_end
|
|
60
|
+
offset = [raw_char_index - pos, field.length].min
|
|
61
|
+
return formatted_pos + offset
|
|
62
|
+
end
|
|
63
|
+
pos = field_end + 1 # skip delimiter
|
|
64
|
+
field_dw = RuVim::DisplayWidth.display_width(field)
|
|
65
|
+
pad_chars = [(col_widths[i] || 0) - field_dw, 0].max
|
|
66
|
+
formatted_pos += field.length + pad_chars + SEPARATOR.length
|
|
67
|
+
end
|
|
68
|
+
formatted_pos
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Map a character index in the raw line to the display column
|
|
72
|
+
# in the formatted line. Unlike raw_to_formatted_char_index (which
|
|
73
|
+
# returns a character index), this returns the screen column directly,
|
|
74
|
+
# which is needed for display-column-based horizontal scrolling.
|
|
75
|
+
def raw_to_formatted_display_col(raw_line, raw_char_index, delimiter:, col_widths:)
|
|
76
|
+
fields = split_fields(raw_line, delimiter)
|
|
77
|
+
pos = 0
|
|
78
|
+
display_col = 0
|
|
79
|
+
fields.each_with_index do |field, i|
|
|
80
|
+
field_end = pos + field.length
|
|
81
|
+
if raw_char_index <= field_end
|
|
82
|
+
offset_chars = [raw_char_index - pos, field.length].min
|
|
83
|
+
offset_dw = RuVim::DisplayWidth.display_width(field[0...offset_chars])
|
|
84
|
+
return display_col + offset_dw
|
|
85
|
+
end
|
|
86
|
+
display_col += (col_widths[i] || 0) + SEPARATOR_WIDTH
|
|
87
|
+
pos = field_end + 1
|
|
88
|
+
end
|
|
89
|
+
display_col
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Compute the display column for the cursor position in a formatted line.
|
|
93
|
+
def cursor_display_col(raw_line, cursor_x, visible_lines:, delimiter:)
|
|
94
|
+
col_widths = compute_col_widths(visible_lines, delimiter: delimiter)
|
|
95
|
+
return RuVim::TextMetrics.screen_col_for_char_index(raw_line, cursor_x) unless col_widths
|
|
96
|
+
|
|
97
|
+
raw_to_formatted_display_col(raw_line, cursor_x, delimiter: delimiter, col_widths: col_widths)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Render visible lines: split by delimiter, compute column widths, pad and join.
|
|
101
|
+
# Returns an array of formatted strings.
|
|
102
|
+
def render_visible(lines, delimiter:)
|
|
103
|
+
return lines if lines.nil? || lines.empty?
|
|
104
|
+
|
|
105
|
+
col_widths = compute_col_widths(lines, delimiter:)
|
|
106
|
+
return lines unless col_widths
|
|
107
|
+
|
|
108
|
+
lines.map { |line| format_line(line, delimiter:, col_widths:) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Split a line into fields respecting the delimiter.
|
|
112
|
+
# For CSV, handles quoted fields minimally.
|
|
113
|
+
def split_fields(line, delimiter)
|
|
114
|
+
if delimiter == ","
|
|
115
|
+
parse_csv_fields(line)
|
|
116
|
+
else
|
|
117
|
+
line.to_s.split(delimiter, -1)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Minimal CSV field parser: handles double-quoted fields with embedded
|
|
122
|
+
# commas and escaped quotes ("").
|
|
123
|
+
def parse_csv_fields(line)
|
|
124
|
+
fields = []
|
|
125
|
+
s = line.to_s
|
|
126
|
+
pos = 0
|
|
127
|
+
|
|
128
|
+
while pos <= s.length
|
|
129
|
+
if pos < s.length && s[pos] == '"'
|
|
130
|
+
# Quoted field
|
|
131
|
+
pos += 1
|
|
132
|
+
field = +""
|
|
133
|
+
while pos < s.length
|
|
134
|
+
if s[pos] == '"'
|
|
135
|
+
if pos + 1 < s.length && s[pos + 1] == '"'
|
|
136
|
+
field << '"'
|
|
137
|
+
pos += 2
|
|
138
|
+
else
|
|
139
|
+
pos += 1
|
|
140
|
+
break
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
field << s[pos]
|
|
144
|
+
pos += 1
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
fields << field
|
|
148
|
+
# Skip comma after quoted field
|
|
149
|
+
pos += 1 if pos < s.length && s[pos] == ','
|
|
150
|
+
else
|
|
151
|
+
# Unquoted field
|
|
152
|
+
comma_idx = s.index(',', pos)
|
|
153
|
+
if comma_idx
|
|
154
|
+
fields << s[pos...comma_idx]
|
|
155
|
+
pos = comma_idx + 1
|
|
156
|
+
# Trailing comma means empty last field
|
|
157
|
+
fields << "" if pos == s.length
|
|
158
|
+
else
|
|
159
|
+
fields << s[pos..]
|
|
160
|
+
break
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
fields
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Pad a field to a target display width using spaces.
|
|
169
|
+
def pad_field(field, target_width)
|
|
170
|
+
current = RuVim::DisplayWidth.display_width(field)
|
|
171
|
+
gap = target_width - current
|
|
172
|
+
gap > 0 ? "#{field}#{' ' * gap}" : field
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rich_view/table_renderer"
|
|
4
|
+
require_relative "rich_view/markdown_renderer"
|
|
5
|
+
|
|
6
|
+
module RuVim
|
|
7
|
+
module RichView
|
|
8
|
+
@renderers = {}
|
|
9
|
+
@detectors = []
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def register(filetype, renderer, detector: nil)
|
|
14
|
+
@renderers[filetype.to_s] = renderer
|
|
15
|
+
@detectors << { filetype: filetype.to_s, detector: detector } if detector
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def renderer_for(filetype)
|
|
19
|
+
@renderers[filetype.to_s]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def registered_filetypes
|
|
23
|
+
@renderers.keys
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Detect format from filetype or buffer content.
|
|
27
|
+
# Returns a filetype string ("tsv", "csv", "markdown") or nil.
|
|
28
|
+
def detect_format(buffer)
|
|
29
|
+
ft = buffer.options["filetype"].to_s
|
|
30
|
+
return ft if @renderers.key?(ft)
|
|
31
|
+
|
|
32
|
+
# Ask registered detectors
|
|
33
|
+
@detectors.each do |entry|
|
|
34
|
+
return entry[:filetype] if entry[:detector].call(buffer)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Enter rich mode on the current buffer (same buffer, no virtual buffer).
|
|
41
|
+
def open!(editor, format: nil)
|
|
42
|
+
buffer = editor.current_buffer
|
|
43
|
+
format ||= detect_format(buffer)
|
|
44
|
+
raise RuVim::CommandError, "Cannot detect format for rich view" unless format
|
|
45
|
+
|
|
46
|
+
renderer = @renderers[format]
|
|
47
|
+
raise RuVim::CommandError, "No renderer for format: #{format}" unless renderer
|
|
48
|
+
|
|
49
|
+
delimiter = renderer.delimiter_for(format)
|
|
50
|
+
editor.enter_rich_mode(format: format, delimiter: delimiter)
|
|
51
|
+
editor.echo("[Rich: #{format}]")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Check if rich view rendering is active (persists during command-line mode).
|
|
55
|
+
def active?(editor)
|
|
56
|
+
!!editor.rich_state
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Render visible lines through the appropriate renderer.
|
|
60
|
+
# Takes raw lines from buffer, returns formatted lines for display.
|
|
61
|
+
def render_visible_lines(editor, lines, context: {})
|
|
62
|
+
state = editor.rich_state
|
|
63
|
+
return lines unless state
|
|
64
|
+
|
|
65
|
+
format = state[:format]
|
|
66
|
+
renderer = @renderers[format]
|
|
67
|
+
return lines unless renderer
|
|
68
|
+
|
|
69
|
+
delimiter = state[:delimiter]
|
|
70
|
+
if renderer.respond_to?(:needs_pre_context?) && renderer.needs_pre_context?
|
|
71
|
+
renderer.render_visible(lines, delimiter: delimiter, context: context)
|
|
72
|
+
else
|
|
73
|
+
renderer.render_visible(lines, delimiter: delimiter)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Toggle: if in rich mode, exit; otherwise enter.
|
|
78
|
+
def toggle!(editor, format: nil)
|
|
79
|
+
if active?(editor)
|
|
80
|
+
close!(editor)
|
|
81
|
+
else
|
|
82
|
+
open!(editor, format: format)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Exit rich mode and return to normal mode.
|
|
87
|
+
def close!(editor)
|
|
88
|
+
return unless active?(editor)
|
|
89
|
+
|
|
90
|
+
editor.exit_rich_mode
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|