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,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