ruvim 0.4.0 → 0.6.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 (113) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +53 -4
  3. data/README.md +15 -6
  4. data/Rakefile +7 -0
  5. data/benchmark/cext_compare.rb +165 -0
  6. data/benchmark/chunked_load.rb +256 -0
  7. data/benchmark/file_load.rb +140 -0
  8. data/benchmark/hotspots.rb +178 -0
  9. data/docs/binding.md +3 -2
  10. data/docs/command.md +81 -9
  11. data/docs/done.md +23 -0
  12. data/docs/spec.md +105 -19
  13. data/docs/todo.md +9 -0
  14. data/docs/tutorial.md +9 -1
  15. data/docs/vim_diff.md +13 -0
  16. data/ext/ruvim/extconf.rb +5 -0
  17. data/ext/ruvim/ruvim_ext.c +519 -0
  18. data/lib/ruvim/app.rb +217 -2778
  19. data/lib/ruvim/browser.rb +104 -0
  20. data/lib/ruvim/buffer.rb +39 -28
  21. data/lib/ruvim/command_invocation.rb +2 -2
  22. data/lib/ruvim/completion_manager.rb +708 -0
  23. data/lib/ruvim/dispatcher.rb +14 -8
  24. data/lib/ruvim/display_width.rb +91 -45
  25. data/lib/ruvim/editor.rb +64 -81
  26. data/lib/ruvim/ex_command_registry.rb +3 -1
  27. data/lib/ruvim/gh/link.rb +207 -0
  28. data/lib/ruvim/git/blame.rb +16 -6
  29. data/lib/ruvim/git/branch.rb +20 -5
  30. data/lib/ruvim/git/grep.rb +107 -0
  31. data/lib/ruvim/git/handler.rb +42 -1
  32. data/lib/ruvim/global_commands.rb +175 -35
  33. data/lib/ruvim/highlighter.rb +4 -13
  34. data/lib/ruvim/key_handler.rb +1510 -0
  35. data/lib/ruvim/keymap_manager.rb +7 -7
  36. data/lib/ruvim/lang/base.rb +5 -0
  37. data/lib/ruvim/lang/c.rb +116 -0
  38. data/lib/ruvim/lang/cpp.rb +107 -0
  39. data/lib/ruvim/lang/csv.rb +4 -1
  40. data/lib/ruvim/lang/diff.rb +2 -0
  41. data/lib/ruvim/lang/dockerfile.rb +36 -0
  42. data/lib/ruvim/lang/elixir.rb +85 -0
  43. data/lib/ruvim/lang/erb.rb +30 -0
  44. data/lib/ruvim/lang/go.rb +83 -0
  45. data/lib/ruvim/lang/html.rb +34 -0
  46. data/lib/ruvim/lang/javascript.rb +83 -0
  47. data/lib/ruvim/lang/json.rb +6 -0
  48. data/lib/ruvim/lang/lua.rb +76 -0
  49. data/lib/ruvim/lang/makefile.rb +36 -0
  50. data/lib/ruvim/lang/markdown.rb +3 -4
  51. data/lib/ruvim/lang/ocaml.rb +77 -0
  52. data/lib/ruvim/lang/perl.rb +91 -0
  53. data/lib/ruvim/lang/python.rb +85 -0
  54. data/lib/ruvim/lang/registry.rb +102 -0
  55. data/lib/ruvim/lang/ruby.rb +7 -0
  56. data/lib/ruvim/lang/rust.rb +95 -0
  57. data/lib/ruvim/lang/scheme.rb +5 -0
  58. data/lib/ruvim/lang/sh.rb +76 -0
  59. data/lib/ruvim/lang/sql.rb +52 -0
  60. data/lib/ruvim/lang/toml.rb +36 -0
  61. data/lib/ruvim/lang/tsv.rb +4 -1
  62. data/lib/ruvim/lang/typescript.rb +53 -0
  63. data/lib/ruvim/lang/yaml.rb +62 -0
  64. data/lib/ruvim/rich_view/table_renderer.rb +3 -3
  65. data/lib/ruvim/rich_view.rb +14 -7
  66. data/lib/ruvim/screen.rb +126 -72
  67. data/lib/ruvim/stream/file_load.rb +85 -0
  68. data/lib/ruvim/stream/follow.rb +40 -0
  69. data/lib/ruvim/stream/git.rb +43 -0
  70. data/lib/ruvim/stream/run.rb +74 -0
  71. data/lib/ruvim/stream/stdin.rb +55 -0
  72. data/lib/ruvim/stream.rb +35 -0
  73. data/lib/ruvim/stream_mixer.rb +394 -0
  74. data/lib/ruvim/terminal.rb +18 -4
  75. data/lib/ruvim/text_metrics.rb +84 -65
  76. data/lib/ruvim/version.rb +1 -1
  77. data/lib/ruvim/window.rb +5 -5
  78. data/lib/ruvim.rb +23 -6
  79. data/test/app_command_test.rb +382 -0
  80. data/test/app_completion_test.rb +43 -19
  81. data/test/app_dot_repeat_test.rb +27 -3
  82. data/test/app_ex_command_test.rb +154 -0
  83. data/test/app_motion_test.rb +13 -12
  84. data/test/app_register_test.rb +2 -1
  85. data/test/app_scenario_test.rb +15 -10
  86. data/test/app_startup_test.rb +70 -27
  87. data/test/app_text_object_test.rb +2 -1
  88. data/test/app_unicode_behavior_test.rb +3 -2
  89. data/test/browser_test.rb +88 -0
  90. data/test/buffer_test.rb +24 -0
  91. data/test/cli_test.rb +63 -0
  92. data/test/command_invocation_test.rb +33 -0
  93. data/test/config_dsl_test.rb +47 -0
  94. data/test/dispatcher_test.rb +74 -4
  95. data/test/ex_command_registry_test.rb +106 -0
  96. data/test/follow_test.rb +20 -21
  97. data/test/gh_link_test.rb +141 -0
  98. data/test/git_blame_test.rb +96 -17
  99. data/test/git_grep_test.rb +64 -0
  100. data/test/highlighter_test.rb +125 -0
  101. data/test/indent_test.rb +137 -0
  102. data/test/input_screen_integration_test.rb +1 -1
  103. data/test/keyword_chars_test.rb +85 -0
  104. data/test/lang_test.rb +634 -0
  105. data/test/markdown_renderer_test.rb +5 -5
  106. data/test/on_save_hook_test.rb +12 -8
  107. data/test/render_snapshot_test.rb +78 -0
  108. data/test/rich_view_test.rb +42 -42
  109. data/test/run_command_test.rb +307 -0
  110. data/test/screen_test.rb +68 -5
  111. data/test/stream_test.rb +165 -0
  112. data/test/window_test.rb +59 -0
  113. metadata +52 -2
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ class Stream
5
+ attr_accessor :state
6
+ attr_reader :stop_handler
7
+
8
+ def initialize(stop_handler: nil)
9
+ @state = nil
10
+ @stop_handler = stop_handler
11
+ end
12
+
13
+ def live?
14
+ @state == :live
15
+ end
16
+
17
+ def status
18
+ nil
19
+ end
20
+
21
+ def command
22
+ nil
23
+ end
24
+
25
+ def stop!
26
+ # subclasses override
27
+ end
28
+ end
29
+ end
30
+
31
+ require_relative "stream/stdin"
32
+ require_relative "stream/run"
33
+ require_relative "stream/follow"
34
+ require_relative "stream/file_load"
35
+ require_relative "stream/git"
@@ -0,0 +1,394 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuVim
4
+ class StreamMixer
5
+ LARGE_FILE_ASYNC_THRESHOLD_BYTES = 64 * 1024 * 1024
6
+ LARGE_FILE_STAGED_PREFIX_BYTES = 8 * 1024 * 1024
7
+
8
+ def initialize(editor:, signal_w:)
9
+ @editor = editor
10
+ @signal_w = signal_w
11
+ @stream_event_queue = nil
12
+ end
13
+
14
+ def prepare_stdin_stream_buffer!(io)
15
+ buf = @editor.current_buffer
16
+ if buf.intro_buffer?
17
+ @editor.materialize_intro_buffer!
18
+ buf = @editor.current_buffer
19
+ end
20
+
21
+ buf.replace_all_lines!([""])
22
+ buf.configure_special!(kind: :stream, name: "[stdin]", readonly: true, modifiable: false)
23
+ buf.modified = false
24
+ buf.options["filetype"] = "text"
25
+ ensure_event_queue!
26
+ move_window_to_stream_end!(@editor.current_window, buf)
27
+ @editor.echo("[stdin] follow")
28
+ @pending_stdin = { buf: buf, io: io }
29
+ buf
30
+ end
31
+
32
+ def start_pending_stdin!
33
+ return unless @pending_stdin
34
+
35
+ ps = @pending_stdin
36
+ @pending_stdin = nil
37
+ buf = ps[:buf]
38
+ ensure_event_queue!
39
+ buf.stream = Stream::Stdin.new(
40
+ io: ps[:io], buffer_id: buf.id, queue: @stream_event_queue,
41
+ stop_handler: -> { stop_buffer_stream!(buf) }, &method(:notify_signal_wakeup)
42
+ )
43
+ end
44
+
45
+ def start_command_stream!(buf, command)
46
+ ensure_event_queue!
47
+ buf.stream = Stream::Run.new(
48
+ command: command, buffer_id: buf.id, queue: @stream_event_queue,
49
+ stop_handler: -> { stop_buffer_stream!(buf) }, &method(:notify_signal_wakeup)
50
+ )
51
+ end
52
+
53
+ def start_git_stream_command(buffer_id, cmd, root)
54
+ ensure_event_queue!
55
+ buf = @editor.buffers[buffer_id]
56
+ return unless buf
57
+
58
+ buf.stream = Stream::Git.new(cmd: cmd, root: root, buffer_id: buffer_id, queue: @stream_event_queue, &method(:notify_signal_wakeup))
59
+ end
60
+
61
+ def stop_buffer_stream!(buf)
62
+ return false unless buf&.stream&.live?
63
+
64
+ buf.stream.stop!
65
+ @editor.echo("#{buf.display_name} stopped")
66
+ notify_signal_wakeup
67
+ true
68
+ end
69
+
70
+ def stop_git_stream!(buffer_id)
71
+ buf = @editor.buffers[buffer_id]
72
+ buf&.stream&.stop!
73
+ end
74
+
75
+ def drain_events!
76
+ return false unless @stream_event_queue
77
+
78
+ changed = false
79
+ loop do
80
+ event = @stream_event_queue.pop(true)
81
+ case event[:type]
82
+ when :stream_data
83
+ changed = apply_stream_chunk!(event[:buffer_id], event[:data]) || changed
84
+ when :stream_eof
85
+ changed = finish_stream!(event[:buffer_id], status: event[:status]) || changed
86
+ when :stream_error
87
+ changed = fail_stream!(event[:buffer_id], event[:error]) || changed
88
+ when :follow_data
89
+ changed = apply_stream_chunk!(event[:buffer_id], event[:data]) || changed
90
+ when :follow_truncated
91
+ if (buf = @editor.buffers[event[:buffer_id]])
92
+ @editor.echo("[follow] file truncated: #{buf.display_name}")
93
+ changed = true
94
+ end
95
+ when :follow_deleted
96
+ if (buf = @editor.buffers[event[:buffer_id]])
97
+ @editor.echo("[follow] file deleted, waiting for re-creation: #{buf.display_name}")
98
+ changed = true
99
+ end
100
+ when :file_lines
101
+ changed = apply_async_file_lines!(event[:buffer_id], event[:head], event[:lines], loaded_bytes: event[:loaded_bytes], file_size: event[:file_size]) || changed
102
+ when :file_eof
103
+ changed = finish_async_file_load!(event[:buffer_id], ended_with_newline: event[:ended_with_newline]) || changed
104
+ when :file_error
105
+ changed = fail_async_file_load!(event[:buffer_id], event[:error]) || changed
106
+ end
107
+ end
108
+ rescue ThreadError
109
+ changed
110
+ end
111
+
112
+ def ex_follow_toggle
113
+ buf = @editor.current_buffer
114
+ raise RuVim::CommandError, "No file associated with buffer" unless buf.path
115
+
116
+ if buf.stream.is_a?(Stream::Follow)
117
+ stop_follow!(buf)
118
+ else
119
+ raise RuVim::CommandError, "Buffer has unsaved changes" if buf.modified?
120
+ start_follow!(buf)
121
+ end
122
+ end
123
+
124
+ def start_follow!(buf)
125
+ ensure_event_queue!
126
+ Buffer.ensure_regular_file!(buf.path) if buf.path
127
+
128
+ if buf.path && File.file?(buf.path)
129
+ data = File.binread(buf.path)
130
+ if data.end_with?("\n") && buf.lines.last.to_s != ""
131
+ following_wins = @editor.windows.values.select do |w|
132
+ w.buffer_id == buf.id && stream_window_following_end?(w, buf)
133
+ end
134
+ buf.append_stream_text!("\n")
135
+ following_wins.each { |w| move_window_to_stream_end!(w, buf) }
136
+ end
137
+ end
138
+
139
+ buf.stream = Stream::Follow.new(
140
+ path: buf.path, buffer_id: buf.id, queue: @stream_event_queue,
141
+ stop_handler: -> { stop_follow!(buf) }, &method(:notify_signal_wakeup)
142
+ )
143
+ @editor.echo("[follow] #{buf.display_name}")
144
+ end
145
+
146
+ def stop_follow!(buf)
147
+ buf.stream&.stop!
148
+ # Remove trailing empty line added as sentinel by start_follow!
149
+ if buf.line_count > 1 && buf.lines.last.to_s == ""
150
+ buf.lines.pop
151
+ last = buf.line_count - 1
152
+ @editor.windows.each_value do |win|
153
+ next unless win.buffer_id == buf.id
154
+ win.cursor_y = last if win.cursor_y > last
155
+ end
156
+ end
157
+ buf.stream = nil
158
+ @editor.echo("[follow] stopped")
159
+ true
160
+ end
161
+
162
+ def follow_active?(buf)
163
+ buf.stream.is_a?(Stream::Follow)
164
+ end
165
+
166
+ def open_path_with_large_file_support(path)
167
+ return @editor.open_path_sync(path) unless should_open_path_async?(path)
168
+ return @editor.open_path_sync(path) unless can_start_async_file_load?
169
+
170
+ open_path_asynchronously!(path)
171
+ end
172
+
173
+ def shutdown!
174
+ @editor.buffers.each_value do |buf|
175
+ buf.stream&.stop!
176
+ rescue StandardError
177
+ nil
178
+ end
179
+ end
180
+
181
+ def ensure_event_queue!
182
+ @stream_event_queue ||= Queue.new
183
+ end
184
+
185
+ private
186
+
187
+ def apply_stream_chunk!(buffer_id, text)
188
+ return false if text.to_s.empty?
189
+
190
+ buf = @editor.buffers[buffer_id]
191
+ return false unless buf
192
+
193
+ following_win_ids = @editor.windows.values.filter_map do |win|
194
+ next unless win.buffer_id == buf.id
195
+ next unless stream_window_following_end?(win, buf)
196
+ win.id
197
+ end
198
+
199
+ buf.append_stream_text!(text)
200
+
201
+ following_win_ids.each do |win_id|
202
+ win = @editor.windows[win_id]
203
+ move_window_to_stream_end!(win, buf) if win
204
+ end
205
+
206
+ true
207
+ end
208
+
209
+ def finish_stream!(buffer_id, status: nil)
210
+ buf = @editor.buffers[buffer_id]
211
+ return false unless buf&.stream
212
+
213
+ stream = buf.stream
214
+ stream.thread = nil if stream.respond_to?(:thread=)
215
+ stream.io = nil if stream.respond_to?(:io=)
216
+
217
+ # Remove trailing empty line if present
218
+ if buf.lines.length > 1 && buf.lines[-1] == ""
219
+ buf.lines.pop
220
+ end
221
+ stream.state = :closed
222
+ stream.exit_status = status if stream.respond_to?(:exit_status=)
223
+
224
+ if status
225
+ @editor.echo("#{buf.display_name} exit #{status.exitstatus}")
226
+ elsif buf.kind == :stream
227
+ @editor.echo("[stdin] EOF")
228
+ else
229
+ @editor.echo("#{buf.display_name} #{buf.line_count} lines")
230
+ end
231
+ true
232
+ end
233
+
234
+ def fail_stream!(buffer_id, error)
235
+ buf = @editor.buffers[buffer_id]
236
+ return false unless buf&.stream
237
+
238
+ # Ignore errors from intentionally closed streams
239
+ if buf.stream.state == :closed
240
+ msg = error.to_s.downcase
241
+ return false if msg.include?("stream closed") || msg.include?("closed in another thread")
242
+ end
243
+
244
+ stream = buf.stream
245
+ stream.thread = nil if stream.respond_to?(:thread=)
246
+ stream.io = nil if stream.respond_to?(:io=)
247
+ stream.state = :error
248
+ @editor.echo_error("#{buf.display_name} stream error: #{error}")
249
+ true
250
+ end
251
+
252
+ def apply_async_file_lines!(buffer_id, head, lines, loaded_bytes: nil, file_size: nil)
253
+ buf = @editor.buffers[buffer_id]
254
+ return false unless buf
255
+
256
+ following_win_ids = if buf.stream.is_a?(Stream::Follow)
257
+ @editor.windows.values.filter_map do |win|
258
+ next unless win.buffer_id == buffer_id
259
+ next unless stream_window_following_end?(win, buf)
260
+ win.id
261
+ end
262
+ end
263
+
264
+ buf.append_stream_lines!(head, lines)
265
+
266
+ following_win_ids&.each do |win_id|
267
+ win = @editor.windows[win_id]
268
+ move_window_to_stream_end!(win, buf) if win
269
+ end
270
+
271
+ if loaded_bytes && file_size && file_size > 0
272
+ pct = (loaded_bytes * 100.0 / file_size).clamp(0, 100)
273
+ @editor.echo(format("\"%s\" loading... %d%%", buf.display_name, pct))
274
+ end
275
+
276
+ true
277
+ end
278
+
279
+ def finish_async_file_load!(buffer_id, ended_with_newline:)
280
+ buf = @editor.buffers[buffer_id]
281
+ return false unless buf
282
+
283
+ buf.finalize_async_file_load!(ended_with_newline: !!ended_with_newline)
284
+ buf.stream.state = :closed if buf.stream
285
+ @editor.echo(format("\"%s\" %dL", buf.display_name, buf.line_count))
286
+ true
287
+ end
288
+
289
+ def fail_async_file_load!(buffer_id, error)
290
+ buf = @editor.buffers[buffer_id]
291
+ if buf&.stream
292
+ buf.stream.state = :error
293
+ end
294
+ @editor.echo_error("\"#{(buf && buf.display_name) || buffer_id}\" load error: #{error}")
295
+ true
296
+ end
297
+
298
+ def stream_window_following_end?(win, buf)
299
+ return false unless win
300
+
301
+ last_row = buf.line_count - 1
302
+ win.cursor_y >= last_row
303
+ end
304
+
305
+ def move_window_to_stream_end!(win, buf)
306
+ return unless win && buf
307
+
308
+ last_row = buf.line_count - 1
309
+ win.cursor_y = last_row
310
+ win.cursor_x = buf.line_length(last_row)
311
+ win.clamp_to_buffer(buf)
312
+ end
313
+
314
+ def should_open_path_async?(path)
315
+ p = path.to_s
316
+ return false if p.empty?
317
+ return false unless File.file?(p)
318
+
319
+ File.size(p) >= large_file_async_threshold_bytes
320
+ rescue StandardError
321
+ false
322
+ end
323
+
324
+ def can_start_async_file_load?
325
+ @editor.buffers.none? { |_, buf| buf.stream.is_a?(Stream::FileLoad) && buf.stream.live? }
326
+ end
327
+
328
+ def large_file_async_threshold_bytes
329
+ raw = ENV["RUVIM_ASYNC_FILE_THRESHOLD_BYTES"]
330
+ n = raw.to_i if raw
331
+ return n if n && n.positive?
332
+
333
+ LARGE_FILE_ASYNC_THRESHOLD_BYTES
334
+ end
335
+
336
+ def open_path_asynchronously!(path)
337
+ file_size = File.size(path)
338
+ buf = @editor.add_empty_buffer(path: path)
339
+ @editor.switch_to_buffer(buf.id)
340
+ buf.modified = false
341
+
342
+ ensure_event_queue!
343
+ io = File.open(path, "rb")
344
+ staged_prefix_bytes = async_file_staged_prefix_bytes
345
+ staged_mode = file_size > staged_prefix_bytes
346
+ if staged_mode
347
+ prefix = io.read(staged_prefix_bytes) || "".b
348
+ unless prefix.empty?
349
+ last_nl = prefix.rindex("\n".b)
350
+ if last_nl && last_nl < prefix.bytesize - 1
351
+ remainder = prefix.bytesize - last_nl - 1
352
+ prefix = prefix[0..last_nl]
353
+ io.seek(-remainder, IO::SEEK_CUR)
354
+ end
355
+ buf.append_stream_text!(Buffer.decode_text(prefix))
356
+ end
357
+ end
358
+
359
+ if io.eof?
360
+ buf.finalize_async_file_load!(ended_with_newline: prefix&.end_with?("\n") || false)
361
+ io.close unless io.closed?
362
+ return buf
363
+ end
364
+
365
+ # Create FileLoad stream after prefix reading; starts background thread immediately
366
+ buf.stream = Stream::FileLoad.new(io: io, file_size: file_size, buffer_id: buf.id, queue: @stream_event_queue, &method(:notify_signal_wakeup))
367
+
368
+ size_mb = file_size.fdiv(1024 * 1024)
369
+ if staged_mode
370
+ @editor.echo(format("\"%s\" loading... (showing first %.0fMB of %.1fMB)", path, staged_prefix_bytes.fdiv(1024 * 1024), size_mb))
371
+ else
372
+ @editor.echo(format("\"%s\" loading... (%.1fMB)", path, size_mb))
373
+ end
374
+ buf
375
+ rescue StandardError
376
+ buf.stream = nil if buf
377
+ raise
378
+ end
379
+
380
+ def async_file_staged_prefix_bytes
381
+ raw = ENV["RUVIM_ASYNC_FILE_PREFIX_BYTES"]
382
+ n = raw.to_i if raw
383
+ return n if n && n.positive?
384
+
385
+ LARGE_FILE_STAGED_PREFIX_BYTES
386
+ end
387
+
388
+ def notify_signal_wakeup
389
+ @signal_w.write_nonblock(".")
390
+ rescue IO::WaitWritable, Errno::EPIPE
391
+ nil
392
+ end
393
+ end
394
+ end
@@ -22,17 +22,31 @@ module RuVim
22
22
 
