ruvim 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +4 -0
  3. data/AGENTS.md +84 -0
  4. data/CLAUDE.md +1 -0
  5. data/docs/binding.md +23 -0
  6. data/docs/command.md +85 -0
  7. data/docs/config.md +2 -2
  8. data/docs/done.md +21 -0
  9. data/docs/spec.md +157 -12
  10. data/docs/todo.md +1 -5
  11. data/docs/vim_diff.md +94 -172
  12. data/lib/ruvim/app.rb +882 -69
  13. data/lib/ruvim/buffer.rb +35 -1
  14. data/lib/ruvim/cli.rb +12 -3
  15. data/lib/ruvim/clipboard.rb +2 -0
  16. data/lib/ruvim/command_invocation.rb +3 -1
  17. data/lib/ruvim/command_line.rb +2 -0
  18. data/lib/ruvim/command_registry.rb +2 -0
  19. data/lib/ruvim/config_dsl.rb +2 -0
  20. data/lib/ruvim/config_loader.rb +2 -0
  21. data/lib/ruvim/context.rb +2 -0
  22. data/lib/ruvim/dispatcher.rb +143 -13
  23. data/lib/ruvim/display_width.rb +3 -0
  24. data/lib/ruvim/editor.rb +455 -71
  25. data/lib/ruvim/ex_command_registry.rb +2 -0
  26. data/lib/ruvim/global_commands.rb +890 -63
  27. data/lib/ruvim/highlighter.rb +16 -21
  28. data/lib/ruvim/input.rb +39 -28
  29. data/lib/ruvim/keymap_manager.rb +83 -0
  30. data/lib/ruvim/keyword_chars.rb +2 -0
  31. data/lib/ruvim/lang/base.rb +25 -0
  32. data/lib/ruvim/lang/csv.rb +18 -0
  33. data/lib/ruvim/lang/json.rb +18 -0
  34. data/lib/ruvim/lang/markdown.rb +170 -0
  35. data/lib/ruvim/lang/ruby.rb +236 -0
  36. data/lib/ruvim/lang/scheme.rb +44 -0
  37. data/lib/ruvim/lang/tsv.rb +19 -0
  38. data/lib/ruvim/rich_view/markdown_renderer.rb +248 -0
  39. data/lib/ruvim/rich_view/table_renderer.rb +176 -0
  40. data/lib/ruvim/rich_view.rb +93 -0
  41. data/lib/ruvim/screen.rb +503 -106
  42. data/lib/ruvim/terminal.rb +18 -1
  43. data/lib/ruvim/text_metrics.rb +2 -0
  44. data/lib/ruvim/version.rb +1 -1
  45. data/lib/ruvim/window.rb +2 -0
  46. data/lib/ruvim.rb +14 -0
  47. data/test/app_completion_test.rb +73 -0
  48. data/test/app_dot_repeat_test.rb +13 -0
  49. data/test/app_motion_test.rb +13 -0
  50. data/test/app_scenario_test.rb +729 -1
  51. data/test/app_startup_test.rb +187 -0
  52. data/test/arglist_test.rb +113 -0
  53. data/test/buffer_test.rb +49 -30
  54. data/test/dispatcher_test.rb +322 -0
  55. data/test/editor_register_test.rb +23 -0
  56. data/test/highlighter_test.rb +121 -0
  57. data/test/indent_test.rb +201 -0
  58. data/test/input_screen_integration_test.rb +40 -2
  59. data/test/markdown_renderer_test.rb +279 -0
  60. data/test/on_save_hook_test.rb +150 -0
  61. data/test/rich_view_test.rb +478 -0
  62. data/test/screen_test.rb +304 -0
  63. metadata +33 -2
@@ -1,6 +1,7 @@
1
1
  require_relative "test_helper"
2
2
  require "fileutils"
3
3
  require "tmpdir"
4
+ require "stringio"
4
5
 
5
6
  class AppScenarioTest < Minitest::Test
6
7
  def setup
@@ -78,6 +79,79 @@ class AppScenarioTest < Minitest::Test
78
79
  assert_equal ["bc", "bc", "bc"], @editor.current_buffer.lines
79
80
  end
80
81
 
