ruvim 0.2.0 → 0.4.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +96 -0
  4. data/CLAUDE.md +1 -0
  5. data/README.md +15 -1
  6. data/docs/binding.md +39 -0
  7. data/docs/command.md +163 -4
  8. data/docs/config.md +12 -4
  9. data/docs/done.md +21 -0
  10. data/docs/spec.md +214 -18
  11. data/docs/todo.md +1 -5
  12. data/docs/tutorial.md +24 -0
  13. data/docs/vim_diff.md +105 -173
  14. data/lib/ruvim/app.rb +1165 -70
  15. data/lib/ruvim/buffer.rb +47 -1
  16. data/lib/ruvim/cli.rb +18 -3
  17. data/lib/ruvim/clipboard.rb +2 -0
  18. data/lib/ruvim/command_invocation.rb +3 -1
  19. data/lib/ruvim/command_line.rb +2 -0
  20. data/lib/ruvim/command_registry.rb +2 -0
  21. data/lib/ruvim/config_dsl.rb +2 -0
  22. data/lib/ruvim/config_loader.rb +2 -0
  23. data/lib/ruvim/context.rb +2 -0
  24. data/lib/ruvim/dispatcher.rb +143 -13
  25. data/lib/ruvim/display_width.rb +3 -0
  26. data/lib/ruvim/editor.rb +466 -71
  27. data/lib/ruvim/ex_command_registry.rb +2 -0
  28. data/lib/ruvim/file_watcher.rb +243 -0
  29. data/lib/ruvim/git/blame.rb +245 -0
  30. data/lib/ruvim/git/branch.rb +97 -0
  31. data/lib/ruvim/git/commit.rb +102 -0
  32. data/lib/ruvim/git/diff.rb +129 -0
  33. data/lib/ruvim/git/handler.rb +84 -0
  34. data/lib/ruvim/git/log.rb +41 -0
  35. data/lib/ruvim/git/status.rb +103 -0
  36. data/lib/ruvim/global_commands.rb +1066 -105
  37. data/lib/ruvim/highlighter.rb +19 -22
  38. data/lib/ruvim/input.rb +40 -28
  39. data/lib/ruvim/keymap_manager.rb +83 -0
  40. data/lib/ruvim/keyword_chars.rb +2 -0
  41. data/lib/ruvim/lang/base.rb +25 -0
  42. data/lib/ruvim/lang/csv.rb +18 -0
  43. data/lib/ruvim/lang/diff.rb +41 -0
  44. data/lib/ruvim/lang/json.rb +52 -0
  45. data/lib/ruvim/lang/markdown.rb +170 -0
  46. data/lib/ruvim/lang/ruby.rb +236 -0
  47. data/lib/ruvim/lang/scheme.rb +44 -0
  48. data/lib/ruvim/lang/tsv.rb +19 -0
  49. data/lib/ruvim/rich_view/json_renderer.rb +131 -0
  50. data/lib/ruvim/rich_view/jsonl_renderer.rb +57 -0
  51. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  52. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  53. data/lib/ruvim/rich_view.rb +109 -0
  54. data/lib/ruvim/screen.rb +503 -109
  55. data/lib/ruvim/terminal.rb +18 -1
  56. data/lib/ruvim/text_metrics.rb +2 -0
  57. data/lib/ruvim/version.rb +1 -1
  58. data/lib/ruvim/window.rb +2 -0
  59. data/lib/ruvim.rb +24 -0
  60. data/test/app_completion_test.rb +98 -0
  61. data/test/app_dot_repeat_test.rb +13 -0
  62. data/test/app_motion_test.rb +13 -0
  63. data/test/app_scenario_test.rb +898 -1
  64. data/test/app_startup_test.rb +187 -0
  65. data/test/arglist_test.rb +113 -0
  66. data/test/buffer_test.rb +49 -30
  67. data/test/cli_test.rb +14 -0
  68. data/test/clipboard_test.rb +67 -0
  69. data/test/command_line_test.rb +118 -0
  70. data/test/config_dsl_test.rb +87 -0
  71. data/test/dispatcher_test.rb +322 -0
  72. data/test/display_width_test.rb +41 -0
  73. data/test/editor_register_test.rb +23 -0
  74. data/test/file_watcher_test.rb +197 -0
  75. data/test/follow_test.rb +199 -0
  76. data/test/git_blame_test.rb +713 -0
  77. data/test/highlighter_test.rb +165 -0
  78. data/test/indent_test.rb +287 -0
  79. data/test/input_screen_integration_test.rb +40 -2
  80. data/test/markdown_renderer_test.rb +279 -0
  81. data/test/on_save_hook_test.rb +150 -0
  82. data/test/rich_view_test.rb +734 -0
  83. data/test/screen_test.rb +304 -0
  84. data/test/search_option_test.rb +19 -0
  85. data/test/test_helper.rb +9 -0
  86. metadata +49 -2