23
23
  def with_ui
24
24
  @stdin.raw do
25
- write("\e]112\a\e[?1049h\e[?25l")
25
+ write("\e]112\a\e[2 q\e[?1049h\e[?25l")
26
26
  yield
27
27
  ensure
28
- write("\e[?25h\e[?1049l")
28
+ write("\e[0 q\e[?25h\e[?1049l")
29
+ end
30
+ end
31
+
32
+ def suspend_for_shell(command)
33
+ shell = ENV["SHELL"].to_s
34
+ shell = "/bin/sh" if shell.empty?
35
+ @stdin.cooked do
36
+ write("\e[0 q\e[?25h\e[?1049l")
37
+ system(shell, "-c", command)
38
+ status = $?
39
+ write("\r\nPress ENTER or type command to continue")
40
+ @stdin.raw { @stdin.getc }
41
+ write("\e[2 q\e[?1049h\e[?25l")
42
+ status
29
43
  end
30
44
  end
31
45
 
32
46
  def suspend_for_tstp
33
47
  prev_tstp = Signal.trap("TSTP", "DEFAULT")
34
48
  @stdin.cooked do
35
- write("\e[?25h\e[?1049l")
49
+ write("\e[0 q\e[?25h\e[?1049l")
36
50
  Process.kill("TSTP", 0)