82
+ def test_s_substitutes_char_and_enters_insert_mode
83
+ @editor.current_buffer.replace_all_lines!(["abcd"])
84
+ @editor.current_window.cursor_x = 1
85
+
86
+ feed("s", "X", :escape)
87
+
88
+ assert_equal ["aXcd"], @editor.current_buffer.lines
89
+ assert_equal :normal, @editor.mode
90
+ end
91
+
92
+ def test_z_commands_reposition_current_line_in_window
93
+ @editor.current_buffer.replace_all_lines!((1..20).map { |i| "line#{i}" })
94
+ @editor.current_window_view_height_hint = 5
95
+ @editor.current_window.cursor_y = 10
96
+
97
+ feed("z", "t")
98
+ assert_equal 10, @editor.current_window.row_offset
99
+
100
+ feed("z", "z")
101
+ assert_equal 8, @editor.current_window.row_offset
102
+
103
+ feed("z", "b")
104
+ assert_equal 6, @editor.current_window.row_offset
105
+ end
106
+
107
+ def test_j_joins_next_line_trimming_indent
108
+ @editor.current_buffer.replace_all_lines!(["foo", " bar", "baz"])
109
+ @editor.current_window.cursor_y = 0
110
+ @editor.current_window.cursor_x = 0
111
+
112
+ feed("J")
113
+
114
+ assert_equal ["foo bar", "baz"], @editor.current_buffer.lines
115
+ assert_equal 3, @editor.current_window.cursor_x
116
+ end
117
+
118
+ def test_uppercase_aliases_d_c_s_x_y_and_tilde
119
+ @editor.current_buffer.replace_all_lines!(["abcd"])
120
+ @editor.current_window.cursor_x = 2
121
+ feed("X")
122
+ assert_equal ["acd"], @editor.current_buffer.lines
123
+
124
+ @editor.current_buffer.replace_all_lines!(["abcd"])
125
+ @editor.current_window.cursor_x = 1
126
+ feed("D")
127
+ assert_equal ["a"], @editor.current_buffer.lines
128
+
129
+ @editor.current_buffer.replace_all_lines!(["abcd"])
130
+ @editor.current_window.cursor_x = 1
131
+ feed("C", "X", :escape)
132
+ assert_equal ["aX"], @editor.current_buffer.lines
133
+ assert_equal :normal, @editor.mode
134
+
135
+ @editor.current_buffer.replace_all_lines!(["Abcd"])
136
+ @editor.current_window.cursor_x = 0
137
+ feed("~")
138
+ assert_equal ["abcd"], @editor.current_buffer.lines
139
+ assert_equal 1, @editor.current_window.cursor_x
140
+
141
+ @editor.current_buffer.replace_all_lines!(["hello"])
142
+ @editor.current_window.cursor_x = 0
143
+ feed("Y")
144
+ reg = @editor.get_register("\"")
145
+ assert_equal :linewise, reg[:type]
146
+ assert_equal "hello\n", reg[:text]
147
+
148
+ @editor.current_buffer.replace_all_lines!(["hello"])
149
+ @editor.current_window.cursor_x = 0
150
+ feed("S", "x", :escape)
151
+ assert_equal ["x"], @editor.current_buffer.lines
152
+ assert_equal :normal, @editor.mode
153
+ end
154
+
81
155
  def test_expandtab_and_autoindent_in_insert_mode
82
156
  @editor.set_option("expandtab", true, scope: :buffer)
83
157
  @editor.set_option("tabstop", 4, scope: :buffer)
@@ -103,6 +177,62 @@ class AppScenarioTest < Minitest::Test
103
177
  assert_equal ["if x {", " "], @editor.current_buffer.lines
104
178
  end
105
179
 
180
+ def test_smartindent_adds_shiftwidth_after_ruby_def
181
+ @editor.set_option("autoindent", true, scope: :buffer)
182
+ @editor.set_option("smartindent", true, scope: :buffer)
183
+ @editor.set_option("shiftwidth", 2, scope: :buffer)
184
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
185
+ @editor.current_buffer.replace_all_lines!(["def foo"])
186
+ @editor.current_window.cursor_y = 0
187
+ @editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
188
+
189
+ feed("A", :enter, :escape)
190
+
191
+ assert_equal ["def foo", " "], @editor.current_buffer.lines
192
+ end
193
+
194
+ def test_smartindent_adds_shiftwidth_after_ruby_do_block
195
+ @editor.set_option("autoindent", true, scope: :buffer)
196
+ @editor.set_option("smartindent", true, scope: :buffer)
197
+ @editor.set_option("shiftwidth", 2, scope: :buffer)
198
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
199
+ @editor.current_buffer.replace_all_lines!([" items.each do |x|"])
200
+ @editor.current_window.cursor_y = 0
201
+ @editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
202
+
203
+ feed("A", :enter, :escape)
204
+
205
+ assert_equal [" items.each do |x|", " "], @editor.current_buffer.lines
206
+ end
207
+
208
+ def test_smartindent_dedents_end_in_insert_mode
209
+ @editor.set_option("autoindent", true, scope: :buffer)
210
+ @editor.set_option("smartindent", true, scope: :buffer)
211
+ @editor.set_option("shiftwidth", 2, scope: :buffer)
212
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
213
+ @editor.current_buffer.replace_all_lines!(["def foo"])
214
+ @editor.current_window.cursor_y = 0
215
+ @editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
216
+
217
+ feed("A", :enter, "b", "a", "r", :enter, "e", "n", "d", :escape)
218
+
219
+ assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
220
+ end
221
+
222
+ def test_smartindent_dedents_else_in_insert_mode
223
+ @editor.set_option("autoindent", true, scope: :buffer)
224
+ @editor.set_option("smartindent", true, scope: :buffer)
225
+ @editor.set_option("shiftwidth", 2, scope: :buffer)
226
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
227
+ @editor.current_buffer.replace_all_lines!(["if cond"])
228
+ @editor.current_window.cursor_y = 0
229
+ @editor.current_window.cursor_x = @editor.current_buffer.line_length(0)
230
+
231
+ feed("A", :enter, "a", :enter, "e", "l", "s", "e", :escape)
232
+
233
+ assert_equal ["if cond", " a", "else"], @editor.current_buffer.lines
234
+ end
235
+
106
236
  def test_incsearch_moves_cursor_while_typing_and_escape_restores
