ruvim 0.1.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 (72) 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 +29 -0
  6. data/docs/command.md +101 -0
  7. data/docs/config.md +203 -84
  8. data/docs/done.md +21 -0
  9. data/docs/lib_cleanup_report.md +79 -0
  10. data/docs/plugin.md +13 -15
  11. data/docs/spec.md +195 -33
  12. data/docs/todo.md +183 -10
  13. data/docs/tutorial.md +1 -1
  14. data/docs/vim_diff.md +94 -171
  15. data/lib/ruvim/app.rb +1543 -172
  16. data/lib/ruvim/buffer.rb +35 -1
  17. data/lib/ruvim/cli.rb +12 -3
  18. data/lib/ruvim/clipboard.rb +2 -0
  19. data/lib/ruvim/command_invocation.rb +3 -1
  20. data/lib/ruvim/command_line.rb +2 -0
  21. data/lib/ruvim/command_registry.rb +2 -0
  22. data/lib/ruvim/config_dsl.rb +2 -0
  23. data/lib/ruvim/config_loader.rb +21 -5
  24. data/lib/ruvim/context.rb +2 -7
  25. data/lib/ruvim/dispatcher.rb +153 -13
  26. data/lib/ruvim/display_width.rb +28 -2
  27. data/lib/ruvim/editor.rb +622 -69
  28. data/lib/ruvim/ex_command_registry.rb +2 -0
  29. data/lib/ruvim/global_commands.rb +1386 -114
  30. data/lib/ruvim/highlighter.rb +16 -21
  31. data/lib/ruvim/input.rb +52 -29
  32. data/lib/ruvim/keymap_manager.rb +83 -0
  33. data/lib/ruvim/keyword_chars.rb +48 -0
  34. data/lib/ruvim/lang/base.rb +25 -0
  35. data/lib/ruvim/lang/csv.rb +18 -0
  36. data/lib/ruvim/lang/json.rb +18 -0
  37. data/lib/ruvim/lang/markdown.rb +170 -0
  38. data/lib/ruvim/lang/ruby.rb +236 -0
  39. data/lib/ruvim/lang/scheme.rb +44 -0
  40. data/lib/ruvim/lang/tsv.rb +19 -0
  41. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  42. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  43. data/lib/ruvim/rich_view.rb +93 -0
  44. data/lib/ruvim/screen.rb +851 -119
  45. data/lib/ruvim/terminal.rb +18 -1
  46. data/lib/ruvim/text_metrics.rb +28 -0
  47. data/lib/ruvim/version.rb +2 -2
  48. data/lib/ruvim/window.rb +37 -10
  49. data/lib/ruvim.rb +15 -0
  50. data/test/app_completion_test.rb +174 -0
  51. data/test/app_dot_repeat_test.rb +13 -0
  52. data/test/app_motion_test.rb +110 -2
  53. data/test/app_scenario_test.rb +998 -0
  54. data/test/app_startup_test.rb +197 -0
  55. data/test/arglist_test.rb +113 -0
  56. data/test/buffer_test.rb +49 -30
  57. data/test/config_loader_test.rb +37 -0
  58. data/test/dispatcher_test.rb +438 -0
  59. data/test/display_width_test.rb +18 -0
  60. data/test/editor_register_test.rb +23 -0
  61. data/test/fixtures/render_basic_snapshot.txt +7 -8
  62. data/test/fixtures/render_basic_snapshot_nonumber.txt +1 -2
  63. data/test/fixtures/render_unicode_scrolled_snapshot.txt +6 -7
  64. data/test/highlighter_test.rb +121 -0
  65. data/test/indent_test.rb +201 -0
  66. data/test/input_screen_integration_test.rb +65 -14
  67. data/test/markdown_renderer_test.rb +279 -0
  68. data/test/on_save_hook_test.rb +150 -0
  69. data/test/rich_view_test.rb +478 -0
  70. data/test/screen_test.rb +470 -0
  71. data/test/window_test.rb +26 -0
  72. metadata +37 -2