37
51
  end
38
52
  ensure
@@ -41,7 +55,7 @@ module RuVim
41
55
  rescue StandardError
42
56
  nil
43
57
  end
44
- write("\e[?1049h\e[?25l")
58
+ write("\e[2 q\e[?1049h\e[?25l")
45
59
  end
46
60
  end
47
61
  end
@@ -4,15 +4,21 @@ module RuVim
4
4
  module TextMetrics
5
5
  module_function
6
6
 
7
- Cell = Struct.new(:glyph, :source_col, :display_width, keyword_init: true)
7
+ class Cell
8
+ attr_reader :glyph, :source_col, :display_width
9
+
10
+ def initialize(glyph, source_col, display_width)
11
+ @glyph = glyph
12
+ @source_col = source_col
13
+ @display_width = display_width
14
+ end
15
+ end
8
16
 
9
- # Cursor positions in RuVim are currently "character index" (Ruby String#[] index on UTF-8),
10
- # not byte offsets. Grapheme-aware movement is layered on top of that.
11
17
  def previous_grapheme_char_index(line, char_index)
12
- idx = [char_index.to_i, 0].max
18
+ idx = [char_index, 0].max
13
19
  return 0 if idx <= 0
14
20
 
15
- left = line.to_s[0...idx].to_s
21
+ left = line[0...idx].to_s
16
22
  clusters = left.scan(/\X/)