data/lib/ruvim/editor.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module RuVim
2
4
  class Editor
3
5
  OPTION_DEFS = {
@@ -45,20 +47,36 @@ module RuVim
45
47
  "expandtab" => { default_scope: :buffer, type: :bool, default: false },
46
48
  "shiftwidth" => { default_scope: :buffer, type: :int, default: 2 },
47
49
  "softtabstop" => { default_scope: :buffer, type: :int, default: 0 },
48
- "autoindent" => { default_scope: :buffer, type: :bool, default: false },
49
- "smartindent" => { default_scope: :buffer, type: :bool, default: false },
50
+ "autoindent" => { default_scope: :buffer, type: :bool, default: true },
51
+ "smartindent" => { default_scope: :buffer, type: :bool, default: true },
50
52
  "iskeyword" => { default_scope: :buffer, type: :string, default: nil },
51
53
  "tabstop" => { default_scope: :buffer, type: :int, default: 2 },
52
- "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" }
53
58
  }.freeze
54
-
55
- attr_reader :buffers, :windows
56
- attr_accessor :current_window_id, :mode, :message, :pending_count, :alternate_buffer_id, :window_layout, :restricted_mode, :current_window_view_height_hint
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, :git_stream_handler, :git_stream_stop_handler
57
75
 
58
76
  def initialize
59
77
  @buffers = {}
60
78
  @windows = {}
61
- @window_order = []
79
+ @layout_tree = nil
62
80
  @tabpages = []
63
81
  @current_tabpage_index = nil
64
82
  @next_tabpage_id = 1
@@ -68,7 +86,6 @@ module RuVim
68
86
  @current_window_id = nil
69
87
  @alternate_buffer_id = nil
70
88
  @mode = :normal
71
- @window_layout = :single
72
89
  @message = ""
73
90
  @message_kind = :info
74
91
  @message_deadline = nil
@@ -76,9 +93,14 @@ module RuVim
76
93
  @restricted_mode = false
77
94
  @current_window_view_height_hint = 1
78
95
  @running = true
96
+ @stdin_stream_stop_handler = nil
97
+ @open_path_handler = nil
98
+ @keymap_manager = nil
99
+ @app_action_handler = nil
79
100
  @global_options = default_global_options
80
101
  @command_line = CommandLine.new
81
102
  @last_search = nil
103
+ @hlsearch_suppressed = false
82
104
  @last_find = nil
83
105
  @registers = {}
84
106
  @active_register_name = nil
@@ -89,8 +111,12 @@ module RuVim
89
111
  @macros = {}
90
112
  @macro_recording = nil
91
113
  @visual_state = nil
114
+ @rich_state = nil
92
115
  @quickfix_list = { items: [], index: nil }
93
116
  @location_lists = Hash.new { |h, k| h[k] = { items: [], index: nil } }
117
+ @arglist = []
118
+ @arglist_index = 0
119
+ @hit_enter_lines = nil
94
120
  end
95
121
 
96
122
  def running?
@@ -127,6 +153,15 @@ module RuVim
127
153
 
128
154
  def set_last_search(pattern:, direction:)
129
155
  @last_search = { pattern: pattern.to_s, direction: direction.to_sym }
156
+ @hlsearch_suppressed = false
157
+ end
158
+
159
+ def suppress_hlsearch!
160
+ @hlsearch_suppressed = true
161
+ end
162
+
163
+ def hlsearch_suppressed?
164
+ @hlsearch_suppressed
130
165
  end
131
166
 
132
167
  def set_last_find(char:, direction:, till:)
@@ -141,6 +176,22 @@ module RuVim
141
176
  @buffers.fetch(current_window.buffer_id)
142
177
  end
143
178
 
179
+ def stdin_stream_stop_or_cancel!
180
+ handler = @stdin_stream_stop_handler
181
+ return false unless handler
182
+
183
+ handler.call
184
+ true
185
+ end
186
+
187
+ def invoke_app_action(name, **kwargs)
188
+ handler = @app_action_handler
189
+ return false unless handler
190
+
191
+ handler.call(name.to_sym, **kwargs)
192
+ true
193
+ end
194
+
144
195
  def option_def(name)
145
196
  OPTION_DEFS[name.to_s]
146
197
  end