data/lib/ruvim/editor.rb CHANGED
@@ -1,22 +1,82 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class Editor
3
5
  OPTION_DEFS = {
4
6
  "number" => { default_scope: :window, type: :bool, default: false },
5
7
  "relativenumber" => { default_scope: :window, type: :bool, default: false },
8
+ "wrap" => { default_scope: :window, type: :bool, default: true },
9
+ "linebreak" => { default_scope: :window, type: :bool, default: false },
10
+ "breakindent" => { default_scope: :window, type: :bool, default: false },
11
+ "cursorline" => { default_scope: :window, type: :bool, default: false },
12
+ "scrolloff" => { default_scope: :window, type: :int, default: 0 },
13
+ "sidescrolloff" => { default_scope: :window, type: :int, default: 0 },
14
+ "numberwidth" => { default_scope: :window, type: :int, default: 4 },
15
+ "colorcolumn" => { default_scope: :window, type: :string, default: nil },
16
+ "signcolumn" => { default_scope: :window, type: :string, default: "auto" },
17
+ "list" => { default_scope: :window, type: :bool, default: false },
18
+ "listchars" => { default_scope: :window, type: :string, default: "tab:>-,trail:-,nbsp:+" },
19
+ "showbreak" => { default_scope: :window, type: :string, default: ">" },
20
+ "showmatch" => { default_scope: :global, type: :bool, default: false },
21
+ "matchtime" => { default_scope: :global, type: :int, default: 5 },
22
+ "whichwrap" => { default_scope: :global, type: :string, default: "" },
23
+ "virtualedit" => { default_scope: :global, type: :string, default: "" },
6
24
  "ignorecase" => { default_scope: :global, type: :bool, default: false },
7
25
  "smartcase" => { default_scope: :global, type: :bool, default: false },
8
26
  "hlsearch" => { default_scope: :global, type: :bool, default: true },
27
+ "incsearch" => { default_scope: :global, type: :bool, default: false },
28
+ "splitbelow" => { default_scope: :global, type: :bool, default: false },
29
+ "splitright" => { default_scope: :global, type: :bool, default: false },
30
+ "hidden" => { default_scope: :global, type: :bool, default: false },
31
+ "autowrite" => { default_scope: :global, type: :bool, default: false },
32
+ "clipboard" => { default_scope: :global, type: :string, default: "" },
33
+ "timeoutlen" => { default_scope: :global, type: :int, default: 1000 },
34
+ "ttimeoutlen" => { default_scope: :global, type: :int, default: 50 },
35
+ "backspace" => { default_scope: :global, type: :string, default: "indent,eol,start" },
36
+ "completeopt" => { default_scope: :global, type: :string, default: "menu,menuone,noselect" },
37
+ "pumheight" => { default_scope: :global, type: :int, default: 10 },
38
+ "wildmode" => { default_scope: :global, type: :string, default: "full" },
39
+ "wildignore" => { default_scope: :global, type: :string, default: "" },
40
+ "wildignorecase" => { default_scope: :global, type: :bool, default: false },
41
+ "wildmenu" => { default_scope: :global, type: :bool, default: false },
42
+ "termguicolors" => { default_scope: :global, type: :bool, default: false },
43
+ "path" => { default_scope: :buffer, type: :string, default: nil },
44
+ "suffixesadd" => { default_scope: :buffer, type: :string, default: nil },
45
+ "textwidth" => { default_scope: :buffer, type: :int, default: 0 },
46
+ "formatoptions" => { default_scope: :buffer, type: :string, default: nil },
47
+ "expandtab" => { default_scope: :buffer, type: :bool, default: false },
48
+ "shiftwidth" => { default_scope: :buffer, type: :int, default: 2 },
49
+ "softtabstop" => { default_scope: :buffer, type: :int, default: 0 },
50
+ "autoindent" => { default_scope: :buffer, type: :bool, default: true },
51
+ "smartindent" => { default_scope: :buffer, type: :bool, default: true },
52
+ "iskeyword" => { default_scope: :buffer, type: :string, default: nil },
9
53
  "tabstop" => { default_scope: :buffer, type: :int, default: 2 },
10
- "filetype" => { default_scope: :buffer, type: :string, default: nil }
54
+ "filetype" => { default_scope: :buffer, type: :string, default: nil },
55
+ "onsavehook" => { default_scope: :buffer, type: :bool, default: true },
56
+ "grepprg" => { default_scope: :global, type: :string, default: "grep -nH" },
57
+ "grepformat" => { default_scope: :global, type: :string, default: "%f:%l:%m" }
11
58
  }.freeze
12
-
13
- attr_reader :buffers, :windows
14
- attr_accessor :current_window_id, :mode, :message, :pending_count, :alternate_buffer_id, :window_layout, :restricted_mode
59
+ SHEBANG_FILETYPE_RULES = [
60
+ [/\Aruby(?:\d+(?:\.\d+)*)?\z/, "ruby"],
61
+ [/\Apython(?:\d+(?:\.\d+)*)?\z/, "python"],
62
+ ["node", "javascript"],
63
+ ["nodejs", "javascript"],
64
+ ["deno", "javascript"],
65
+ ["bash", "sh"],
66
+ ["sh", "sh"],
67
+ ["zsh", "sh"],
68
+ ["ksh", "sh"],
69
+ ["dash", "sh"],
70
+ [/\Aperl(?:\d+(?:\.\d+)*)?\z/, "perl"]
71
+ ].freeze
72
+
73
+ attr_reader :buffers, :windows, :layout_tree
74
+ attr_accessor :current_window_id, :mode, :message, :pending_count, :alternate_buffer_id, :restricted_mode, :current_window_view_height_hint, :stdin_stream_stop_handler, :open_path_handler, :keymap_manager, :app_action_handler
15
75
 
16
76
  def initialize
17
77
  @buffers = {}
18
78
  @windows = {}
19
- @window_order = []
79
+ @layout_tree = nil
20
80
  @tabpages = []
21
81
  @current_tabpage_index = nil
22
82
  @next_tabpage_id = 1
@@ -26,12 +86,17 @@ module RuVim
26
86
  @current_window_id = nil
27
87
  @alternate_buffer_id = nil
28
88
  @mode = :normal
29
- @window_layout = :single
30
89
  @message = ""
31
90
  @message_kind = :info
91
+ @message_deadline = nil
32
92
  @pending_count = nil
33
93
  @restricted_mode = false
94
+ @current_window_view_height_hint = 1
34
95
  @running = true
96
+ @stdin_stream_stop_handler = nil
97
+ @open_path_handler = nil
98
+ @keymap_manager = nil
99
+ @app_action_handler = nil
35
100
  @global_options = default_global_options
36
101
  @command_line = CommandLine.new
37
102
  @last_search = nil
@@ -45,8 +110,12 @@ module RuVim
45
110
  @macros = {}
46
111
  @macro_recording = nil
47
112
  @visual_state = nil
113
+ @rich_state = nil
48
114
  @quickfix_list = { items: [], index: nil }
49
115
  @location_lists = Hash.new { |h, k| h[k] = { items: [], index: nil } }
116
+ @arglist = []
117
+ @arglist_index = 0
118
+ @hit_enter_lines = nil
50
119
  end