17
23
  return 0 if clusters.empty?
18
24
 
@@ -21,7 +27,7 @@ module RuVim
21
27
 
22
28
  def next_grapheme_char_index(line, char_index)
23
29
  s = line.to_s
24
- idx = [[char_index.to_i, 0].max, s.length].min
30
+ idx = [[char_index, 0].max, s.length].min
25
31
  return s.length if idx >= s.length
26
32
 
27
33
  rest = s[idx..].to_s
@@ -32,93 +38,106 @@ module RuVim
32
38
  end
33
39
 
34
40
  def screen_col_for_char_index(line, char_index, tabstop: 2)
35
- idx = [char_index.to_i, 0].max
36
- prefix = line.to_s[0...idx].to_s
41
+ idx = [char_index, 0].max
42
+ prefix = line[0...idx].to_s
37
43
  RuVim::DisplayWidth.display_width(prefix, tabstop:)
38
44
  end
39
45
 
40
- # Returns a character index whose screen column is <= target_screen_col,
41
- # aligned to a grapheme-cluster boundary.
42
- def char_index_for_screen_col(line, target_screen_col, tabstop: 2, align: :floor)
43
- s = line.to_s
44
- target = [target_screen_col.to_i, 0].max
45
- screen_col = 0
46
- char_index = 0
47
-
48
- s.scan(/\X/).each do |cluster|
49
- width = RuVim::DisplayWidth.display_width(cluster, tabstop:, start_col: screen_col)
50
- if screen_col + width > target
51
- return align == :ceil ? (char_index + cluster.length) : char_index
52
- end
46
+ if defined?(RuVim::TextMetricsExt)
47
+ # ---- C extension paths ----
53
48
 
