ruvim 0.1.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 (66) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +15 -0
  3. data/README.md +135 -0
  4. data/Rakefile +36 -0
  5. data/docs/binding.md +125 -0
  6. data/docs/command.md +306 -0
  7. data/docs/config.md +155 -0
  8. data/docs/done.md +112 -0
  9. data/docs/plugin.md +559 -0
  10. data/docs/spec.md +655 -0
  11. data/docs/todo.md +63 -0
  12. data/docs/tutorial.md +490 -0
  13. data/docs/vim_diff.md +179 -0
  14. data/exe/ruvim +6 -0
  15. data/lib/ruvim/app.rb +1600 -0
  16. data/lib/ruvim/buffer.rb +421 -0
  17. data/lib/ruvim/cli.rb +264 -0
  18. data/lib/ruvim/clipboard.rb +73 -0
  19. data/lib/ruvim/command_invocation.rb +14 -0
  20. data/lib/ruvim/command_line.rb +63 -0
  21. data/lib/ruvim/command_registry.rb +38 -0
  22. data/lib/ruvim/config_dsl.rb +134 -0
  23. data/lib/ruvim/config_loader.rb +68 -0
  24. data/lib/ruvim/context.rb +26 -0
  25. data/lib/ruvim/dispatcher.rb +120 -0
  26. data/lib/ruvim/display_width.rb +110 -0
  27. data/lib/ruvim/editor.rb +1025 -0
  28. data/lib/ruvim/ex_command_registry.rb +80 -0
  29. data/lib/ruvim/global_commands.rb +1889 -0
  30. data/lib/ruvim/highlighter.rb +52 -0
  31. data/lib/ruvim/input.rb +66 -0
  32. data/lib/ruvim/keymap_manager.rb +96 -0
  33. data/lib/ruvim/screen.rb +452 -0
  34. data/lib/ruvim/terminal.rb +30 -0
  35. data/lib/ruvim/text_metrics.rb +96 -0
  36. data/lib/ruvim/version.rb +5 -0
  37. data/lib/ruvim/window.rb +71 -0
  38. data/lib/ruvim.rb +30 -0
  39. data/sig/ruvim.rbs +4 -0
  40. data/test/app_completion_test.rb +39 -0
  41. data/test/app_dot_repeat_test.rb +54 -0
  42. data/test/app_motion_test.rb +73 -0
  43. data/test/app_register_test.rb +47 -0
  44. data/test/app_scenario_test.rb +77 -0
  45. data/test/app_startup_test.rb +199 -0
  46. data/test/app_text_object_test.rb +54 -0
  47. data/test/app_unicode_behavior_test.rb +66 -0
  48. data/test/buffer_test.rb +72 -0
  49. data/test/cli_test.rb +165 -0
  50. data/test/config_dsl_test.rb +78 -0
  51. data/test/dispatcher_test.rb +124 -0
  52. data/test/editor_mark_test.rb +69 -0
  53. data/test/editor_register_test.rb +64 -0
  54. data/test/fixtures/render_basic_snapshot.txt +8 -0
  55. data/test/fixtures/render_basic_snapshot_nonumber.txt +8 -0
  56. data/test/fixtures/render_unicode_scrolled_snapshot.txt +7 -0
  57. data/test/highlighter_test.rb +16 -0
  58. data/test/input_screen_integration_test.rb +69 -0
  59. data/test/keymap_manager_test.rb +48 -0
  60. data/test/render_snapshot_test.rb +70 -0
  61. data/test/screen_test.rb +123 -0
  62. data/test/search_option_test.rb +39 -0
  63. data/test/test_helper.rb +15 -0
  64. data/test/text_metrics_test.rb +42 -0
  65. data/test/window_test.rb +21 -0
  66. metadata +106 -0