@@ -213,7 +264,7 @@ module RuVim
213
264
  base = File.basename(p)
214
265
  return "ruby" if %w[Gemfile Rakefile Guardfile].include?(base)
215
266
 
216
- {
267
+ ext_ft = {
217
268
  ".rb" => "ruby",
218
269
  ".rake" => "ruby",
219
270
  ".ru" => "ruby",
@@ -225,14 +276,23 @@ module RuVim
225
276
  ".tsx" => "typescriptreact",
226
277
  ".jsx" => "javascriptreact",
227
278
  ".json" => "json",
279
+ ".jsonl" => "jsonl",
228
280
  ".yml" => "yaml",
229
281
  ".yaml" => "yaml",
230
282
  ".md" => "markdown",
231
283
  ".txt" => "text",
232
284
  ".html" => "html",
233
285
  ".css" => "css",
234
- ".sh" => "sh"
286
+ ".sh" => "sh",
287
+ ".tsv" => "tsv",
288
+ ".csv" => "csv",
289
+ ".scm" => "scheme",
290
+ ".ss" => "scheme",
291
+ ".sld" => "scheme"
235
292
  }[File.extname(base).downcase]
293
+ return ext_ft if ext_ft
294
+
295
+ detect_filetype_from_shebang(p)
236
296
  end
237
297
 
238
298
  def registers
@@ -481,6 +541,53 @@ module RuVim
481
541
  end
482
542
  end
483
543
 
544
+ def rich_state
545
+ @rich_state
546
+ end
547
+
548
+ def rich_mode?
549
+ @mode == :rich
550
+ end
551
+
552
+ def enter_rich_mode(format:, delimiter:)
553
+ @mode = :rich
554
+ @pending_count = nil
555
+ @rich_state = { format: format, delimiter: delimiter }
556
+ end
557
+
558
+ def exit_rich_mode
559
+ @rich_state = nil
560
+ enter_normal_mode
561
+ end
562
+
563
+ def hit_enter_active?
564
+ @mode == :hit_enter
565
+ end
566
+
567
+ def hit_enter_lines
568
+ @hit_enter_lines
569
+ end
570
+
571
+ def enter_hit_enter_mode(lines)
572
+ @mode = :hit_enter
573
+ @hit_enter_lines = Array(lines)
574
+ @pending_count = nil
575
+ end
576
+
577
+ def exit_hit_enter_mode
578
+ @hit_enter_lines = nil
579
+ enter_normal_mode
580
+ end
581
+
582
+ def echo_multiline(lines)
583
+ lines = Array(lines)
584
+ if lines.length <= 1
585
+ echo(lines.first.to_s)
586
+ else
587
+ enter_hit_enter_mode(lines)
588
+ end
589
+ end
590
+
484
591
  def add_empty_buffer(path: nil)
485
592
  id = next_buffer_id
486
593
  buffer = Buffer.new(id:, path:)
@@ -492,7 +599,7 @@ module RuVim
492
599
  def add_virtual_buffer(kind:, name:, lines:, filetype: nil, readonly: true, modifiable: false)
493
600
  id = next_buffer_id
494
601
  buffer = Buffer.new(id:, lines:, kind:, name:, readonly:, modifiable:)
495
- buffer.options["filetype"] = filetype if filetype
602
+ assign_filetype(buffer, filetype) if filetype
496
603
  @buffers[id] = buffer
497
604
  buffer
498
605
  end
@@ -509,7 +616,17 @@ module RuVim
509
616
  id = next_window_id
510
617
  window = Window.new(id:, buffer_id:)
511
618
  @windows[id] = window
512
- @window_order << id
619
+ leaf = { type: :window, id: id }
620
+ if @layout_tree.nil?
621
+ @layout_tree = leaf
622
+ else
623
+ # Append as sibling — used for initial bootstrap only
624
+ if @layout_tree[:type] == :window
625
+ @layout_tree = { type: :hsplit, children: [@layout_tree, leaf] }
626
+ else
627
+ @layout_tree[:children] << leaf
628
+ end
629
+ end
513
630
  @current_window_id ||= id
514
631
  ensure_initial_tabpage!
515
632
  save_current_tabpage_state! unless @suspend_tab_autosave
@@ -522,15 +639,17 @@ module RuVim
522
639
  id = next_window_id
523
640
  win = Window.new(id:, buffer_id: src.buffer_id)
524
641
  @windows[id] = win
525
- src_idx = @window_order.index(src.id) || (@window_order.length - 1)
526
- insert_idx = (place.to_sym == :before ? src_idx : src_idx + 1)
527
- @window_order.insert(insert_idx, win.id)
528
642
  ensure_initial_tabpage!
529
643
  win.cursor_x = src.cursor_x
530
644
  win.cursor_y = src.cursor_y
531
645
  win.row_offset = src.row_offset
532
646
  win.col_offset = src.col_offset
533
- @window_layout = layout.to_sym
647
+
648
+ split_type = (layout.to_sym == :vertical ? :vsplit : :hsplit)
649
+ new_leaf = { type: :window, id: win.id }
650
+
651
+ @layout_tree = tree_split_leaf(@layout_tree, src.id, split_type, new_leaf, place.to_sym)
652
+
534
653
  @current_window_id = win.id
535
654
  save_current_tabpage_state! unless @suspend_tab_autosave
536
655
  win
@@ -541,18 +660,21 @@ module RuVim
541
660
  end
542
661
 
543
662
  def close_window(id)
544
- return nil if @window_order.empty?
545
- return nil if @window_order.length <= 1
546
- return nil unless @window_order.include?(id)
663
+ leaves = tree_leaves(@layout_tree)
664
+ return nil if leaves.empty?
665
+ return nil if leaves.length <= 1
666
+ return nil unless leaves.include?(id)
547
667
 
548
668
  save_current_tabpage_state! unless @suspend_tab_autosave
549
- idx = @window_order.index(id) || 0
669
+ idx = leaves.index(id) || 0
550
670
  @windows.delete(id)
551
- @window_order.delete(id)
552
671
  @location_lists.delete(id)
553
- @current_window_id = @window_order[[idx, @window_order.length - 1].min] if @current_window_id == id
554
- @current_window_id ||= @window_order.first
555
- @window_layout = :single if @window_order.length <= 1
672
+
673
+ @layout_tree = tree_remove(@layout_tree, id)
674
+
675
+ new_leaves = tree_leaves(@layout_tree)
676
+ @current_window_id = new_leaves[[idx, new_leaves.length - 1].min] if @current_window_id == id
677
+ @current_window_id ||= new_leaves.first
556
678
  save_current_tabpage_state! unless @suspend_tab_autosave
557
679
  current_window
558
680
  end
@@ -563,7 +685,8 @@ module RuVim
563
685
 
564
686
  save_current_tabpage_state!
565
687
  removed = @tabpages.delete_at(@current_tabpage_index)
566
- Array(removed && removed[:window_order]).each do |wid|
688
+ removed_tree = removed && removed[:layout_tree]
689
+ tree_leaves(removed_tree).each do |wid|
567
690
  @windows.delete(wid)
568
691
  @location_lists.delete(wid)
569
692
  end
@@ -581,42 +704,55 @@ module RuVim
581
704
  end
582
705
 
583
706
  def focus_next_window
584
- return current_window if @window_order.length <= 1
707
+ order = window_order
708
+ return current_window if order.length <= 1
585
709
 
586
- idx = @window_order.index(@current_window_id) || 0
587
- focus_window(@window_order[(idx + 1) % @window_order.length])
710
+ idx = order.index(@current_window_id) || 0
711
+ focus_window(order[(idx + 1) % order.length])
588
712
  end
589
713
 
590
714
  def focus_prev_window
591
- return current_window if @window_order.length <= 1
715
+ order = window_order
716
+ return current_window if order.length <= 1
592
717
 
593
- idx = @window_order.index(@current_window_id) || 0
594
- focus_window(@window_order[(idx - 1) % @window_order.length])
718
+ idx = order.index(@current_window_id) || 0
719
+ focus_window(order[(idx - 1) % order.length])
595
720
  end
596
721
 
597
722
  def focus_window_direction(dir)
598
- return current_window if @window_order.length <= 1
599
-
600
- case @window_layout
601
- when :vertical
602
- if dir == :left
603
- focus_prev_window
604
- elsif dir == :right
605
- focus_next_window
606
- else
607
- current_window
723
+ leaves = tree_leaves(@layout_tree)
724
+ return current_window if leaves.length <= 1
725
+
726
+ rects = tree_compute_rects(@layout_tree, top: 0.0, left: 0.0, height: 1.0, width: 1.0)
727
+ cur = rects[@current_window_id]
728
+ return current_window unless cur
729
+
730
+ best_id = nil
731
+ best_dist = Float::INFINITY
732
+ cur_cx = cur[:left] + cur[:width] / 2.0
733
+ cur_cy = cur[:top] + cur[:height] / 2.0
734
+
735
+ rects.each do |wid, r|
736
+ next if wid == @current_window_id
737
+ rcx = r[:left] + r[:width] / 2.0
738
+ rcy = r[:top] + r[:height] / 2.0
739
+
740
+ in_direction = case dir
741
+ when :left then rcx < cur_cx
742
+ when :right then rcx > cur_cx
743
+ when :up then rcy < cur_cy
744
+ when :down then rcy > cur_cy
745
+ end
746
+ next unless in_direction
747
+
748
+ dist = (rcx - cur_cx).abs + (rcy - cur_cy).abs
749
+ if dist < best_dist
750
+ best_dist = dist
751
+ best_id = wid
608
752
  end
609
- when :horizontal
610
- if dir == :up
611
- focus_prev_window
612
- elsif dir == :down
613
- focus_next_window
614
- else
615
- current_window
616
- end
617
- else
618
- focus_next_window
619
753
  end
754
+
755
+ best_id ? focus_window(best_id) : current_window
620
756
  end
621
757
 
622
758
  def switch_to_buffer(buffer_id)
@@ -634,9 +770,16 @@ module RuVim
634
770
  end
635
771
 
636
772
  def open_path(path)
773
+ handler = @open_path_handler
774
+ return handler.call(path) if handler
775
+
776
+ open_path_sync(path)
777
+ end
778
+
779
+ def open_path_sync(path)
637
780
  buffer = add_buffer_from_file(path)
638
781
  switch_to_buffer(buffer.id)
639
- echo(path && File.exist?(path) ? "\"#{path}\" #{buffer.line_count}L" : "\"#{path}\" [New File]")
782
+ echo("[New File]") unless path && File.exist?(path)
640
783
  buffer
641
784
  end
642
785
 
@@ -672,7 +815,7 @@ module RuVim
672
815
  current_buffer.replace_all_lines!(intro_lines)
673
816
  current_buffer.configure_special!(kind: :intro, name: "[Intro]", readonly: true, modifiable: false)
674
817
  current_buffer.modified = false
675
- current_buffer.options["filetype"] = "help"
818
+ assign_filetype(current_buffer, "help")
676
819
  current_window.cursor_x = 0
677
820
  current_window.cursor_y = 0
678
821
  current_window.row_offset = 0
@@ -685,7 +828,7 @@ module RuVim
685
828
  return false unless current_buffer.intro_buffer?
686
829
 
687
830
  current_buffer.become_normal_empty_buffer!
688
- current_buffer.options["filetype"] = nil
831
+ assign_filetype(current_buffer, nil)
689
832
  current_window.cursor_x = 0
690
833
  current_window.cursor_y = 0
691
834
  current_window.row_offset = 0
@@ -745,7 +888,16 @@ module RuVim
745
888
  end
746
889
 
747
890
  def window_order
748
- @window_order
891
+ tree_leaves(@layout_tree)
892
+ end
893
+
894
+ def window_layout
895
+ return :single if @layout_tree.nil? || @layout_tree[:type] == :window
896
+ case @layout_tree[:type]
897
+ when :vsplit then :vertical
898
+ when :hsplit then :horizontal
899
+ else :single
900
+ end
749
901
  end
750
902
 
751
903
  def tabpages
@@ -764,8 +916,12 @@ module RuVim
764
916
  @tabpages.length
765
917
  end
766
918
 
919
+ def tabpage_windows(tab)
920
+ tree_leaves(tab[:layout_tree])
921
+ end
922
+
767
923
  def window_count
768
- @window_order.length
924
+ tree_leaves(@layout_tree).length
769
925
  end
770
926
 
771
927
  def quickfix_items
@@ -778,7 +934,7 @@ module RuVim
778
934
 
779
935
  def set_quickfix_list(items)
780
936
  ary = Array(items).map { |it| normalize_location(it)&.merge(text: (it[:text] || it["text"]).to_s) }.compact
781
- @quickfix_list = { items: ary, index: ary.empty? ? nil : 0 }
937
+ @quickfix_list = { items: ary, index: nil }
782
938
  @quickfix_list
783
939
  end
784
940
 
@@ -791,7 +947,12 @@ module RuVim
791
947
  items = @quickfix_list[:items]
792
948
  return nil if items.empty?
793
949
 
794
- @quickfix_list[:index] = ((@quickfix_list[:index] || 0) + step.to_i) % items.length
950
+ cur = @quickfix_list[:index]
951
+ @quickfix_list[:index] = if cur.nil?
952
+ step.to_i > 0 ? 0 : items.length - 1
953
+ else
954
+ (cur + step.to_i) % items.length
955
+ end
795
956
  current_quickfix_item
796
957
  end
797
958
 
@@ -814,7 +975,7 @@ module RuVim
814
975
 
815
976
  def set_location_list(items, window_id: current_window_id)
816
977
  ary = Array(items).map { |it| normalize_location(it)&.merge(text: (it[:text] || it["text"]).to_s) }.compact
817
- @location_lists[window_id] = { items: ary, index: ary.empty? ? nil : 0 }
978
+ @location_lists[window_id] = { items: ary, index: nil }
818
979
  @location_lists[window_id]
819
980
  end
820
981
 
@@ -829,7 +990,12 @@ module RuVim
829
990
  items = list[:items]
830
991
  return nil if items.empty?
831
992
 
832
- list[:index] = ((list[:index] || 0) + step.to_i) % items.length
993
+ cur = list[:index]
994
+ list[:index] = if cur.nil?
995
+ step.to_i > 0 ? 0 : items.length - 1
996
+ else
997
+ (cur + step.to_i) % items.length
998
+ end
833
999
  current_location_list_item(window_id)
834
1000
  end
835
1001
 
@@ -843,9 +1009,22 @@ module RuVim
843
1009
  current_location_list_item(window_id)
844
1010
  end
845
1011
 
1012
+ # Check if the path from root to the current window passes through
1013
+ # a split node matching the direction's axis. Used by focus_or_split
1014
+ # to decide whether to split or stay put at edges.
1015
+ # left/right → check for :vsplit ancestor
1016
+ # up/down → check for :hsplit ancestor
1017
+ def has_split_ancestor_on_axis?(dir)
1018
+ target_type = case dir
1019
+ when :left, :right then :vsplit
1020
+ when :up, :down then :hsplit
1021
+ end
1022
+ tree_path_has_split_type?(@layout_tree, @current_window_id, target_type)
1023
+ end
1024
+
846
1025
  def find_window_ids_by_buffer_kind(kind)
847
1026
  sym = kind.to_sym
848
- @window_order.select do |wid|
1027
+ window_order.select do |wid|
849
1028
  win = @windows[wid]
850
1029
  buf = win && @buffers[win.buffer_id]
851
1030
  buf && buf.kind == sym
@@ -857,9 +1036,8 @@ module RuVim
857
1036
  save_current_tabpage_state!
858
1037
 
859
1038
  with_tab_autosave_suspended do
860
- @window_order = []
1039
+ @layout_tree = nil
861
1040
  @current_window_id = nil
862
- @window_layout = :single
863
1041
 
864
1042
  buffer = path ? add_buffer_from_file(path) : add_empty_buffer
865
1043
  add_window(buffer_id: buffer.id)
@@ -886,6 +1064,7 @@ module RuVim
886
1064
  @mode = :normal
887
1065
  @pending_count = nil
888
1066
  clear_visual
1067
+ @rich_state = nil
889
1068
  end
890
1069
 
891
1070
  def enter_insert_mode
@@ -901,7 +1080,16 @@ module RuVim
901
1080
 
902
1081
  def cancel_command_line
903
1082
  @command_line.clear
904
- enter_normal_mode
1083
+ leave_command_line
1084
+ end
1085
+
1086
+ def leave_command_line
1087
+ if @rich_state
1088
+ @mode = :rich
1089
+ @pending_count = nil
1090
+ else
1091
+ enter_normal_mode
1092
+ end
905
1093
  end
906
1094
 
907
1095
  def echo(msg)
@@ -974,8 +1162,69 @@ module RuVim
974
1162
  command_line_active? || message_error?
975
1163
  end
976
1164
 
1165
+ def assign_filetype(buffer, ft)
1166
+ buffer.options["filetype"] = ft
1167
+ buffer.lang_module = resolve_lang_module(ft)
1168
+ end
1169
+
977
1170
  private
978
1171
 
1172
+ def resolve_lang_module(ft)
1173
+ case ft
1174
+ when "ruby" then Lang::Ruby
1175
+ else Lang::Base
1176
+ end
1177
+ end
1178
+
1179
+ def detect_filetype_from_shebang(path)
1180
+ line = read_first_line(path)
1181
+ return nil unless line.start_with?("#!")
1182
+
1183
+ cmd = shebang_command_name(line)
1184
+ return nil if cmd.nil? || cmd.empty?
1185
+
1186
+ rule = SHEBANG_FILETYPE_RULES.find do |matcher, _filetype|
1187
+ matcher.is_a?(Regexp) ? matcher.match?(cmd) : matcher.to_s == cmd
1188
+ end
1189
+ rule && rule[1]
1190
+ rescue StandardError
1191
+ nil
1192
+ end
1193
+
1194
+ def read_first_line(path)
1195
+ return "" unless path && !path.empty?
1196
+ return "" unless File.file?(path)
1197
+
1198
+ File.open(path, "rb") do |f|
1199
+ (f.gets || "").to_s.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
1200
+ end
1201
+ rescue StandardError
1202
+ ""
1203
+ end
1204
+
1205
+ def shebang_command_name(line)
1206
+ src = line.to_s.sub(/\A#!/, "").strip
1207
+ return nil if src.empty?
1208
+
1209
+ tokens = src.split(/\s+/)
1210
+ return nil if tokens.empty?
1211
+
1212
+ prog = tokens[0].to_s
1213
+ if File.basename(prog) == "env"
1214
+ i = 1
1215
+ while i < tokens.length && tokens[i].start_with?("-")
1216
+ if tokens[i] == "-S"
1217
+ i += 1
1218
+ break
1219
+ end
1220
+ i += 1
1221
+ end
1222
+ prog = tokens[i].to_s
1223
+ end
1224
+
1225
+ File.basename(prog.to_s)
1226
+ end
1227
+
979
1228
  def default_global_options
980
1229
  OPTION_DEFS.each_with_object({}) { |(k, v), h| h[k] = v[:default] }
981
1230
  end
@@ -1033,7 +1282,7 @@ module RuVim
1033
1282
 
1034
1283
  def assign_detected_filetype(buffer)
1035
1284
  ft = detect_filetype(buffer.path)
1036
- buffer.options["filetype"] = ft if ft && !ft.empty?
1285
+ assign_filetype(buffer, ft) if ft && !ft.empty?
1037
1286
  buffer
1038
1287
  end
1039
1288
 
@@ -1070,6 +1319,51 @@ module RuVim
1070
1319
  current_location
1071
1320
  end
1072
1321
 
1322
+ def arglist
1323
+ @arglist.dup
1324
+ end
1325
+
1326
+ def arglist_index
1327
+ @arglist_index
1328
+ end
1329
+
1330
+ def set_arglist(paths)
1331
+ @arglist = Array(paths).dup
1332
+ @arglist_index = 0
1333
+ end
1334
+
1335
+ def arglist_current
1336
+ @arglist[@arglist_index] if @arglist_index < @arglist.length
1337
+ end
1338
+
1339
+ def arglist_next(count = 1)
1340
+ new_index = @arglist_index + count
1341
+ if new_index >= @arglist.length
1342
+ raise RuVim::CommandError, "Already at last argument"
1343
+ end
1344
+ @arglist_index = new_index
1345
+ @arglist[@arglist_index]
1346
+ end
1347
+
1348
+ def arglist_prev(count = 1)
1349
+ new_index = @arglist_index - count
1350
+ if new_index < 0
1351
+ raise RuVim::CommandError, "Already at first argument"
1352
+ end
1353
+ @arglist_index = new_index
1354
+ @arglist[@arglist_index]
1355
+ end
1356
+
1357
+ def arglist_first
1358
+ @arglist_index = 0
1359
+ @arglist[@arglist_index] if @arglist.length > 0
1360
+ end
1361
+
1362
+ def arglist_last
1363
+ @arglist_index = [@arglist.length - 1, 0].max
1364
+ @arglist[@arglist_index] if @arglist.length > 0
1365
+ end
1366
+
1073
1367
  private
1074
1368
 
1075
1369
  def first_nonblank_col(buffer, row)
@@ -1136,7 +1430,7 @@ module RuVim
1136
1430
 
1137
1431
  def ensure_initial_tabpage!
1138
1432
  return unless @tabpages.empty?
1139
- return if @window_order.empty?
1433
+ return if @layout_tree.nil?
1140
1434
 
1141
1435
  @tabpages << new_tabpage_snapshot
1142
1436
  @current_tabpage_index = 0
@@ -1150,18 +1444,16 @@ module RuVim
1150
1444
  end
1151
1445
 
1152
1446
  def load_tabpage_state!(tab)
1153
- @window_order = tab[:window_order].dup
1447
+ @layout_tree = tree_deep_dup(tab[:layout_tree])
1154
1448
  @current_window_id = tab[:current_window_id]
1155
- @window_layout = tab[:window_layout]
1156
1449
  current_window
1157
1450
  end
1158
1451
 
1159
1452
  def new_tabpage_snapshot(id: nil)
1160
1453
  {
1161
1454
  id: id || next_tabpage_id,
1162
- window_order: @window_order.dup,
1163
- current_window_id: @current_window_id,
1164
- window_layout: @window_layout
1455
+ layout_tree: tree_deep_dup(@layout_tree),
1456
+ current_window_id: @current_window_id
1165
1457
  }
1166
1458
  end
1167
1459
 
@@ -1190,5 +1482,108 @@ module RuVim
1190
1482
  ensure
1191
1483
  @suspend_tab_autosave = prev
1192
1484
  end
1485
+
1486
+ # --- Layout tree helpers ---
1487
+
1488
+ def tree_leaves(node)
1489
+ return [] if node.nil?
1490
+ return [node[:id]] if node[:type] == :window
1491
+
1492
+ node[:children].flat_map { |c| tree_leaves(c) }
1493
+ end
1494
+
1495
+ def tree_deep_dup(node)
1496
+ return nil if node.nil?
1497
+ return { type: :window, id: node[:id] } if node[:type] == :window
1498
+
1499
+ { type: node[:type], children: node[:children].map { |c| tree_deep_dup(c) } }
1500
+ end
1501
+
1502
+ # Split a leaf node into a new split node. If the parent is already the same
1503
+ # split type, merge into the parent instead of nesting.
1504
+ def tree_split_leaf(node, target_id, split_type, new_leaf, place)
1505
+ if node[:type] == :window
1506
+ if node[:id] == target_id
1507
+ children = place == :before ? [new_leaf, node] : [node, new_leaf]
1508
+ return { type: split_type, children: children }
1509
+ end
1510
+ return node
1511
+ end
1512
+
1513
+ new_children = node[:children].flat_map do |child|
1514
+ result = tree_split_leaf(child, target_id, split_type, new_leaf, place)
1515
+ # If the child was replaced with the same split type as this node, merge
1516
+ if result[:type] == node[:type] && result != child
1517
+ result[:children]
1518
+ else
1519
+ [result]
1520
+ end
1521
+ end
1522
+
1523
+ { type: node[:type], children: new_children }
1524
+ end
1525
+
1526
+ def tree_remove(node, target_id)
1527
+ return nil if node.nil?
1528
+ return nil if node[:type] == :window && node[:id] == target_id
1529
+ return node if node[:type] == :window
1530
+
1531
+ new_children = node[:children].filter_map { |c| tree_remove(c, target_id) }
1532
+ return nil if new_children.empty?
1533
+ return new_children.first if new_children.length == 1
1534
+
1535
+ { type: node[:type], children: new_children }
1536
+ end
1537
+
1538
+ # Check if the path from root to the target window passes through
1539
+ # a split node of the given type. Returns true if any ancestor of the
1540
+ # target window in the tree has type == split_type.
1541
+ def tree_path_has_split_type?(node, target_id, split_type)
1542
+ return false if node.nil?
1543
+ return false if node[:type] == :window
1544
+ # Check if target is in any child subtree
1545
+ node[:children].each do |child|
1546
+ if tree_subtree_contains?(child, target_id)
1547
+ return true if node[:type] == split_type
1548
+ return tree_path_has_split_type?(child, target_id, split_type)
1549
+ end
1550
+ end
1551
+ false
1552
+ end
1553
+
1554
+ def tree_subtree_contains?(node, target_id)
1555
+ return false if node.nil?
1556
+ return node[:id] == target_id if node[:type] == :window
1557
+ node[:children].any? { |c| tree_subtree_contains?(c, target_id) }
1558
+ end
1559
+
1560
+ # Compute normalized rects (0.0–1.0 coordinate space) for direction finding.
1561
+ # Separators are ignored here — this is purely for adjacency/direction logic.
1562
+ def tree_compute_rects(node, top:, left:, height:, width:)
1563
+ return {} if node.nil?
1564
+
1565
+ if node[:type] == :window
1566
+ return { node[:id] => { top: top, left: left, height: height, width: width } }
1567
+ end
1568
+
1569
+ children = node[:children]
1570
+ n = children.length
1571
+ rects = {}
1572
+
1573
+ case node[:type]
1574
+ when :vsplit
1575
+ w_each = width / n.to_f
1576
+ children.each_with_index do |child, i|
1577
+ rects.merge!(tree_compute_rects(child, top: top, left: left + i * w_each, height: height, width: w_each))
1578
+ end
1579
+ when :hsplit
1580
+ h_each = height / n.to_f
1581
+ children.each_with_index do |child, i|
1582
+ rects.merge!(tree_compute_rects(child, top: top + i * h_each, left: left, height: h_each, width: width))
1583
+ end
1584
+ end
1585
+
1586
+ rects
1587
+ end
1193
1588
  end
1194
1589
  end