54
- screen_col += width
55
- char_index += cluster.length
49
+ def clip_cells_for_width(text, width, source_col_start: 0, tabstop: 2)
50
+ TextMetricsExt.clip_cells_for_width(text, width, source_col_start:, tabstop:)
56
51
  end
57
52
 
58
- char_index
59
- end
53
+ def char_index_for_screen_col(line, target_screen_col, tabstop: 2, align: :floor)
54
+ TextMetricsExt.char_index_for_screen_col(line, target_screen_col, tabstop:, align:)
55
+ end
56
+ else
57
+ # ---- Pure Ruby fallback ----
58
+
59
+ def char_index_for_screen_col(line, target_screen_col, tabstop: 2, align: :floor)
60
+ s = line.to_s
61
+ target = [target_screen_col, 0].max
62
+ screen_col = 0
63
+ char_index = 0
64
+
65
+ s.scan(/\X/).each do |cluster|
66
+ width = RuVim::DisplayWidth.display_width(cluster, tabstop:, start_col: screen_col)
67
+ if screen_col + width > target
68
+ return align == :ceil ? (char_index + cluster.length) : char_index
69
+ end
60
70
 
61
- def clip_cells_for_width(text, width, source_col_start: 0, tabstop: 2)
62
- max_width = [width.to_i, 0].max
63
- cells = []
64
- display_col = 0
65
- source_col = source_col_start.to_i
71
+ screen_col += width
72
+ char_index += cluster.length
73
+ end
66
74
 