@@ -0,0 +1,52 @@
1
+ module RuVim
2
+ module Highlighter
3
+ module_function
4
+
5
+ def color_columns(filetype, line)
6
+ ft = filetype.to_s
7
+ text = line.to_s
8
+ return {} if text.empty?
9
+
10
+ case ft
11
+ when "ruby"
12
+ ruby_color_columns(text)
13
+ when "json"
14
+ json_color_columns(text)
15
+ else
16
+ {}
17
+ end
18
+ end
19
+
20
+ def ruby_color_columns(text)
21
+ cols = {}
22
+ apply_regex(cols, text, /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/, "\e[32m")
23
+ apply_regex(cols, text, /\b(?:def|class|module|end|if|elsif|else|unless|case|when|do|while|until|begin|rescue|ensure|return|yield)\b/, "\e[36m")
24
+ apply_regex(cols, text, /\b\d+(?:\.\d+)?\b/, "\e[33m")
25
+ apply_regex(cols, text, /#.*\z/, "\e[90m", override: true)
26
+ cols
27
+ end
28
+
29
+ def json_color_columns(text)
30
+ cols = {}
31
+ apply_regex(cols, text, /"(?:\\.|[^"\\])*"\s*(?=:)/, "\e[36m")
32
+ apply_regex(cols, text, /"(?:\\.|[^"\\])*"/, "\e[32m")
33
+ apply_regex(cols, text, /\b(?:true|false|null)\b/, "\e[35m")
34
+ apply_regex(cols, text, /-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/, "\e[33m")
35
+ cols
36
+ end
37
+
38
+ def apply_regex(cols, text, regex, color, override: false)
39
+ text.to_enum(:scan, regex).each do
40
+ m = Regexp.last_match
41
+ next unless m
42
+ (m.begin(0)...m.end(0)).each do |idx|
43
+ next if cols.key?(idx) && !override
44
+
45
+ cols[idx] = color
46
+ end
47
+ end
48
+ end
49
+
50
+ module_function :ruby_color_columns, :json_color_columns, :apply_regex
51
+ end
52
+ end
@@ -0,0 +1,66 @@
1
+ module RuVim
2
+ class Input
3
+ def initialize(stdin: STDIN)
4
+ @stdin = stdin
5
+ end
6
+
7
+ def read_key(timeout: nil, wakeup_ios: [])
8
+ ios = [@stdin, *wakeup_ios].compact
9
+ readable = IO.select(ios, nil, nil, timeout)
10
+ return nil unless readable
11
+
12
+ ready = readable[0]
13
+ wakeups = ready - [@stdin]
14
+ wakeups.each { |io| drain_io(io) }
15
+ return nil unless ready.include?(@stdin)
16
+
17
+ ch = @stdin.getch
18
+ return :ctrl_c if ch == "\u0003"
19
+ return :ctrl_i if ch == "\u0009"
20
+ return :ctrl_n if ch == "\u000e"
21
+ return :ctrl_o if ch == "\u000f"
22
+ return :ctrl_p if ch == "\u0010"
23
+ return :ctrl_r if ch == "\u0012"
24
+ return :ctrl_v if ch == "\u0016"
25
+ return :ctrl_w if ch == "\u0017"
26
+ return :enter if ch == "\r" || ch == "\n"
27
+ return :backspace if ch == "\u007f" || ch == "\b"
28
+
29
+ return read_escape_sequence if ch == "\e"
30
+
31
+ ch
32
+ end
33
+
34
+ private
35
+
36
+ def drain_io(io)
37
+ loop do
38
+ io.read_nonblock(1024)
39
+ end
40
+ rescue IO::WaitReadable, EOFError
41
+ nil
42
+ end
43
+
44
+ def read_escape_sequence
45
+ extra = +""
46
+ begin
47
+ while IO.select([@stdin], nil, nil, 0.005)
48
+ extra << @stdin.read_nonblock(1)
49
+ end
50
+ rescue IO::WaitReadable, EOFError
51
+ end
52
+
53
+ case extra
54
+ when "" then :escape
55
+ when "[A" then :up
56
+ when "[B" then :down
57
+ when "[C" then :right
58
+ when "[D" then :left
59
+ when "[5~" then :pageup
60
+ when "[6~" then :pagedown
61
+ else
62
+ [:escape_sequence, extra]
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,96 @@
1
+ module RuVim
2
+ class KeymapManager
3
+ Match = Struct.new(:status, :invocation, keyword_init: true)
4
+
5
+ def initialize
6
+ @mode_maps = Hash.new { |h, k| h[k] = {} }
7
+ @global_map = {}
8
+ @buffer_maps = Hash.new { |h, k| h[k] = {} }
9
+ @filetype_maps = Hash.new { |h, k| h[k] = Hash.new { |hh, m| hh[m] = {} } }
10
+ end
11
+
12
+ def bind(mode, seq, id, argv: [], kwargs: {}, bang: false)
13
+ tokens = normalize_seq(seq)
14
+ @mode_maps[mode.to_sym][tokens] = build_invocation(id, argv:, kwargs:, bang:, tokens:)
15
+ end
16
+
17
+ def bind_global(seq, id, argv: [], kwargs: {}, bang: false)
18
+ tokens = normalize_seq(seq)
19
+ @global_map[tokens] = build_invocation(id, argv:, kwargs:, bang:, tokens:)
20
+ end
21
+
22
+ def bind_buffer(buffer_id, seq, id, argv: [], kwargs: {}, bang: false)
23
+ tokens = normalize_seq(seq)
24
+ @buffer_maps[buffer_id][tokens] = build_invocation(id, argv:, kwargs:, bang:, tokens:)
25
+ end
26
+
27
+ def bind_filetype(filetype, seq, id, mode: :normal, argv: [], kwargs: {}, bang: false)
28
+ tokens = normalize_seq(seq)
29
+ @filetype_maps[filetype.to_s][mode.to_sym][tokens] = build_invocation(id, argv:, kwargs:, bang:, tokens:)
30
+ end
31
+
32
+ def resolve(mode, pending_tokens)
33
+ resolve_layers([@mode_maps[mode.to_sym]], pending_tokens)
34
+ end
35
+
36
+ def resolve_with_context(mode, pending_tokens, editor:)
37
+ buffer = editor.current_buffer
38
+ filetype = detect_filetype(buffer)
39
+ layers = []
40
+ layers << @filetype_maps[filetype][mode.to_sym] if filetype && @filetype_maps.key?(filetype)
41
+ layers << @buffer_maps[buffer.id] if @buffer_maps.key?(buffer.id)
42
+ layers << @mode_maps[mode.to_sym]
43
+ layers << @global_map
44
+ resolve_layers(layers, pending_tokens)
45
+ end
46
+
47
+ private
48
+
49
+ def build_invocation(id, argv:, kwargs:, bang:, tokens:)
50
+ CommandInvocation.new(
51
+ id: id,
52
+ argv: argv,
53
+ kwargs: kwargs,
54
+ bang: bang,
55
+ raw_keys: tokens
56
+ )
57
+ end
58
+
59
+ def resolve_layers(layers, pending_tokens)
60
+ layers = layers.compact
61
+ return Match.new(status: :none) if layers.empty?
62
+
63
+ layers.each do |layer|
64
+ next if layer.empty?
65
+
66
+ if (exact = layer[pending_tokens])
67
+ longer = layer.keys.any? { |k| k.length > pending_tokens.length && k[0, pending_tokens.length] == pending_tokens }
68
+ return Match.new(status: (longer ? :ambiguous : :match), invocation: exact)
69
+ end
70
+ end
71
+
72
+ has_prefix = layers.any? { |layer| layer.keys.any? { |k| k[0, pending_tokens.length] == pending_tokens } }
73
+ Match.new(status: has_prefix ? :pending : :none)
74
+ end
75
+
76
+ def detect_filetype(buffer)
77
+ ft = buffer.options["filetype"] if buffer.respond_to?(:options)
78
+ return ft if ft && !ft.empty?
79
+
80
+ path = buffer.path.to_s
81
+ ext = File.extname(path)
82
+ return nil if ext.empty?
83
+
84
+ ext.delete_prefix(".")
85
+ end
86
+
87
+ def normalize_seq(seq)
88
+ case seq
89
+ when Array
90
+ seq.map(&:to_s).freeze
91
+ else
92
+ seq.to_s.each_char.map(&:to_s).freeze
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,452 @@
1
+ module RuVim
2
+ class Screen
3
+ DEFAULT_TABSTOP = 2
4
+ SYNTAX_CACHE_LIMIT = 2048
5
+ def initialize(terminal:)
6
+ @terminal = terminal
7
+ @last_frame = nil
8
+ @syntax_color_cache = {}
9
+ end
10
+
11
+ def invalidate_cache!
12
+ @last_frame = nil
13
+ end
14
+
15
+ def render(editor)
16
+ rows, cols = @terminal.winsize
17
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
18
+ text_rows = [text_rows, 1].max
19
+ text_cols = [text_cols, 1].max
20
+
21
+ rects = window_rects(editor, text_rows:, text_cols:)
22
+ editor.window_order.each do |win_id|
23
+ win = editor.windows.fetch(win_id)
24
+ buf = editor.buffers.fetch(win.buffer_id)
25
+ rect = rects[win_id]
26
+ next unless rect
27
+ 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
+ )
34
+ end
35
+
36
+ frame = build_frame(editor, rows:, cols:, text_rows:, text_cols:, rects:)
37
+ out = if can_diff_render?(frame)
38
+ render_diff(frame)
39
+ else
40
+ render_full(frame)
41
+ end
42
+ cursor_row, cursor_col = cursor_screen_position(editor, text_rows, rects)
43
+ out << "\e[#{cursor_row};#{cursor_col}H"
44
+ out << "\e[?25h"
45
+ @last_frame = frame.merge(cursor_row:, cursor_col:)
46
+ @terminal.write(out)
47
+ end
48
+
49
+ def current_window_view_height(editor)
50
+ rows, cols = @terminal.winsize
51
+ text_rows, text_cols = editor.text_viewport_size(rows:, cols:)
52
+ text_rows = [text_rows, 1].max
53
+ text_cols = [text_cols, 1].max
54
+ rect = window_rects(editor, text_rows:, text_cols:)[editor.current_window_id]
55
+ [rect ? rect[:height].to_i : text_rows, 1].max
56
+ rescue StandardError
57
+ 1
58
+ end
59
+
60
+ private
61
+
62
+ def build_frame(editor, rows:, cols:, text_rows:, text_cols:, rects:)
63
+ lines = {}
64
+ render_window_area(editor, lines, rects, text_rows:, text_cols:)
65
+
66
+ status_row = text_rows + 1
67
+ lines[status_row] = "\e[7m#{truncate(status_line(editor, cols), cols)}\e[m"
68
+
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)
74
+ end
75
+
76
+ {
77
+ rows: rows,
78
+ cols: cols,
79
+ lines: lines,
80
+ rects: rects
81
+ }
82
+ end
83
+
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:)
89
+ end
90
+ end
91
+
92
+ def render_horizontal_windows(editor, lines, rects, text_rows:, text_cols:)
93
+ 1.upto(text_rows) { |row_no| lines[row_no] = " " * text_cols }
94
+
95
+ editor.window_order.each do |win_id|
96
+ rect = rects[win_id]
97
+ next unless rect
98
+
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
113
+ end
114
+ end
115
+
116
+ rects.each_value do |rect|
117
+ next unless rect[:separator] == :horizontal
118
+ lines[rect[:sep_row]] = ("-" * text_cols)
119
+ end
120
+ end
121
+
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]
143
+ end
144
+ pieces << text
145
+ pieces << "|" if idx < editor.window_order.length - 1
146
+ end
147
+ lines[row_no] = pieces
148
+ end
149
+ end
150
+
151
+ def can_diff_render?(frame)
152
+ return false unless @last_frame
153
+ @last_frame[:rows] == frame[:rows] && @last_frame[:cols] == frame[:cols]
154
+ end
155
+
156
+ def render_full(frame)
157
+ out = +""
158
+ out << "\e[?25l"
159
+ out << "\e[H"
160
+ 1.upto(frame[:rows]) do |row_no|
161
+ out << "\e[2K"
162
+ out << (frame[:lines][row_no] || "")
163
+ out << "\r\n" unless row_no == frame[:rows]
164
+ end
165
+ out
166
+ end
167
+
168
+ def render_diff(frame)
169
+ out = +""
170
+ out << "\e[?25l"
171
+ max_rows = [frame[:rows], @last_frame[:rows]].max
172
+ 1.upto(max_rows) do |row_no|
173
+ new_line = frame[:lines][row_no] || ""
174
+ old_line = @last_frame[:lines][row_no] || ""
175
+ next if new_line == old_line
176
+
177
+ out << "\e[#{row_no};1H"
178
+ out << "\e[2K"
179
+ out << new_line
180
+ end
181
+ out
182
+ end
183
+
184
+ def render_text_line(text, editor, buffer_row:, window:, buffer:, width:)
185
+ tabstop = tabstop_for(editor, window, buffer)
186
+ cells, display_col = RuVim::TextMetrics.clip_cells_for_width(text, width, source_col_start: window.col_offset, tabstop:)
187
+ highlighted = +""
188
+ 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)
191
+
192
+ cells.each_with_index do |cell, idx|
193
+ ch = cell.glyph
194
+ buffer_col = cell.source_col
195
+ selected = selected_in_visual?(visual, buffer_row, buffer_col)
196
+ cursor_here = (editor.current_window_id == window.id && window.cursor_y == buffer_row && window.cursor_x == buffer_col)
197
+ if selected || cursor_here
198
+ highlighted << "\e[7m#{ch}\e[m"
199
+ elsif search_cols[buffer_col]
200
+ highlighted << "\e[43m#{ch}\e[m"
201
+ elsif (syntax_color = syntax_cols[buffer_col])
202
+ highlighted << "#{syntax_color}#{ch}\e[m"
203
+ else
204
+ highlighted << ch
205
+ end
206
+ end
207
+
208
+ 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"
212
+ display_col += 1
213
+ end
214
+ end
215
+
216
+ highlighted << (" " * [width - display_col, 0].max)
217
+ highlighted
218
+ end
219
+
220
+ def render_window_row(editor, window, buffer, buffer_row, gutter_w:, content_w:)
221
+ line = buffer.line_at(buffer_row)
222
+ line = line[window.col_offset..] || ""
223
+ prefix = line_number_prefix(editor, window, buffer, buffer_row, gutter_w)
224
+ body = render_text_line(line, editor, buffer_row:, window:, buffer:, width: content_w)
225
+ prefix + body
226
+ end
227
+
228
+ def tabstop_for(editor, window, buffer)
229
+ val = editor.effective_option("tabstop", window:, buffer:)
230
+ iv = val.to_i
231
+ iv.positive? ? iv : DEFAULT_TABSTOP
232
+ rescue StandardError
233
+ DEFAULT_TABSTOP
234
+ end
235
+
236
+ def number_column_width(editor, window, buffer)
237
+ enabled = editor.effective_option("number", window:, buffer:) || editor.effective_option("relativenumber", window:, buffer:)
238
+ return 0 unless enabled
239
+
240
+ [buffer.line_count.to_s.length, 1].max + 1
241
+ end
242
+
243
+ def line_number_prefix(editor, window, buffer, buffer_row, width)
244
+ return "" if width <= 0
245
+ show_abs = editor.effective_option("number", window:, buffer:)
246
+ show_rel = editor.effective_option("relativenumber", window:, buffer:)
247
+ return " " * width unless show_abs || show_rel
248
+ return " " * (width - 1) + " " if buffer_row.nil?
249
+
250
+ num =
251
+ if show_rel && buffer_row != window.cursor_y
252
+ (buffer_row - window.cursor_y).abs.to_s
253
+ elsif show_abs
254
+ (buffer_row + 1).to_s
255
+ else
256
+ "0"
257
+ end
258
+ num.rjust(width - 1) + " "
259
+ end
260
+
261
+ def pad_plain_display(text, width)
262
+ RuVim::TextMetrics.pad_plain_to_screen_width(text, width, tabstop: DEFAULT_TABSTOP)
263
+ end
264
+
265
+ def status_line(editor, width)
266
+ buffer = editor.current_buffer
267
+ window = editor.current_window
268
+ mode = case editor.mode
269
+ when :insert then "-- INSERT --"
270
+ when :command_line then "-- COMMAND --"
271
+ when :visual_char then "-- VISUAL --"
272
+ when :visual_line then "-- VISUAL LINE --"
273
+ when :visual_block then "-- VISUAL BLOCK --"
274
+ else "-- NORMAL --"
275
+ end
276
+
277
+ 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
+ mod = buffer.modified? ? " [+]" : ""
281
+ 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} "
287
+ body_width = [width - right.length, 0].max
288
+ "#{compose_status_body(left, msg, body_width)}#{right}"
289
+ end
290
+
291
+ def compose_status_body(left, msg, width)
292
+ w = [width.to_i, 0].max
293
+ return "" if w.zero?
294
+ return left.ljust(w)[0, w] if msg.to_s.empty?
295
+
296
+ msg_part = " | #{msg}"
297
+ if msg_part.length >= w
298
+ return msg_part[0, w]
299
+ end
300
+
301
+ left_budget = w - msg_part.length
302
+ "#{left.ljust(left_budget)[0, left_budget]}#{msg_part}"
303
+ end
304
+
305
+ def truncate(str, width)
306
+ str.to_s.ljust(width)[0, width]
307
+ end
308
+
309
+ def error_message_line(msg, cols)
310
+ "\e[97;41m#{truncate(msg, cols)}\e[m"
311
+ end
312
+
313
+ def cursor_screen_position(editor, text_rows, rects)
314
+ window = editor.current_window
315
+
316
+ if editor.command_line_active?
317
+ row = text_rows + 2
318
+ col = 1 + editor.command_line.prefix.length + editor.command_line.cursor
319
+ return [row, col]
320
+ end
321
+
322
+ 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
330
+ [row, col]
331
+ end
332
+
333
+ def window_rects(editor, text_rows:, text_cols:)
334
+ ids = editor.window_order
335
+ 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|
345
+ w = widths[i]
346
+ rects[id] = { top: 1, left: left, height: text_rows, width: w, separator: :vertical }
347
+ left += w + 1
348
+ 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|
357
+ 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
363
+ end
364
+ end
365
+ rects
366
+ end
367
+ end
368
+
369
+ def split_sizes(total, n)
370
+ base = total / n
371
+ rem = total % n
372
+ Array.new(n) { |i| base + (i < rem ? 1 : 0) }
373
+ end
374
+
375
+ def selected_in_visual?(visual, row, col)
376
+ return false unless visual
377
+
378
+ if visual[:mode] == :linewise
379
+ row >= visual[:start_row] && row <= visual[:end_row]
380
+ elsif visual[:mode] == :blockwise
381
+ row >= visual[:start_row] && row <= visual[:end_row] &&
382
+ col >= visual[:start_col] && col < visual[:end_col]
383
+ else
384
+ return false if row < visual[:start_row] || row > visual[:end_row]
385
+ if visual[:start_row] == visual[:end_row]
386
+ col >= visual[:start_col] && col < visual[:end_col]
387
+ elsif row == visual[:start_row]
388
+ col >= visual[:start_col]
389
+ elsif row == visual[:end_row]
390
+ col < visual[:end_col]
391
+ else
392
+ true
393
+ end
394
+ end
395
+ end
396
+
397
+ def search_highlight_source_cols(editor, source_line_text, source_col_offset:)
398
+ search = editor.last_search
399
+ return {} unless search && search[:pattern]
400
+ return {} unless editor.effective_option("hlsearch")
401
+
402
+ regex = build_screen_search_regex(editor, search[:pattern])
403
+ cols = {}
404
+ offset = 0
405
+ while (m = regex.match(source_line_text, offset))
406
+ from = m.begin(0)
407
+ to = [m.end(0), from + 1].max
408
+ (from...to).each { |i| cols[source_col_offset + i] = true }
409
+ offset = to
410
+ break if offset > source_line_text.length
411
+ end
412
+ cols
413
+ rescue RegexpError
414
+ {}
415
+ end
416
+
417
+ def build_screen_search_regex(editor, pattern)
418
+ ignorecase = !!editor.effective_option("ignorecase")
419
+ smartcase = !!editor.effective_option("smartcase")
420
+ flags = if ignorecase && !(smartcase && pattern.to_s.match?(/[A-Z]/))
421
+ Regexp::IGNORECASE
422
+ else
423
+ 0
424
+ end
425
+ Regexp.new(pattern.to_s, flags)
426
+ end
427
+
428
+ def syntax_highlight_source_cols(editor, window, buffer, source_line_text, source_col_offset:)
429
+ filetype = editor.effective_option("filetype", buffer:, window:)
430
+ rel = cached_syntax_color_columns(filetype, source_line_text)
431
+ return {} if rel.empty?
432
+
433
+ rel.each_with_object({}) do |(idx, color), h|
434
+ h[source_col_offset + idx] = color
435
+ end
436
+ rescue StandardError
437
+ {}
438
+ end
439
+
440
+ def cached_syntax_color_columns(filetype, source_line_text)
441
+ key = [filetype.to_s, source_line_text.to_s]
442
+ if (cached = @syntax_color_cache[key])
443
+ return cached
444
+ end
445
+
446
+ cols = RuVim::Highlighter.color_columns(filetype, source_line_text)
447
+ @syntax_color_cache[key] = cols
448
+ @syntax_color_cache.shift while @syntax_color_cache.length > SYNTAX_CACHE_LIMIT
449
+ cols
450
+ end
451
+ end
452
+ end
@@ -0,0 +1,30 @@
1
+ require "io/console"
2
+
3
+ module RuVim
4
+ class Terminal
5
+ def initialize(stdin: STDIN, stdout: STDOUT)
6
+ @stdin = stdin
7
+ @stdout = stdout
8
+ end
9
+
10
+ def winsize
11
+ IO.console.winsize
12
+ rescue StandardError
13
+ [24, 80]
14
+ end
15
+
16
+ def write(str)
17
+ @stdout.write(str)
18
+ @stdout.flush
19
+ end
20
+
21
+ def with_ui
22
+ @stdin.raw do
23
+ write("\e[?1049h\e[?25l")
24
+ yield
25
+ ensure
26
+ write("\e[?25h\e[?1049l")
27
+ end
28
+ end
29
+ end
30
+ end