107
237
  @editor.set_option("incsearch", true, scope: :global)
108
238
  @editor.current_buffer.replace_all_lines!(["alpha", "beta", "gamma"])
@@ -218,6 +348,87 @@ class AppScenarioTest < Minitest::Test
218
348
  assert_equal [1, 0], [@editor.current_window.cursor_y, @editor.current_window.cursor_x]
219
349
  end
220
350
 
351
+ def test_nomodifiable_buffer_edit_key_does_not_crash
352
+ @editor.current_buffer.replace_all_lines!(["hello"])
353
+ @editor.current_buffer.modifiable = false
354
+ @editor.current_buffer.readonly = true
355
+
356
+ @app.send(:handle_key, "x")
357
+
358
+ assert_equal ["hello"], @editor.current_buffer.lines
359
+ assert_match(/not modifiable/i, @editor.message)
360
+ end
361
+
362
+ def test_nomodifiable_buffer_insert_mode_is_rejected
363
+ @editor.current_buffer.replace_all_lines!(["hello"])
364
+ @editor.current_buffer.modifiable = false
365
+ @editor.current_buffer.readonly = true
366
+
367
+ @app.send(:handle_key, "i")
368
+
369
+ assert_equal :normal, @editor.mode
370
+ assert_equal ["hello"], @editor.current_buffer.lines
371
+ assert_match(/not modifiable/i, @editor.message)
372
+ end
373
+
374
+ def test_normal_ctrl_c_stops_stdin_stream_via_default_binding
375
+ stream = StringIO.new("hello\n")
376
+ @app.instance_variable_set(:@stdin_stream_source, stream)
377
+ @app.send(:prepare_stdin_stream_buffer!)
378
+
379
+ @app.send(:handle_key, :ctrl_c)
380
+
381
+ assert_equal :closed, @editor.current_buffer.stream_state
382
+ assert_equal :normal, @editor.mode
383
+ assert_equal true, stream.closed?
384
+ assert_match(/\[stdin\] closed/, @editor.message)
385
+ end
386
+
387
+ def test_ctrl_z_calls_terminal_suspend
388
+ terminal_stub = Object.new
389
+ terminal_stub.instance_variable_set(:@suspend_calls, 0)
390
+ terminal_stub.define_singleton_method(:suspend_for_tstp) do
391
+ @suspend_calls += 1
392
+ end
393
+ terminal_stub.define_singleton_method(:suspend_calls) { @suspend_calls }
394
+ @app.instance_variable_set(:@terminal, terminal_stub)
395
+
396
+ feed("i", "a", :ctrl_z)
397
+
398
+ assert_equal 1, terminal_stub.suspend_calls
399
+ assert_equal :insert, @editor.mode
400
+ assert_equal ["a"], @editor.current_buffer.lines
401
+ end
402
+
403
+ def test_ctrl_z_invalidates_screen_cache_for_full_redraw_after_fg
404
+ terminal_stub = Object.new
405
+ terminal_stub.define_singleton_method(:suspend_for_tstp) {}
406
+ @app.instance_variable_set(:@terminal, terminal_stub)
407
+
408
+ screen_stub = Object.new
409
+ screen_stub.instance_variable_set(:@invalidated, false)
410
+ screen_stub.define_singleton_method(:invalidate_cache!) do
411
+ @invalidated = true
412
+ end
413
+ screen_stub.define_singleton_method(:invalidated?) { @invalidated }
414
+ @app.instance_variable_set(:@screen, screen_stub)
415
+
416
+ feed(:ctrl_z)
417
+
418
+ assert_equal true, screen_stub.invalidated?
419
+ end
420
+
421
+ def test_g_and_1g_distinguish_implicit_and_explicit_count
422
+ @editor.current_buffer.replace_all_lines!(%w[a b c d])
423
+ @editor.current_window.cursor_y = 1
424
+
425
+ feed("G")
426
+ assert_equal 3, @editor.current_window.cursor_y
427
+
428
+ feed("1", "G")
429
+ assert_equal 0, @editor.current_window.cursor_y
430
+ end
431
+
221
432
  def test_backspace_indent_allows_deleting_autoindent_before_insert_start