67
- text.to_s.each_char do |ch|
68
- if ch == "\t"
69
- w = RuVim::DisplayWidth.cell_width(ch, col: display_col, tabstop:)
70
- break if display_col + w > max_width
75
+ char_index
76
+ end
71
77
 
72
- w.times do
73
- cells << Cell.new(glyph: " ", source_col:, display_width: 1)
78
+ def clip_cells_for_width(text, width, source_col_start: 0, tabstop: 2)
79
+ max_width = [width, 0].max
80
+ cells = []
81
+ display_col = 0
82
+ source_col = source_col_start
83
+
84
+ text.to_s.each_char do |ch|
85
+ code = ch.ord
86
+ # Fast path: printable ASCII (0x20..0x7E) — width 1, no special handling
87
+ if code >= 0x20 && code <= 0x7E
88
+ break if display_col >= max_width
89
+ cells << Cell.new(ch, source_col, 1)
90
+ display_col += 1
91
+ source_col += 1
92
+ next
93
+ end
94
+
95
+ if ch == "\t"
96
+ w = tabstop - (display_col % tabstop)
97
+ w = tabstop if w.zero?
98
+ break if display_col + w > max_width
99
+
100
+ w.times do
101
+ cells << Cell.new(" ", source_col, 1)
102
+ end
103
+ display_col += w
104
+ source_col += 1
105
+ next
74
106
  end