51
120
 
52
121
  def running?
@@ -97,6 +166,22 @@ module RuVim
97
166
  @buffers.fetch(current_window.buffer_id)
98
167
  end
99
168
 
169
+ def stdin_stream_stop_or_cancel!
170
+ handler = @stdin_stream_stop_handler
171
+ return false unless handler
172
+
173
+ handler.call
174
+ true
175
+ end
176
+
177
+ def invoke_app_action(name, **kwargs)
178
+ handler = @app_action_handler
179
+ return false unless handler
180
+
181
+ handler.call(name.to_sym, **kwargs)
182
+ true
183
+ end
184
+
100
185
  def option_def(name)
101
186
  OPTION_DEFS[name.to_s]
102
187
  end
@@ -169,7 +254,7 @@ module RuVim
169
254
  base = File.basename(p)
170
255
  return "ruby" if %w[Gemfile Rakefile Guardfile].include?(base)
171
256
 
172
- {
257
+ ext_ft = {
173
258
  ".rb" => "ruby",
174
259
  ".rake" => "ruby",
175
260
  ".ru" => "ruby",
@@ -187,8 +272,16 @@ module RuVim
187
272
  ".txt" => "text",
188
273
  ".html" => "html",
189
274
  ".css" => "css",
190
- ".sh" => "sh"
275
+ ".sh" => "sh",
276
+ ".tsv" => "tsv",
277
+ ".csv" => "csv",
278
+ ".scm" => "scheme",
279
+ ".ss" => "scheme",
280
+ ".sld" => "scheme"
191
281
  }[File.extname(base).downcase]
282
+ return ext_ft if ext_ft
283
+
284
+ detect_filetype_from_shebang(p)
192
285
  end
193
286
 
194
287
  def registers
@@ -201,6 +294,12 @@ module RuVim
201
294
 
202
295
  payload = write_register_payload(key, text: text.to_s, type: type.to_sym)
203
296
  write_clipboard_register(key, payload)
297
+ if key == "\""
298
+ if (default_clip = clipboard_default_register_key)
299
+ mirror = write_register_payload(default_clip, text: payload[:text], type: payload[:type])
300
+ write_clipboard_register(default_clip, mirror)
301
+ end
302
+ end
204
303
  @registers["\""] = payload unless key == "\""
205
304
  payload
206
305
  end
@@ -225,6 +324,14 @@ module RuVim
225
324
 
226
325
  def get_register(name = "\"")
227
326
  key = name.to_s.downcase
327
+ if key == "\""
328
+ if (default_clip = clipboard_default_register_key)
329
+ if (payload = read_clipboard_register(default_clip))
330
+ @registers["\""] = dup_register_payload(payload)
331
+ return @registers["\""]
332
+ end
333
+ end
334
+ end
228
335
  return read_clipboard_register(key) if clipboard_register?(key)
229
336
 
230
337
  @registers[key]
@@ -423,6 +530,53 @@ module RuVim
423
530
  end
424
531
  end
425
532
 
533
+ def rich_state
534
+ @rich_state
535
+ end
536
+
537
+ def rich_mode?
538
+ @mode == :rich
539
+ end
540
+
541
+ def enter_rich_mode(format:, delimiter:)
542
+ @mode = :rich
543
+ @pending_count = nil
544
+ @rich_state = { format: format, delimiter: delimiter }
545
+ end
546
+
547
+ def exit_rich_mode
548
+ @rich_state = nil
549
+ enter_normal_mode
550
+ end
551
+
552
+ def hit_enter_active?
553
+ @mode == :hit_enter
554
+ end
555
+
556
+ def hit_enter_lines
557
+ @hit_enter_lines
558
+ end
559
+
560
+ def enter_hit_enter_mode(lines)
561
+ @mode = :hit_enter
562
+ @hit_enter_lines = Array(lines)
563
+ @pending_count = nil
564
+ end
565
+
566
+ def exit_hit_enter_mode
567
+ @hit_enter_lines = nil
568
+ enter_normal_mode
569
+ end
570
+
571
+ def echo_multiline(lines)
572
+ lines = Array(lines)
573
+ if lines.length <= 1
574
+ echo(lines.first.to_s)
575
+ else
576
+ enter_hit_enter_mode(lines)
577
+ end
578
+ end
579
+
426
580
  def add_empty_buffer(path: nil)
427
581
  id = next_buffer_id
428
582
  buffer = Buffer.new(id:, path:)
@@ -434,7 +588,7 @@ module RuVim
434
588
  def add_virtual_buffer(kind:, name:, lines:, filetype: nil, readonly: true, modifiable: false)
435
589
  id = next_buffer_id
436
590
  buffer = Buffer.new(id:, lines:, kind:, name:, readonly:, modifiable:)
437
- buffer.options["filetype"] = filetype if filetype
591
+ assign_filetype(buffer, filetype) if filetype
438
592
  @buffers[id] = buffer
439
593
  buffer
440
594
  end
@@ -451,22 +605,40 @@ module RuVim
451
605
  id = next_window_id
452
606
  window = Window.new(id:, buffer_id:)
453
607
  @windows[id] = window
454
- @window_order << id
608
+ leaf = { type: :window, id: id }
609
+ if @layout_tree.nil?
610
+ @layout_tree = leaf
611
+ else
612
+ # Append as sibling — used for initial bootstrap only
613
+ if @layout_tree[:type] == :window
614
+ @layout_tree = { type: :hsplit, children: [@layout_tree, leaf] }
615
+ else
616
+ @layout_tree[:children] << leaf
617
+ end
618
+ end
455
619
  @current_window_id ||= id
456
620
  ensure_initial_tabpage!
457
621
  save_current_tabpage_state! unless @suspend_tab_autosave
458
622
  window
459
623
  end
460
624
 
461
- def split_current_window(layout: :horizontal)
625
+ def split_current_window(layout: :horizontal, place: :after)
462
626
  save_current_tabpage_state! unless @suspend_tab_autosave
463
627
  src = current_window
464
- win = add_window(buffer_id: src.buffer_id)
628
+ id = next_window_id
629
+ win = Window.new(id:, buffer_id: src.buffer_id)
630
+ @windows[id] = win
631
+ ensure_initial_tabpage!
465
632
  win.cursor_x = src.cursor_x
466
633
  win.cursor_y = src.cursor_y
467
634
  win.row_offset = src.row_offset
468
635
  win.col_offset = src.col_offset
469
- @window_layout = layout.to_sym
636
+
637
+ split_type = (layout.to_sym == :vertical ? :vsplit : :hsplit)
638
+ new_leaf = { type: :window, id: win.id }
639
+
640
+ @layout_tree = tree_split_leaf(@layout_tree, src.id, split_type, new_leaf, place.to_sym)
641
+
470
642
  @current_window_id = win.id
471
643
  save_current_tabpage_state! unless @suspend_tab_autosave
472
644
  win
@@ -477,18 +649,21 @@ module RuVim
477
649
  end
478
650
 
479
651
  def close_window(id)
480
- return nil if @window_order.empty?
481
- return nil if @window_order.length <= 1
482
- return nil unless @window_order.include?(id)
652
+ leaves = tree_leaves(@layout_tree)
653
+ return nil if leaves.empty?
654
+ return nil if leaves.length <= 1
655
+ return nil unless leaves.include?(id)
483
656
 
484
657
  save_current_tabpage_state! unless @suspend_tab_autosave
485
- idx = @window_order.index(id) || 0
658
+ idx = leaves.index(id) || 0
486
659
  @windows.delete(id)
487
- @window_order.delete(id)
488
660
  @location_lists.delete(id)
489
- @current_window_id = @window_order[[idx, @window_order.length - 1].min] if @current_window_id == id
490
- @current_window_id ||= @window_order.first
491
- @window_layout = :single if @window_order.length <= 1
661
+
662
+ @layout_tree = tree_remove(@layout_tree, id)
663
+
664
+ new_leaves = tree_leaves(@layout_tree)
665
+ @current_window_id = new_leaves[[idx, new_leaves.length - 1].min] if @current_window_id == id
666
+ @current_window_id ||= new_leaves.first
492
667
  save_current_tabpage_state! unless @suspend_tab_autosave
493
668
  current_window
494
669
  end
@@ -499,7 +674,8 @@ module RuVim
499
674
 
500
675
  save_current_tabpage_state!
501
676
  removed = @tabpages.delete_at(@current_tabpage_index)
502
- Array(removed && removed[:window_order]).each do |wid|
677
+ removed_tree = removed && removed[:layout_tree]
678
+ tree_leaves(removed_tree).each do |wid|
503
679
  @windows.delete(wid)
504
680
  @location_lists.delete(wid)
505
681
  end
@@ -517,42 +693,55 @@ module RuVim
517
693
  end
518
694
 
519
695
  def focus_next_window
520
- return current_window if @window_order.length <= 1
696
+ order = window_order
697
+ return current_window if order.length <= 1
521
698
 
522
- idx = @window_order.index(@current_window_id) || 0
523
- focus_window(@window_order[(idx + 1) % @window_order.length])
699
+ idx = order.index(@current_window_id) || 0
700
+ focus_window(order[(idx + 1) % order.length])
524
701
  end
525
702
 
526
703
  def focus_prev_window
527
- return current_window if @window_order.length <= 1
704
+ order = window_order
705
+ return current_window if order.length <= 1
528
706
 
529
- idx = @window_order.index(@current_window_id) || 0
530
- focus_window(@window_order[(idx - 1) % @window_order.length])
707
+ idx = order.index(@current_window_id) || 0
708
+ focus_window(order[(idx - 1) % order.length])
531
709
  end
532
710
 
533
711
  def focus_window_direction(dir)
534
- return current_window if @window_order.length <= 1
535
-
536
- case @window_layout
537
- when :vertical
538
- if dir == :left
539
- focus_prev_window
540
- elsif dir == :right
541
- focus_next_window
542
- else
543
- current_window
544
- end
545
- when :horizontal
546
- if dir == :up
547
- focus_prev_window
548
- elsif dir == :down
549
- focus_next_window
550
- else
551
- current_window
712
+ leaves = tree_leaves(@layout_tree)
713
+ return current_window if leaves.length <= 1
714
+
715
+ rects = tree_compute_rects(@layout_tree, top: 0.0, left: 0.0, height: 1.0, width: 1.0)
716
+ cur = rects[@current_window_id]
717
+ return current_window unless cur
718
+
719
+ best_id = nil
720
+ best_dist = Float::INFINITY
721
+ cur_cx = cur[:left] + cur[:width] / 2.0
722
+ cur_cy = cur[:top] + cur[:height] / 2.0
723
+
724
+ rects.each do |wid, r|
725
+ next if wid == @current_window_id
726
+ rcx = r[:left] + r[:width] / 2.0
727
+ rcy = r[:top] + r[:height] / 2.0
728
+
729
+ in_direction = case dir
730
+ when :left then rcx < cur_cx
731
+ when :right then rcx > cur_cx
732
+ when :up then rcy < cur_cy
733
+ when :down then rcy > cur_cy
734
+ end
735
+ next unless in_direction
736
+
737
+ dist = (rcx - cur_cx).abs + (rcy - cur_cy).abs
738
+ if dist < best_dist
739
+ best_dist = dist
740
+ best_id = wid
552
741
  end
553
- else
554
- focus_next_window
555
742
  end
743
+
744
+ best_id ? focus_window(best_id) : current_window
556
745
  end
557
746
 
558
747
  def switch_to_buffer(buffer_id)
@@ -570,9 +759,16 @@ module RuVim
570
759
  end
571
760
 
572
761
  def open_path(path)
762
+ handler = @open_path_handler
763
+ return handler.call(path) if handler
764
+
765
+ open_path_sync(path)
766
+ end
767
+
768
+ def open_path_sync(path)
573
769
  buffer = add_buffer_from_file(path)
574
770
  switch_to_buffer(buffer.id)
575
- echo(path && File.exist?(path) ? "\"#{path}\" #{buffer.line_count}L" : "\"#{path}\" [New File]")
771
+ echo("[New File]") unless path && File.exist?(path)
576
772
  buffer
577
773
  end
578
774
 
@@ -608,7 +804,7 @@ module RuVim
608
804
  current_buffer.replace_all_lines!(intro_lines)
609
805
  current_buffer.configure_special!(kind: :intro, name: "[Intro]", readonly: true, modifiable: false)
610
806
  current_buffer.modified = false
611
- current_buffer.options["filetype"] = "help"
807
+ assign_filetype(current_buffer, "help")
612
808
  current_window.cursor_x = 0
613
809
  current_window.cursor_y = 0
614
810
  current_window.row_offset = 0
@@ -621,7 +817,7 @@ module RuVim
621
817
  return false unless current_buffer.intro_buffer?
622
818
 
623
819
  current_buffer.become_normal_empty_buffer!
624
- current_buffer.options["filetype"] = nil
820
+ assign_filetype(current_buffer, nil)
625
821
  current_window.cursor_x = 0
626
822
  current_window.cursor_y = 0
627
823
  current_window.row_offset = 0
@@ -641,8 +837,56 @@ module RuVim
641
837
  ids[(idx + step) % ids.length]
642
838
  end
643
839
 
840
+ def delete_buffer(buffer_id)
841
+ id = buffer_id.to_i
842
+ buffer = @buffers[id]
843
+ return nil unless buffer
844
+
845
+ if @buffers.length <= 1
846
+ replacement = add_empty_buffer
847
+ else
848
+ replacement = nil
849
+ end
850
+
851
+ fallback_id =
852
+ if replacement
853
+ replacement.id
854
+ else
855
+ candidates = @buffers.keys.reject { |bid| bid == id }
856
+ alt = @alternate_buffer_id
857
+ (alt && alt != id && @buffers.key?(alt)) ? alt : candidates.first
858
+ end
859
+
860
+ @windows.each_value do |win|
861
+ next unless win.buffer_id == id
862
+ next unless fallback_id
863
+
864
+ win.buffer_id = fallback_id
865
+ win.cursor_x = 0
866
+ win.cursor_y = 0
867
+ win.row_offset = 0
868
+ win.col_offset = 0
869
+ end
870
+
871
+ @buffers.delete(id)
872
+ @local_marks.delete(id)
873
+ @alternate_buffer_id = nil if @alternate_buffer_id == id
874
+ save_current_tabpage_state! unless @suspend_tab_autosave
875
+ ensure_bootstrap_buffer! if @buffers.empty?
876
+ true
877
+ end
878
+
644
879
  def window_order
645
- @window_order
880
+ tree_leaves(@layout_tree)
881
+ end
882
+
883
+ def window_layout
884
+ return :single if @layout_tree.nil? || @layout_tree[:type] == :window
885
+ case @layout_tree[:type]
886
+ when :vsplit then :vertical
887
+ when :hsplit then :horizontal
888
+ else :single
889
+ end
646
890
  end
647
891
 
648
892
  def tabpages
@@ -661,8 +905,12 @@ module RuVim
661
905
  @tabpages.length
662
906
  end
663
907
 
908
+ def tabpage_windows(tab)
909
+ tree_leaves(tab[:layout_tree])
910
+ end
911
+
664
912
  def window_count
665
- @window_order.length
913
+ tree_leaves(@layout_tree).length
666
914
  end
667
915
 
668
916
  def quickfix_items
@@ -675,7 +923,7 @@ module RuVim
675
923
 
676
924
  def set_quickfix_list(items)
677
925
  ary = Array(items).map { |it| normalize_location(it)&.merge(text: (it[:text] || it["text"]).to_s) }.compact
678
- @quickfix_list = { items: ary, index: ary.empty? ? nil : 0 }
926
+ @quickfix_list = { items: ary, index: nil }
679
927
  @quickfix_list
680
928
  end
681
929
 
@@ -688,7 +936,21 @@ module RuVim
688
936
  items = @quickfix_list[:items]
689
937
  return nil if items.empty?
690
938
 
691
- @quickfix_list[:index] = ((@quickfix_list[:index] || 0) + step.to_i) % items.length
939
+ cur = @quickfix_list[:index]
940
+ @quickfix_list[:index] = if cur.nil?
941
+ step.to_i > 0 ? 0 : items.length - 1
942
+ else
943
+ (cur + step.to_i) % items.length
944
+ end
945
+ current_quickfix_item
946
+ end
947
+
948
+ def select_quickfix(index)
949
+ items = @quickfix_list[:items]
950
+ return nil if items.empty?
951
+
952
+ i = [[index.to_i, 0].max, items.length - 1].min
953
+ @quickfix_list[:index] = i
692
954
  current_quickfix_item
693
955
  end
694
956
 
@@ -702,7 +964,7 @@ module RuVim
702
964
 
703
965
  def set_location_list(items, window_id: current_window_id)
704
966
  ary = Array(items).map { |it| normalize_location(it)&.merge(text: (it[:text] || it["text"]).to_s) }.compact
705
- @location_lists[window_id] = { items: ary, index: ary.empty? ? nil : 0 }
967
+ @location_lists[window_id] = { items: ary, index: nil }
706
968
  @location_lists[window_id]
707
969
  end
708
970
 
@@ -717,13 +979,41 @@ module RuVim
717
979
  items = list[:items]
718
980
  return nil if items.empty?
719
981
 
720
- list[:index] = ((list[:index] || 0) + step.to_i) % items.length
982
+ cur = list[:index]
983
+ list[:index] = if cur.nil?
984
+ step.to_i > 0 ? 0 : items.length - 1
985
+ else
986
+ (cur + step.to_i) % items.length
987
+ end
988
+ current_location_list_item(window_id)
989
+ end
990
+
991
+ def select_location_list(index, window_id: current_window_id)
992
+ list = location_list(window_id)
993
+ items = list[:items]
994
+ return nil if items.empty?
995
+
996
+ i = [[index.to_i, 0].max, items.length - 1].min
997
+ list[:index] = i
721
998
  current_location_list_item(window_id)
722
999
  end
723
1000
 
1001
+ # Check if the path from root to the current window passes through
1002
+ # a split node matching the direction's axis. Used by focus_or_split
1003
+ # to decide whether to split or stay put at edges.
1004
+ # left/right → check for :vsplit ancestor
1005
+ # up/down → check for :hsplit ancestor
1006
+ def has_split_ancestor_on_axis?(dir)
1007
+ target_type = case dir
1008
+ when :left, :right then :vsplit
1009
+ when :up, :down then :hsplit
1010
+ end
1011
+ tree_path_has_split_type?(@layout_tree, @current_window_id, target_type)
1012
+ end
1013
+
724
1014
  def find_window_ids_by_buffer_kind(kind)
725
1015
  sym = kind.to_sym
726
- @window_order.select do |wid|
1016
+ window_order.select do |wid|
727
1017
  win = @windows[wid]
728
1018
  buf = win && @buffers[win.buffer_id]
729
1019
  buf && buf.kind == sym
@@ -735,9 +1025,8 @@ module RuVim
735
1025
  save_current_tabpage_state!
736
1026
 
737
1027
  with_tab_autosave_suspended do
738
- @window_order = []
1028
+ @layout_tree = nil
739
1029
  @current_window_id = nil
740
- @window_layout = :single
741
1030
 
742
1031
  buffer = path ? add_buffer_from_file(path) : add_empty_buffer
743
1032
  add_window(buffer_id: buffer.id)
@@ -764,6 +1053,7 @@ module RuVim
764
1053
  @mode = :normal
765
1054
  @pending_count = nil
766
1055
  clear_visual
1056
+ @rich_state = nil
767
1057
  end
768
1058
 
769
1059
  def enter_insert_mode
@@ -779,30 +1069,77 @@ module RuVim
779
1069
 
780
1070
  def cancel_command_line
781
1071
  @command_line.clear
782
- enter_normal_mode
1072
+ leave_command_line
1073
+ end
1074
+
1075
+ def leave_command_line
1076
+ if @rich_state
1077
+ @mode = :rich
1078
+ @pending_count = nil
1079
+ else
1080
+ enter_normal_mode
1081
+ end
783
1082
  end
784
1083
 
785
1084
  def echo(msg)
786
1085
  @message_kind = :info
787
1086
  @message = msg.to_s
1087
+ @message_deadline = nil
1088
+ end
1089
+
1090
+ def echo_temporary(msg, duration_seconds:)
1091
+ @message_kind = :info
1092
+ @message = msg.to_s
1093
+ dur = duration_seconds.to_f
1094
+ @message_deadline = dur.positive? ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + dur) : nil
1095
+ rescue StandardError
1096
+ @message_deadline = nil
788
1097
  end