222
433
  @editor.set_option("backspace", "indent,eol", scope: :global)
223
434
  @editor.current_buffer.replace_all_lines!([" abc"])
@@ -281,6 +492,23 @@ class AppScenarioTest < Minitest::Test
281
492
  end
282
493
  end
283
494
 
495
+ def test_gf_supports_file_with_line_number_suffix
496
+ Dir.mktmpdir("ruvim-gf-line") do |dir|
497
+ target = File.join(dir, "foo.rb")
498
+ File.write(target, "line1\nline2\nline3\n")
499
+ @editor.current_buffer.path = File.join(dir, "main.txt")
500
+ @editor.current_buffer.replace_all_lines!(["foo.rb:3"])
501
+ @editor.current_window.cursor_y = 0
502
+ @editor.current_window.cursor_x = 3
503
+ @editor.set_option("hidden", true, scope: :global)
504
+
505
+ feed("g", "f")
506
+
507
+ assert_equal File.expand_path(target), File.expand_path(@editor.current_buffer.path)
508
+ assert_equal 2, @editor.current_window.cursor_y
509
+ end
510
+ end
511
+
284
512
  def test_showmatch_message_respects_matchtime_and_clears
285
513
  @editor.set_option("showmatch", true, scope: :global)
286
514
  @editor.set_option("matchtime", 1, scope: :global) # 0.1 sec
@@ -323,7 +551,7 @@ class AppScenarioTest < Minitest::Test
323
551
  @editor.current_buffer.replace_all_lines!(["a", "b"])
324
552
  @editor.current_window.cursor_y = 0
325
553
 
326
- feed("z")
554
+ feed("_")
327
555
  assert @editor.message_error?
328
556
  assert_match(/Unknown key:/, @editor.message)
329
557
 
@@ -344,4 +572,504 @@ class AppScenarioTest < Minitest::Test
344
572
  assert_equal "", @editor.message
345
573
  assert_equal 1, @editor.current_window.cursor_y
346
574
  end