75
- display_col += w
76
- source_col += 1
77
- next
78
- end
79
107
 
80
- w = RuVim::DisplayWidth.cell_width(ch, col: display_col, tabstop:)
81
- if terminal_unsafe_control_char?(ch)
82
- w = [w, 1].max
108
+ # Control chars (0x00..0x1F, 0x7F, 0x80..0x9F)
109
+ if code < 0x20 || code == 0x7F || (code >= 0x80 && code <= 0x9F)
110
+ break if display_col >= max_width
111
+ cells << Cell.new("?", source_col, 1)
112
+ display_col += 1
113
+ source_col += 1
114
+ next
115
+ end
116
+
117
+ w = RuVim::DisplayWidth.cell_width(ch, col: display_col, tabstop:)
83
118
  break if display_col + w > max_width
84
119
 
85
- cells << Cell.new(glyph: terminal_safe_placeholder(ch), source_col:, display_width: w)
120
+ cells << Cell.new(ch, source_col, w)
86
121
  display_col += w
87
122
  source_col += 1
88
- next
89
123
  end
90
- break if display_col + w > max_width
91
124
 
92
- cells << Cell.new(glyph: ch, source_col:, display_width: w)
93
- display_col += w
94
- source_col += 1
125
+ [cells, display_col]
95
126
  end