789
1098
 
790
1099
  def echo_error(msg)
791
1100
  @message_kind = :error
792
1101
  @message = msg.to_s
1102
+ @message_deadline = nil
793
1103
  end
794
1104
 
795
1105
  def clear_message
796
1106
  @message_kind = :info
797
1107
  @message = ""
1108
+ @message_deadline = nil
798
1109
  end
799
1110
 
800
1111
  def message_error?
801
1112
  !@message.to_s.empty? && @message_kind == :error
802
1113
  end
803
1114
 
1115
+ def transient_message_timeout_seconds(now: nil)
1116
+ return nil unless @message_deadline
1117
+ return nil if message_error?
1118
+ return nil if command_line_active?
1119
+
1120
+ now ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
1121
+ [@message_deadline - now, 0.0].max
1122
+ rescue StandardError
1123
+ nil
1124
+ end
1125
+
1126
+ def clear_expired_transient_message!(now: nil)
1127
+ return false unless @message_deadline
1128
+ return false if message_error?
1129
+ return false if command_line_active?
1130
+
1131
+ now ||= Process.clock_gettime(Process::CLOCK_MONOTONIC)
1132
+ return false if now < @message_deadline
1133
+
1134
+ clear_message
1135
+ true
1136
+ rescue StandardError
1137
+ false
1138
+ end
1139
+
804
1140
  def text_viewport_size(rows:, cols:)