575
+
576
+ # --- hit-enter prompt tests ---
577
+
578
+ def test_ls_with_multiple_buffers_enters_hit_enter_mode
579
+ @editor.add_empty_buffer(path: "second.rb")
580
+ @dispatcher.dispatch_ex(@editor, "ls")
581
+
582
+ assert_equal :hit_enter, @editor.mode
583
+ assert_instance_of Array, @editor.hit_enter_lines
584
+ assert_operator @editor.hit_enter_lines.length, :>=, 2
585
+ end
586
+
587
+ def test_ls_with_single_buffer_uses_normal_echo
588
+ @dispatcher.dispatch_ex(@editor, "ls")
589
+
590
+ refute_equal :hit_enter, @editor.mode
591
+ refute_nil @editor.message
592
+ refute @editor.message.to_s.empty?
593
+ end
594
+
595
+ def test_hit_enter_dismiss_with_enter
596
+ @editor.add_empty_buffer(path: "second.rb")
597
+ @dispatcher.dispatch_ex(@editor, "ls")
598
+ assert_equal :hit_enter, @editor.mode
599
+
600
+ feed(:enter)
601
+
602
+ assert_equal :normal, @editor.mode
603
+ assert_nil @editor.hit_enter_lines
604
+ end
605
+
606
+ def test_hit_enter_dismiss_with_escape
607
+ @editor.add_empty_buffer(path: "second.rb")
608
+ @dispatcher.dispatch_ex(@editor, "ls")
609
+ assert_equal :hit_enter, @editor.mode
610
+
611
+ feed(:escape)
612
+
613
+ assert_equal :normal, @editor.mode
614
+ assert_nil @editor.hit_enter_lines
615
+ end
616
+
617
+ def test_hit_enter_dismiss_with_ctrl_c
618
+ @editor.add_empty_buffer(path: "second.rb")
619
+ @dispatcher.dispatch_ex(@editor, "ls")
620
+ assert_equal :hit_enter, @editor.mode
621
+
622
+ feed(:ctrl_c)
623
+
624
+ assert_equal :normal, @editor.mode
625
+ assert_nil @editor.hit_enter_lines
626
+ end
627
+
628
+ def test_hit_enter_colon_enters_command_line
629
+ @editor.add_empty_buffer(path: "second.rb")
630
+ @dispatcher.dispatch_ex(@editor, "ls")
631
+ assert_equal :hit_enter, @editor.mode
632
+
633
+ feed(":")
634
+
635
+ assert_equal :command_line, @editor.mode
636
+ assert_nil @editor.hit_enter_lines
637
+ end
638
+
639
+ def test_hit_enter_slash_enters_search
640
+ @editor.add_empty_buffer(path: "second.rb")
641
+ @dispatcher.dispatch_ex(@editor, "ls")
642
+ assert_equal :hit_enter, @editor.mode
643
+
644
+ feed("/")
645
+
646
+ assert_equal :command_line, @editor.mode
647
+ assert_equal "/", @editor.command_line_prefix
648
+ assert_nil @editor.hit_enter_lines
649
+ end
650
+
651
+ def test_hit_enter_question_enters_reverse_search
652
+ @editor.add_empty_buffer(path: "second.rb")
653
+ @dispatcher.dispatch_ex(@editor, "ls")
654
+ assert_equal :hit_enter, @editor.mode
655
+
656
+ feed("?")
657
+
658
+ assert_equal :command_line, @editor.mode
659
+ assert_equal "?", @editor.command_line_prefix
660
+ assert_nil @editor.hit_enter_lines
661
+ end
662
+
663
+ def test_args_with_multiple_files_enters_hit_enter_mode
664
+ @editor.set_arglist(["a.rb", "b.rb", "c.rb"])
665
+ @dispatcher.dispatch_ex(@editor, "args")
666
+
667
+ assert_equal :hit_enter, @editor.mode
668
+ assert_instance_of Array, @editor.hit_enter_lines
669
+ assert_equal 3, @editor.hit_enter_lines.length
670
+ assert_match(/\[a\.rb\]/, @editor.hit_enter_lines[0])
671
+ end
672
+
673
+ def test_args_with_single_file_uses_normal_echo
674
+ @editor.set_arglist(["a.rb"])
675
+ @dispatcher.dispatch_ex(@editor, "args")
676
+
677
+ refute_equal :hit_enter, @editor.mode
678
+ end
679
+
680
+ def test_set_no_args_enters_hit_enter_mode
681
+ @dispatcher.dispatch_ex(@editor, "set")
682
+
683
+ # option_snapshot returns many options, so always > 1 line
684
+ assert_equal :hit_enter, @editor.mode
685
+ assert_instance_of Array, @editor.hit_enter_lines
686
+ assert_operator @editor.hit_enter_lines.length, :>, 1
687
+ end
688
+
689
+ def test_batch_insert_handles_pasted_text_correctly
690
+ @editor.current_buffer.replace_all_lines!([""])
691
+ # Simulate pasting "Hello World\n" in insert mode (batch of characters)
692
+ feed("i", *"Hello World".chars, :enter, *"Second line".chars, :escape)
693
+
694
+ assert_equal ["Hello World", "Second line"], @editor.current_buffer.lines
695
+ assert_equal :normal, @editor.mode
696
+ end
697
+
698
+ def test_batch_insert_stops_on_escape
699
+ @editor.current_buffer.replace_all_lines!([""])
700
+ # Escape exits insert mode; subsequent keys are normal-mode commands
701
+ feed("i", "a", "b", "c", :escape)
702
+
703
+ assert_equal ["abc"], @editor.current_buffer.lines
704
+ assert_equal :normal, @editor.mode
705
+ end
706
+
707
+ def test_ls_format_shows_vim_style_output
708
+ @editor.add_empty_buffer(path: "second.rb")
709
+ @dispatcher.dispatch_ex(@editor, "ls")
710
+
711
+ lines = @editor.hit_enter_lines
712
+ # Each line should contain the buffer id and name
713
+ assert_match(/1.*\[No Name\]/, lines[0])
714
+ assert_match(/2.*"second\.rb"/, lines[1])
715
+ end
716
+
717
+ def test_equal_equal_indents_current_line
718
+ @editor.current_buffer.replace_all_lines!(["def foo", "bar", "end"])
719
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
720
+ @editor.current_window.cursor_y = 1
721
+ @editor.current_window.cursor_x = 0
722
+
723
+ feed("=", "=")
724
+
725
+ assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
726
+ end
727
+
728
+ def test_equal_j_indents_two_lines
729
+ @editor.current_buffer.replace_all_lines!(["def foo", "bar", "baz", "end"])
730
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
731
+ @editor.current_window.cursor_y = 1
732
+ @editor.current_window.cursor_x = 0
733
+
734
+ feed("=", "j")
735
+
736
+ assert_equal ["def foo", " bar", " baz", "end"], @editor.current_buffer.lines
737
+ end
738
+
739
+ def test_visual_equal_indents_selection
740
+ @editor.current_buffer.replace_all_lines!(["def foo", "bar", "end"])
741
+ @editor.assign_filetype(@editor.current_buffer, "ruby")
742
+ @editor.current_window.cursor_y = 0
743
+ @editor.current_window.cursor_x = 0
744
+
745
+ feed("V", "j", "j", "=")
746
+
747
+ assert_equal ["def foo", " bar", "end"], @editor.current_buffer.lines
748
+ assert_equal :normal, @editor.mode
749
+ end
750
+
751
+ # :qa / :qall tests
752
+
753
+ def test_qa_quits_with_multiple_windows
754
+ @dispatcher.dispatch_ex(@editor, "split")
755
+ assert_equal 2, @editor.window_count
756
+
757
+ @dispatcher.dispatch_ex(@editor, "qa")
758
+ assert_equal false, @editor.running?
759
+ end
760
+
761
+ def test_qa_refuses_with_unsaved_changes
762
+ @editor.current_buffer.replace_all_lines!(["modified"])
763
+ @editor.current_buffer.instance_variable_set(:@modified, true)
764
+
765
+ @dispatcher.dispatch_ex(@editor, "qa")
766
+ assert @editor.running?
767
+ assert_match(/unsaved changes/, @editor.message)
768
+ end
769
+
770
+ def test_qa_bang_forces_quit_with_unsaved_changes
771
+ @editor.current_buffer.replace_all_lines!(["modified"])
772
+ @editor.current_buffer.instance_variable_set(:@modified, true)
773
+
774
+ @dispatcher.dispatch_ex(@editor, "qa!")
775
+ assert_equal false, @editor.running?
776
+ end
777
+
778
+ def test_wqa_writes_all_and_quits
779
+ Dir.mktmpdir do |dir|
780
+ path1 = File.join(dir, "a.txt")
781
+ path2 = File.join(dir, "b.txt")
782
+ File.write(path1, "")
783
+ File.write(path2, "")
784
+
785
+ @editor.current_buffer.replace_all_lines!(["hello"])
786
+ @editor.current_buffer.instance_variable_set(:@path, path1)
787
+ @editor.current_buffer.instance_variable_set(:@modified, true)
788
+
789
+ buf2 = @editor.add_empty_buffer(path: path2)
790
+ buf2.replace_all_lines!(["world"])
791
+ buf2.instance_variable_set(:@modified, true)
792
+
793
+ @dispatcher.dispatch_ex(@editor, "wqa")
794
+ assert_equal false, @editor.running?
795
+ assert_equal "hello", File.read(path1)
796
+ assert_equal "world", File.read(path2)
797
+ end
798
+ end
799
+
800
+ def test_shift_right_splits_when_single_window
801
+ assert_equal 1, @editor.window_count
802
+ first_win = @editor.current_window
803
+ feed(:shift_right)
804
+ assert_equal 2, @editor.window_count
805
+ # Focus should be on the new (right) window
806
+ refute_equal first_win.id, @editor.current_window.id
807
+ assert_equal :vertical, @editor.window_layout
808
+ end
809
+
810
+ def test_shift_left_splits_when_single_window
811
+ assert_equal 1, @editor.window_count
812
+ first_win = @editor.current_window
813
+ feed(:shift_left)
814
+ assert_equal 2, @editor.window_count
815
+ # Focus should be on the new (left) window
816
+ refute_equal first_win.id, @editor.current_window.id
817
+ assert_equal :vertical, @editor.window_layout
818
+ end
819
+
820
+ def test_shift_down_splits_when_single_window
821
+ assert_equal 1, @editor.window_count
822
+ first_win = @editor.current_window
823
+ feed(:shift_down)
824
+ assert_equal 2, @editor.window_count
825
+ refute_equal first_win.id, @editor.current_window.id
826
+ assert_equal :horizontal, @editor.window_layout
827
+ end
828
+
829
+ def test_shift_up_splits_when_single_window
830
+ assert_equal 1, @editor.window_count
831
+ first_win = @editor.current_window
832
+ feed(:shift_up)
833
+ assert_equal 2, @editor.window_count
834
+ refute_equal first_win.id, @editor.current_window.id
835
+ assert_equal :horizontal, @editor.window_layout
836
+ end
837
+
838
+ def test_shift_right_splits_even_with_horizontal_split
839
+ # Horizontal split exists, but no window to the right → vsplit
840
+ @editor.split_current_window(layout: :horizontal)
841
+ assert_equal 2, @editor.window_count
842
+ feed(:shift_right)
843
+ assert_equal 3, @editor.window_count
844
+ end
845
+
846
+ def test_shift_left_splits_even_with_horizontal_split
847
+ @editor.split_current_window(layout: :horizontal)
848
+ assert_equal 2, @editor.window_count
849
+ feed(:shift_left)
850
+ assert_equal 3, @editor.window_count
851
+ end
852
+
853
+ def test_shift_down_splits_even_with_vertical_split
854
+ @editor.split_current_window(layout: :vertical)
855
+ assert_equal 2, @editor.window_count
856
+ feed(:shift_down)
857
+ assert_equal 3, @editor.window_count
858
+ end
859
+
860
+ def test_shift_up_splits_even_with_vertical_split
861
+ @editor.split_current_window(layout: :vertical)
862
+ assert_equal 2, @editor.window_count
863
+ feed(:shift_up)
864
+ assert_equal 3, @editor.window_count
865
+ end
866
+
867
+ def test_shift_arrow_moves_window_focus_when_multiple_windows
868
+ # Create a vertical split so we have two windows
869
+ first_win = @editor.current_window
870
+ @editor.split_current_window(layout: :vertical)
871
+ second_win = @editor.current_window
872
+
873
+ # After split, focus is on the new (second) window
874
+ assert_equal second_win.id, @editor.current_window.id
875
+
876
+ # Shift+Left should move focus to the left window (no new split)
877
+ feed(:shift_left)
878
+ assert_equal first_win.id, @editor.current_window.id
879
+ assert_equal 2, @editor.window_count
880
+
881
+ # Shift+Right should move focus back to the right window
882
+ feed(:shift_right)
883
+ assert_equal second_win.id, @editor.current_window.id
884
+ assert_equal 2, @editor.window_count
885
+ end
886
+
887
+ def test_shift_arrow_up_down_moves_window_focus_horizontal_split
888
+ # Create a horizontal split so we have two windows
889
+ first_win = @editor.current_window
890
+ @editor.split_current_window(layout: :horizontal)
891
+ second_win = @editor.current_window
892
+
893
+ assert_equal second_win.id, @editor.current_window.id
894
+
895
+ # Shift+Up should move focus to the upper window (no new split)
896
+ feed(:shift_up)
897
+ assert_equal first_win.id, @editor.current_window.id
898
+ assert_equal 2, @editor.window_count
899
+
900
+ # Shift+Down should move focus back to the lower window
901
+ feed(:shift_down)
902
+ assert_equal second_win.id, @editor.current_window.id
903
+ assert_equal 2, @editor.window_count
904
+ end
905
+
906
+ # --- Nested layout tree tests ---
907
+
908
+ def test_vsplit_then_split_creates_nested_layout
909
+ # Start with 1 window (win1)
910
+ win1 = @editor.current_window
911
+ # vsplit → creates win2 to the right
912
+ @editor.split_current_window(layout: :vertical)
913
+ win2 = @editor.current_window
914
+ assert_equal 2, @editor.window_count
915
+
916
+ # split the right window (win2) horizontally → creates win3 below win2
917
+ @editor.split_current_window(layout: :horizontal)
918
+ win3 = @editor.current_window
919
+ assert_equal 3, @editor.window_count
920
+
921
+ # Layout tree should be: vsplit[ win1, hsplit[ win2, win3 ] ]
922
+ tree = @editor.layout_tree
923
+ assert_equal :vsplit, tree[:type]
924
+ assert_equal 2, tree[:children].length
925
+ assert_equal :window, tree[:children][0][:type]
926
+ assert_equal win1.id, tree[:children][0][:id]
927
+ assert_equal :hsplit, tree[:children][1][:type]
928
+ assert_equal 2, tree[:children][1][:children].length
929
+
930
+ # window_order should traverse leaves left-to-right, top-to-bottom
931
+ assert_equal [win1.id, win2.id, win3.id], @editor.window_order
932
+ end
933
+
934
+ def test_split_then_vsplit_creates_nested_layout
935
+ # split → creates win2 below
936
+ @editor.split_current_window(layout: :horizontal)
937
+ assert_equal 2, @editor.window_count
938
+
939
+ # vsplit the lower window → creates win3 to the right of win2
940
+ @editor.split_current_window(layout: :vertical)
941
+ assert_equal 3, @editor.window_count
942
+
943
+ # Layout tree should be: hsplit[ win1, vsplit[ win2, win3 ] ]
944
+ tree = @editor.layout_tree
945
+ assert_equal :hsplit, tree[:type]
946
+ assert_equal 2, tree[:children].length
947
+ assert_equal :window, tree[:children][0][:type]
948
+ assert_equal :vsplit, tree[:children][1][:type]
949
+ end
950
+
951
+ def test_close_window_simplifies_nested_tree
952
+ @editor.split_current_window(layout: :vertical)
953
+ @editor.split_current_window(layout: :horizontal)
954
+ win3 = @editor.current_window
955
+ assert_equal 3, @editor.window_count
956
+
957
+ # Close win3 → hsplit node should collapse, leaving vsplit[ win1, win2 ]
958
+ @editor.close_window(win3.id)
959
+ assert_equal 2, @editor.window_count
960
+
961
+ tree = @editor.layout_tree
962
+ assert_equal :vsplit, tree[:type]
963
+ assert_equal 2, tree[:children].length
964
+ assert_equal :window, tree[:children][0][:type]
965
+ assert_equal :window, tree[:children][1][:type]
966
+ end
967
+
968
+ def test_close_window_to_single_produces_single_layout
969
+ @editor.split_current_window(layout: :vertical)
970
+ win2 = @editor.current_window
971
+ assert_equal 2, @editor.window_count
972
+
973
+ @editor.close_window(win2.id)
974
+ assert_equal 1, @editor.window_count
975
+ assert_equal :single, @editor.window_layout
976
+ end
977
+
978
+ def test_focus_window_direction_in_nested_layout
979
+ # Create vsplit[ win1, hsplit[ win2, win3 ] ]
980
+ win1 = @editor.current_window
981
+ @editor.split_current_window(layout: :vertical)
982
+ win2 = @editor.current_window
983
+ @editor.split_current_window(layout: :horizontal)
984
+ win3 = @editor.current_window
985
+
986
+ # From win3 (bottom-right), going left should reach win1
987
+ @editor.focus_window(win3.id)
988
+ @editor.focus_window_direction(:left)
989
+ assert_equal win1.id, @editor.current_window_id
990
+
991
+ # From win1 (left), going right should reach win2 or win3
992
+ @editor.focus_window_direction(:right)
993
+ assert_includes [win2.id, win3.id], @editor.current_window_id
994
+
995
+ # From win2 (top-right), going down should reach win3
996
+ @editor.focus_window(win2.id)
997
+ @editor.focus_window_direction(:down)
998
+ assert_equal win3.id, @editor.current_window_id
999
+
1000
+ # From win3 (bottom-right), going up should reach win2
1001
+ @editor.focus_window_direction(:up)
1002
+ assert_equal win2.id, @editor.current_window_id
1003
+ end
1004
+
1005
+ def test_shift_left_does_not_split_at_edge_of_existing_vsplit
1006
+ # vsplit creates [win1, win2], focus on win2
1007
+ @editor.split_current_window(layout: :vertical)
1008
+ # Move focus to left (win1)
1009
+ feed(:shift_left)
1010
+ assert_equal 2, @editor.window_count
1011
+
1012
+ # Now we're on win1 (leftmost). Shift+Left should NOT split because
1013
+ # there are already windows on the same axis (horizontal neighbors exist).
1014
+ feed(:shift_left)
1015
+ assert_equal 2, @editor.window_count, "Should not split at edge of existing vsplit"
1016
+
1017
+ # Pressing again should still not split
1018
+ feed(:shift_left)
1019
+ assert_equal 2, @editor.window_count
1020
+ end
1021
+
1022
+ def test_shift_left_splits_bottom_window_in_nested_layout
1023
+ # Create layout: hsplit[ vsplit[win1, win2], win3 ]
1024
+ # Start with win1
1025
+ win1 = @editor.current_window
1026
+ # vsplit → vsplit[win1, win2]
1027
+ @editor.split_current_window(layout: :vertical)
1028
+ win2 = @editor.current_window
1029
+ # Focus back to win1, then split horizontally from win1
1030
+ # Actually, easier: split from win2 horizontally to get the right structure
1031
+ # Let me build it differently: start fresh
1032
+ @editor.focus_window(win1.id)
1033
+
1034
+ # From win1, hsplit → hsplit[win1, win3], but we want vsplit on top.
1035
+ # Let me just build the tree directly.
1036
+ # Better approach: vsplit first, then move up and hsplit from the vsplit pair
1037
+ # Actually: vsplit[win1, win2], then from win1 do hsplit → hsplit[vsplit[...], win3]
1038
+ # No, that's wrong. Let me think:
1039
+ # We want hsplit[ vsplit[A, B], C ]
1040
+ # Step 1: split (horizontal) → hsplit[win1, win3], focus on win3
1041
+ @editor.close_window(win2.id)
1042
+ assert_equal 1, @editor.window_count
1043
+ @editor.split_current_window(layout: :horizontal)
1044
+ win3 = @editor.current_window
1045
+ # Step 2: focus win1 (top), then vsplit → vsplit[win1, win2] inside hsplit
1046
+ @editor.focus_window(win1.id)
1047
+ @editor.split_current_window(layout: :vertical)
1048
+ win2 = @editor.current_window
1049
+ assert_equal 3, @editor.window_count
1050
+
1051
+ # Layout should be: hsplit[ vsplit[win1, win2], win3 ]
1052
+ tree = @editor.layout_tree
1053
+ assert_equal :hsplit, tree[:type]
1054
+ assert_equal :vsplit, tree[:children][0][:type]
1055
+ assert_equal :window, tree[:children][1][:type]
1056
+ assert_equal win3.id, tree[:children][1][:id]
1057
+
1058
+ # From win3 (full-width bottom), Shift+Left should SPLIT (no vsplit ancestor)
1059
+ @editor.focus_window(win3.id)
1060
+ feed(:shift_left)
1061
+ assert_equal 4, @editor.window_count, "Shift+Left from full-width bottom should vsplit it"
1062
+ end
1063
+
1064
+ def test_same_direction_split_merges_into_parent
1065
+ # hsplit[ win1, win2 ], then split win2 again horizontally
1066
+ @editor.split_current_window(layout: :horizontal)
1067
+ @editor.split_current_window(layout: :horizontal)
1068
+ assert_equal 3, @editor.window_count
1069
+
1070
+ # All three should be in a single hsplit (no nested hsplit inside hsplit)
1071
+ tree = @editor.layout_tree
1072
+ assert_equal :hsplit, tree[:type]
1073
+ assert_equal 3, tree[:children].length
1074
+ end
347
1075
  end