96
-
97
- [cells, display_col]
98
127
  end
99
128
 
100
129
  def pad_plain_to_screen_width(text, width, tabstop: 2)
101
130
  cells, used = clip_cells_for_width(text, width, tabstop:)
102
131
  out = cells.map(&:glyph).join
103
- out << (" " * [width.to_i - used, 0].max)
132
+ out << (" " * [width - used, 0].max)
104
133
  out
105
134
  end
106
135
 
107
- def terminal_safe_text(text)
108
- text.to_s.each_char.map { |ch| terminal_unsafe_control_char?(ch) ? terminal_safe_placeholder(ch) : ch }.join
109
- end
110
-
111
- def terminal_unsafe_control_char?(ch)
112
- return false if ch.nil? || ch.empty? || ch == "\t"
136
+ UNSAFE_CONTROL_CHAR_RE = /[\u0000-\u0008\u000a-\u001f\u007f\u0080-\u009f]/
113
137
 
114
- code = ch.ord
115
- (code >= 0x00 && code < 0x20) || code == 0x7F || (0x80..0x9F).cover?(code)
116
- rescue StandardError
117
- false
138
+ def terminal_safe_text(text)
139
+ text.to_s.gsub(UNSAFE_CONTROL_CHAR_RE, "?")
118
140
  end
119
141
 
120
- def terminal_safe_placeholder(_ch)
121
- "?"
122
- end
123
142
  end
124
143
  end
data/lib/ruvim/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RuVim
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end