805
- text_rows = command_area_active? ? rows - 2 : rows - 1
1141
+ # Reserve one status row + one command/error row at the bottom.
1142
+ text_rows = rows - 2
806
1143
  [text_rows, cols]
807
1144
  end
808
1145
 
@@ -814,8 +1151,69 @@ module RuVim
814
1151
  command_line_active? || message_error?
815
1152
  end
816
1153
 
1154
+ def assign_filetype(buffer, ft)
1155
+ buffer.options["filetype"] = ft
1156
+ buffer.lang_module = resolve_lang_module(ft)
1157
+ end
1158
+
817
1159
  private
818
1160
 
1161
+ def resolve_lang_module(ft)
1162
+ case ft
1163
+ when "ruby" then Lang::Ruby
1164
+ else Lang::Base
1165
+ end
1166
+ end
1167
+
1168
+ def detect_filetype_from_shebang(path)
1169
+ line = read_first_line(path)
1170
+ return nil unless line.start_with?("#!")
1171
+
1172
+ cmd = shebang_command_name(line)
1173
+ return nil if cmd.nil? || cmd.empty?
1174
+
1175
+ rule = SHEBANG_FILETYPE_RULES.find do |matcher, _filetype|
1176
+ matcher.is_a?(Regexp) ? matcher.match?(cmd) : matcher.to_s == cmd
1177
+ end
1178
+ rule && rule[1]
1179
+ rescue StandardError
1180
+ nil
1181
+ end
1182
+
1183
+ def read_first_line(path)
1184
+ return "" unless path && !path.empty?
1185
+ return "" unless File.file?(path)
1186
+
1187
+ File.open(path, "rb") do |f|
1188
+ (f.gets || "").to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
1189
+ end
1190
+ rescue StandardError
1191
+ ""
1192
+ end
1193
+
1194
+ def shebang_command_name(line)
1195
+ src = line.to_s.sub(/\A#!/, "").strip
1196
+ return nil if src.empty?
1197
+
1198
+ tokens = src.split(/\s+/)
1199
+ return nil if tokens.empty?
1200
+
1201
+ prog = tokens[0].to_s
1202
+ if File.basename(prog) == "env"
1203
+ i = 1
1204
+ while i < tokens.length && tokens[i].start_with?("-")
1205
+ if tokens[i] == "-S"
1206
+ i += 1
1207
+ break
1208
+ end
1209
+ i += 1
1210
+ end
1211
+ prog = tokens[i].to_s
1212
+ end
1213
+
1214
+ File.basename(prog.to_s)
1215
+ end
1216
+
819
1217
  def default_global_options
820
1218
  OPTION_DEFS.each_with_object({}) { |(k, v), h| h[k] = v[:default] }
821
1219
  end
@@ -873,7 +1271,7 @@ module RuVim
873
1271
 
874
1272
  def assign_detected_filetype(buffer)
875
1273
  ft = detect_filetype(buffer.path)
876
- buffer.options["filetype"] = ft if ft && !ft.empty?
1274
+ assign_filetype(buffer, ft) if ft && !ft.empty?
877
1275
  buffer
878
1276
  end
879
1277
 
@@ -910,6 +1308,51 @@ module RuVim
910
1308
  current_location
911
1309
  end
912
1310
 
1311
+ def arglist
1312
+ @arglist.dup
1313
+ end
1314
+
1315
+ def arglist_index
1316
+ @arglist_index
1317
+ end
1318
+
1319
+ def set_arglist(paths)
1320
+ @arglist = Array(paths).dup
1321
+ @arglist_index = 0
1322
+ end
1323
+
1324
+ def arglist_current
1325
+ @arglist[@arglist_index] if @arglist_index < @arglist.length
1326
+ end
1327
+
1328
+ def arglist_next(count = 1)
1329
+ new_index = @arglist_index + count
1330
+ if new_index >= @arglist.length
1331
+ raise RuVim::CommandError, "Already at last argument"
1332
+ end
1333
+ @arglist_index = new_index
1334
+ @arglist[@arglist_index]
1335
+ end
1336
+
1337
+ def arglist_prev(count = 1)
1338
+ new_index = @arglist_index - count
1339
+ if new_index < 0
1340
+ raise RuVim::CommandError, "Already at first argument"
1341
+ end
1342
+ @arglist_index = new_index
1343
+ @arglist[@arglist_index]
1344
+ end
1345
+
1346
+ def arglist_first
1347
+ @arglist_index = 0
1348
+ @arglist[@arglist_index] if @arglist.length > 0
1349
+ end
1350
+
1351
+ def arglist_last
1352
+ @arglist_index = [@arglist.length - 1, 0].max
1353
+ @arglist[@arglist_index] if @arglist.length > 0
1354
+ end
1355
+
913
1356
  private
914
1357
 
915
1358
  def first_nonblank_col(buffer, row)
@@ -950,6 +1393,15 @@ module RuVim
950
1393
  key == "+" || key == "*"
951
1394
  end
952
1395
 
1396
+ def clipboard_default_register_key
1397
+ spec = @global_options["clipboard"].to_s
1398
+ parts = spec.split(",").map { |s| s.strip.downcase }.reject(&:empty?)
1399
+ return "+" if parts.include?("unnamedplus")
1400
+ return "*" if parts.include?("unnamed")
1401
+
1402
+ nil
1403
+ end
1404
+
953
1405
  def write_clipboard_register(key, payload)
954
1406
  return unless clipboard_register?(key.downcase)
955
1407
 
@@ -967,7 +1419,7 @@ module RuVim
967
1419
 
968
1420
  def ensure_initial_tabpage!
969
1421
  return unless @tabpages.empty?
970
- return if @window_order.empty?
1422
+ return if @layout_tree.nil?
971
1423
 
972
1424
  @tabpages << new_tabpage_snapshot
973
1425
  @current_tabpage_index = 0
@@ -981,18 +1433,16 @@ module RuVim
981
1433
  end
982
1434
 
983
1435
  def load_tabpage_state!(tab)
984
- @window_order = tab[:window_order].dup
1436
+ @layout_tree = tree_deep_dup(tab[:layout_tree])
985
1437
  @current_window_id = tab[:current_window_id]
986
- @window_layout = tab[:window_layout]
987
1438
  current_window
988
1439
  end
989
1440
 
990
1441
  def new_tabpage_snapshot(id: nil)
991
1442
  {
992
1443
  id: id || next_tabpage_id,
993
- window_order: @window_order.dup,
994
- current_window_id: @current_window_id,
995
- window_layout: @window_layout
1444
+ layout_tree: tree_deep_dup(@layout_tree),
1445
+ current_window_id: @current_window_id
996
1446
  }
997
1447
  end
998
1448
 
@@ -1021,5 +1471,108 @@ module RuVim
1021
1471
  ensure
1022
1472
  @suspend_tab_autosave = prev
1023
1473
  end
1474
+
1475
+ # --- Layout tree helpers ---
1476
+
1477
+ def tree_leaves(node)
1478
+ return [] if node.nil?
1479
+ return [node[:id]] if node[:type] == :window
1480
+
1481
+ node[:children].flat_map { |c| tree_leaves(c) }
1482
+ end
1483
+
1484
+ def tree_deep_dup(node)
1485
+ return nil if node.nil?
1486
+ return { type: :window, id: node[:id] } if node[:type] == :window
1487
+
1488
+ { type: node[:type], children: node[:children].map { |c| tree_deep_dup(c) } }
1489
+ end
1490
+
1491
+ # Split a leaf node into a new split node. If the parent is already the same
1492
+ # split type, merge into the parent instead of nesting.
1493
+ def tree_split_leaf(node, target_id, split_type, new_leaf, place)
1494
+ if node[:type] == :window
1495
+ if node[:id] == target_id
1496
+ children = place == :before ? [new_leaf, node] : [node, new_leaf]
1497
+ return { type: split_type, children: children }
1498
+ end
1499
+ return node
1500
+ end
1501
+
1502
+ new_children = node[:children].flat_map do |child|
1503
+ result = tree_split_leaf(child, target_id, split_type, new_leaf, place)
1504
+ # If the child was replaced with the same split type as this node, merge
1505
+ if result[:type] == node[:type] && result != child
1506
+ result[:children]
1507
+ else
1508
+ [result]
1509
+ end
1510
+ end
1511
+
1512
+ { type: node[:type], children: new_children }
1513
+ end
1514
+
1515
+ def tree_remove(node, target_id)
1516
+ return nil if node.nil?
1517
+ return nil if node[:type] == :window && node[:id] == target_id
1518
+ return node if node[:type] == :window
1519
+
1520
+ new_children = node[:children].filter_map { |c| tree_remove(c, target_id) }
1521
+ return nil if new_children.empty?
1522
+ return new_children.first if new_children.length == 1
1523
+
1524
+ { type: node[:type], children: new_children }
1525
+ end
1526
+
1527
+ # Check if the path from root to the target window passes through
1528
+ # a split node of the given type. Returns true if any ancestor of the
1529
+ # target window in the tree has type == split_type.
1530
+ def tree_path_has_split_type?(node, target_id, split_type)
1531
+ return false if node.nil?
1532
+ return false if node[:type] == :window
1533
+ # Check if target is in any child subtree
1534
+ node[:children].each do |child|
1535
+ if tree_subtree_contains?(child, target_id)
1536
+ return true if node[:type] == split_type
1537
+ return tree_path_has_split_type?(child, target_id, split_type)
1538
+ end
1539
+ end
1540
+ false
1541
+ end
1542
+
1543
+ def tree_subtree_contains?(node, target_id)
1544
+ return false if node.nil?
1545
+ return node[:id] == target_id if node[:type] == :window
1546
+ node[:children].any? { |c| tree_subtree_contains?(c, target_id) }
1547
+ end
1548
+
1549
+ # Compute normalized rects (0.0–1.0 coordinate space) for direction finding.
1550
+ # Separators are ignored here — this is purely for adjacency/direction logic.
1551
+ def tree_compute_rects(node, top:, left:, height:, width:)
1552
+ return {} if node.nil?
1553
+
1554
+ if node[:type] == :window
1555
+ return { node[:id] => { top: top, left: left, height: height, width: width } }
1556
+ end
1557
+
1558
+ children = node[:children]
1559
+ n = children.length
1560
+ rects = {}
1561
+
1562
+ case node[:type]
1563
+ when :vsplit
1564
+ w_each = width / n.to_f
1565
+ children.each_with_index do |child, i|
1566
+ rects.merge!(tree_compute_rects(child, top: top, left: left + i * w_each, height: height, width: w_each))
1567
+ end
1568
+ when :hsplit
1569
+ h_each = height / n.to_f
1570
+ children.each_with_index do |child, i|
1571
+ rects.merge!(tree_compute_rects(child, top: top + i * h_each, left: left, height: h_each, width: width))
1572
+ end
1573
+ end
1574
+
1575
+ rects
1576
+ end
1024
1577
  end
1025
1578
  end