feat(daemon): add pane resize control and modal commands
- commit
- cbd555e9988ebd63619848ccc88528f2ff63250a
- author
- brice · · 2026-07-25 04:33 UTC
- committer
- brice · · 2026-07-25 04:35 UTC
- parent
- 871b49b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +741 -12
M internal/app/cmd_test.go +7 -3
| @@ -23,6 +23,8 @@ func TestParseCmdArgs(t *testing.T) { | |||
| 23 | 23 | {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false}, | |
| 24 | 24 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | |
| 25 | 25 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | |
| 26 | + | {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false}, | |
| 27 | + | {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, | |
| 26 | 28 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | |
| 27 | 29 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | |
| 28 | 30 | {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false}, | |
| @@ -90,13 +92,15 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||
| 90 | 92 | if called { | |
| 91 | 93 | t.Fatal("help dialed daemon") | |
| 92 | 94 | } | |
| 93 | - | for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} { | |
| 95 | + | for _, want := range []string{"usage: vev cmd", "split-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} { | |
| 94 | 96 | if !strings.Contains(out.String(), want) { | |
| 95 | 97 | t.Fatalf("help %q missing %q", out.String(), want) | |
| 96 | 98 | } | |
| 97 | 99 | } | |
| 98 | - | if strings.Contains(out.String(), "session-picker") { | |
| 99 | - | t.Fatalf("help leaked non-scriptable command: %q", out.String()) | |
| 100 | + | for _, hidden := range []string{"session-picker", "resize-pane"} { | |
| 101 | + | if strings.Contains(out.String(), hidden) { | |
| 102 | + | t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String()) | |
| 103 | + | } | |
| 100 | 104 | } | |
| 101 | 105 | } | |
| 102 | 106 | ||
| @@ -23,6 +23,8 @@ func TestParseCmdArgs(t *testing.T) { | |||||
| 23 | {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false}, | 23 | {"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false}, | ||
| 24 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | 24 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | ||
| 25 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | 25 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | ||
| 26 | + | {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false}, | |||
| 27 | + | {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, | |||
| 26 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | 28 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | ||
| 27 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | 29 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | ||
| 28 | {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false}, | 30 | {"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false}, | ||
| @@ -90,13 +92,15 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||||
| 90 | if called { | 92 | if called { | ||
| 91 | t.Fatal("help dialed daemon") | 93 | t.Fatal("help dialed daemon") | ||
| 92 | } | 94 | } | ||
| 93 | - | for _, want := range []string{"usage: vev cmd", "split-right", "list-panes"} { | 95 | + | for _, want := range []string{"usage: vev cmd", "split-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} { |
| 94 | if !strings.Contains(out.String(), want) { | 96 | if !strings.Contains(out.String(), want) { | ||
| 95 | t.Fatalf("help %q missing %q", out.String(), want) | 97 | t.Fatalf("help %q missing %q", out.String(), want) | ||
| 96 | } | 98 | } | ||
| 97 | } | 99 | } | ||
| 98 | - | if strings.Contains(out.String(), "session-picker") { | 100 | + | for _, hidden := range []string{"session-picker", "resize-pane"} { |
| 99 | - | t.Fatalf("help leaked non-scriptable command: %q", out.String()) | 101 | + | if strings.Contains(out.String(), hidden) { |
| 102 | + | t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String()) | |||
| 103 | + | } | |||
| 100 | } | 104 | } | ||
| 101 | } | 105 | } | ||
| 102 | 106 | ||||
M internal/usecase/command/command.go +11 -0
| @@ -43,6 +43,12 @@ type Context interface { | |||
| 43 | 43 | FocusPaneRight() error | |
| 44 | 44 | FocusPaneUp() error | |
| 45 | 45 | FocusPaneDown() error | |
| 46 | + | EnterResizeMode() error | |
| 47 | + | GrowPaneWidth() error | |
| 48 | + | ShrinkPaneWidth() error | |
| 49 | + | GrowPaneHeight() error | |
| 50 | + | ShrinkPaneHeight() error | |
| 51 | + | EqualizePanes() error | |
| 46 | 52 | NextTab() error | |
| 47 | 53 | PrevTab() error | |
| 48 | 54 | BackSession() error | |
| @@ -104,6 +110,11 @@ type ControlContext interface { | |||
| 104 | 110 | SplitDown() error | |
| 105 | 111 | StackPane() error | |
| 106 | 112 | ToggleStack() error | |
| 113 | + | GrowPaneWidth() error | |
| 114 | + | ShrinkPaneWidth() error | |
| 115 | + | GrowPaneHeight() error | |
| 116 | + | ShrinkPaneHeight() error | |
| 117 | + | EqualizePanes() error | |
| 107 | 118 | FocusPaneLeft() error | |
| 108 | 119 | FocusPaneRight() error | |
| 109 | 120 | FocusPaneUp() error | |
| @@ -43,6 +43,12 @@ type Context interface { | |||||
| 43 | FocusPaneRight() error | 43 | FocusPaneRight() error | ||
| 44 | FocusPaneUp() error | 44 | FocusPaneUp() error | ||
| 45 | FocusPaneDown() error | 45 | FocusPaneDown() error | ||
| 46 | + | EnterResizeMode() error | |||
| 47 | + | GrowPaneWidth() error | |||
| 48 | + | ShrinkPaneWidth() error | |||
| 49 | + | GrowPaneHeight() error | |||
| 50 | + | ShrinkPaneHeight() error | |||
| 51 | + | EqualizePanes() error | |||
| 46 | NextTab() error | 52 | NextTab() error | ||
| 47 | PrevTab() error | 53 | PrevTab() error | ||
| 48 | BackSession() error | 54 | BackSession() error | ||
| @@ -104,6 +110,11 @@ type ControlContext interface { | |||||
| 104 | SplitDown() error | 110 | SplitDown() error | ||
| 105 | StackPane() error | 111 | StackPane() error | ||
| 106 | ToggleStack() error | 112 | ToggleStack() error | ||
| 113 | + | GrowPaneWidth() error | |||
| 114 | + | ShrinkPaneWidth() error | |||
| 115 | + | GrowPaneHeight() error | |||
| 116 | + | ShrinkPaneHeight() error | |||
| 117 | + | EqualizePanes() error | |||
| 107 | FocusPaneLeft() error | 118 | FocusPaneLeft() error | ||
| 108 | FocusPaneRight() error | 119 | FocusPaneRight() error | ||
| 109 | FocusPaneUp() error | 120 | FocusPaneUp() error | ||
M internal/usecase/command/context_mock_test.go +264 -0
| @@ -299,6 +299,50 @@ func (_c *MockContext_Detach_Call) RunAndReturn(run func() error) *MockContext_D | |||
| 299 | 299 | return _c | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | + | // EnterResizeMode provides a mock function for the type MockContext | |
| 303 | + | func (_mock *MockContext) EnterResizeMode() error { | |
| 304 | + | ret := _mock.Called() | |
| 305 | + | ||
| 306 | + | if len(ret) == 0 { | |
| 307 | + | panic("no return value specified for EnterResizeMode") | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | var r0 error | |
| 311 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 312 | + | r0 = returnFunc() | |
| 313 | + | } else { | |
| 314 | + | r0 = ret.Error(0) | |
| 315 | + | } | |
| 316 | + | return r0 | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | // MockContext_EnterResizeMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnterResizeMode' | |
| 320 | + | type MockContext_EnterResizeMode_Call struct { | |
| 321 | + | *mock.Call | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // EnterResizeMode is a helper method to define mock.On call | |
| 325 | + | func (_e *MockContext_Expecter) EnterResizeMode() *MockContext_EnterResizeMode_Call { | |
| 326 | + | return &MockContext_EnterResizeMode_Call{Call: _e.mock.On("EnterResizeMode")} | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | func (_c *MockContext_EnterResizeMode_Call) Run(run func()) *MockContext_EnterResizeMode_Call { | |
| 330 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 331 | + | run() | |
| 332 | + | }) | |
| 333 | + | return _c | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (_c *MockContext_EnterResizeMode_Call) Return(err error) *MockContext_EnterResizeMode_Call { | |
| 337 | + | _c.Call.Return(err) | |
| 338 | + | return _c | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | func (_c *MockContext_EnterResizeMode_Call) RunAndReturn(run func() error) *MockContext_EnterResizeMode_Call { | |
| 342 | + | _c.Call.Return(run) | |
| 343 | + | return _c | |
| 344 | + | } | |
| 345 | + | ||
| 302 | 346 | // EnterVisualMode provides a mock function for the type MockContext | |
| 303 | 347 | func (_mock *MockContext) EnterVisualMode() error { | |
| 304 | 348 | ret := _mock.Called() | |
| @@ -343,6 +387,50 @@ func (_c *MockContext_EnterVisualMode_Call) RunAndReturn(run func() error) *Mock | |||
| 343 | 387 | return _c | |
| 344 | 388 | } | |
| 345 | 389 | ||
| 390 | + | // EqualizePanes provides a mock function for the type MockContext | |
| 391 | + | func (_mock *MockContext) EqualizePanes() error { | |
| 392 | + | ret := _mock.Called() | |
| 393 | + | ||
| 394 | + | if len(ret) == 0 { | |
| 395 | + | panic("no return value specified for EqualizePanes") | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | var r0 error | |
| 399 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 400 | + | r0 = returnFunc() | |
| 401 | + | } else { | |
| 402 | + | r0 = ret.Error(0) | |
| 403 | + | } | |
| 404 | + | return r0 | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // MockContext_EqualizePanes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EqualizePanes' | |
| 408 | + | type MockContext_EqualizePanes_Call struct { | |
| 409 | + | *mock.Call | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | // EqualizePanes is a helper method to define mock.On call | |
| 413 | + | func (_e *MockContext_Expecter) EqualizePanes() *MockContext_EqualizePanes_Call { | |
| 414 | + | return &MockContext_EqualizePanes_Call{Call: _e.mock.On("EqualizePanes")} | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func (_c *MockContext_EqualizePanes_Call) Run(run func()) *MockContext_EqualizePanes_Call { | |
| 418 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 419 | + | run() | |
| 420 | + | }) | |
| 421 | + | return _c | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func (_c *MockContext_EqualizePanes_Call) Return(err error) *MockContext_EqualizePanes_Call { | |
| 425 | + | _c.Call.Return(err) | |
| 426 | + | return _c | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | func (_c *MockContext_EqualizePanes_Call) RunAndReturn(run func() error) *MockContext_EqualizePanes_Call { | |
| 430 | + | _c.Call.Return(run) | |
| 431 | + | return _c | |
| 432 | + | } | |
| 433 | + | ||
| 346 | 434 | // FocusPaneDown provides a mock function for the type MockContext | |
| 347 | 435 | func (_mock *MockContext) FocusPaneDown() error { | |
| 348 | 436 | ret := _mock.Called() | |
| @@ -519,6 +607,94 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont | |||
| 519 | 607 | return _c | |
| 520 | 608 | } | |
| 521 | 609 | ||
| 610 | + | // GrowPaneHeight provides a mock function for the type MockContext | |
| 611 | + | func (_mock *MockContext) GrowPaneHeight() error { | |
| 612 | + | ret := _mock.Called() | |
| 613 | + | ||
| 614 | + | if len(ret) == 0 { | |
| 615 | + | panic("no return value specified for GrowPaneHeight") | |
| 616 | + | } | |
| 617 | + | ||
| 618 | + | var r0 error | |
| 619 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 620 | + | r0 = returnFunc() | |
| 621 | + | } else { | |
| 622 | + | r0 = ret.Error(0) | |
| 623 | + | } | |
| 624 | + | return r0 | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | // MockContext_GrowPaneHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrowPaneHeight' | |
| 628 | + | type MockContext_GrowPaneHeight_Call struct { | |
| 629 | + | *mock.Call | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // GrowPaneHeight is a helper method to define mock.On call | |
| 633 | + | func (_e *MockContext_Expecter) GrowPaneHeight() *MockContext_GrowPaneHeight_Call { | |
| 634 | + | return &MockContext_GrowPaneHeight_Call{Call: _e.mock.On("GrowPaneHeight")} | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | func (_c *MockContext_GrowPaneHeight_Call) Run(run func()) *MockContext_GrowPaneHeight_Call { | |
| 638 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 639 | + | run() | |
| 640 | + | }) | |
| 641 | + | return _c | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | func (_c *MockContext_GrowPaneHeight_Call) Return(err error) *MockContext_GrowPaneHeight_Call { | |
| 645 | + | _c.Call.Return(err) | |
| 646 | + | return _c | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | func (_c *MockContext_GrowPaneHeight_Call) RunAndReturn(run func() error) *MockContext_GrowPaneHeight_Call { | |
| 650 | + | _c.Call.Return(run) | |
| 651 | + | return _c | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | // GrowPaneWidth provides a mock function for the type MockContext | |
| 655 | + | func (_mock *MockContext) GrowPaneWidth() error { | |
| 656 | + | ret := _mock.Called() | |
| 657 | + | ||
| 658 | + | if len(ret) == 0 { | |
| 659 | + | panic("no return value specified for GrowPaneWidth") | |
| 660 | + | } | |
| 661 | + | ||
| 662 | + | var r0 error | |
| 663 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 664 | + | r0 = returnFunc() | |
| 665 | + | } else { | |
| 666 | + | r0 = ret.Error(0) | |
| 667 | + | } | |
| 668 | + | return r0 | |
| 669 | + | } | |
| 670 | + | ||
| 671 | + | // MockContext_GrowPaneWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrowPaneWidth' | |
| 672 | + | type MockContext_GrowPaneWidth_Call struct { | |
| 673 | + | *mock.Call | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | // GrowPaneWidth is a helper method to define mock.On call | |
| 677 | + | func (_e *MockContext_Expecter) GrowPaneWidth() *MockContext_GrowPaneWidth_Call { | |
| 678 | + | return &MockContext_GrowPaneWidth_Call{Call: _e.mock.On("GrowPaneWidth")} | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | func (_c *MockContext_GrowPaneWidth_Call) Run(run func()) *MockContext_GrowPaneWidth_Call { | |
| 682 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 683 | + | run() | |
| 684 | + | }) | |
| 685 | + | return _c | |
| 686 | + | } | |
| 687 | + | ||
| 688 | + | func (_c *MockContext_GrowPaneWidth_Call) Return(err error) *MockContext_GrowPaneWidth_Call { | |
| 689 | + | _c.Call.Return(err) | |
| 690 | + | return _c | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | func (_c *MockContext_GrowPaneWidth_Call) RunAndReturn(run func() error) *MockContext_GrowPaneWidth_Call { | |
| 694 | + | _c.Call.Return(run) | |
| 695 | + | return _c | |
| 696 | + | } | |
| 697 | + | ||
| 522 | 698 | // JumpRecentSession provides a mock function for the type MockContext | |
| 523 | 699 | func (_mock *MockContext) JumpRecentSession(rank int) error { | |
| 524 | 700 | ret := _mock.Called(rank) | |
| @@ -834,6 +1010,94 @@ func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContex | |||
| 834 | 1010 | return _c | |
| 835 | 1011 | } | |
| 836 | 1012 | ||
| 1013 | + | // ShrinkPaneHeight provides a mock function for the type MockContext | |
| 1014 | + | func (_mock *MockContext) ShrinkPaneHeight() error { | |
| 1015 | + | ret := _mock.Called() | |
| 1016 | + | ||
| 1017 | + | if len(ret) == 0 { | |
| 1018 | + | panic("no return value specified for ShrinkPaneHeight") | |
| 1019 | + | } | |
| 1020 | + | ||
| 1021 | + | var r0 error | |
| 1022 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 1023 | + | r0 = returnFunc() | |
| 1024 | + | } else { | |
| 1025 | + | r0 = ret.Error(0) | |
| 1026 | + | } | |
| 1027 | + | return r0 | |
| 1028 | + | } | |
| 1029 | + | ||
| 1030 | + | // MockContext_ShrinkPaneHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShrinkPaneHeight' | |
| 1031 | + | type MockContext_ShrinkPaneHeight_Call struct { | |
| 1032 | + | *mock.Call | |
| 1033 | + | } | |
| 1034 | + | ||
| 1035 | + | // ShrinkPaneHeight is a helper method to define mock.On call | |
| 1036 | + | func (_e *MockContext_Expecter) ShrinkPaneHeight() *MockContext_ShrinkPaneHeight_Call { | |
| 1037 | + | return &MockContext_ShrinkPaneHeight_Call{Call: _e.mock.On("ShrinkPaneHeight")} | |
| 1038 | + | } | |
| 1039 | + | ||
| 1040 | + | func (_c *MockContext_ShrinkPaneHeight_Call) Run(run func()) *MockContext_ShrinkPaneHeight_Call { | |
| 1041 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1042 | + | run() | |
| 1043 | + | }) | |
| 1044 | + | return _c | |
| 1045 | + | } | |
| 1046 | + | ||
| 1047 | + | func (_c *MockContext_ShrinkPaneHeight_Call) Return(err error) *MockContext_ShrinkPaneHeight_Call { | |
| 1048 | + | _c.Call.Return(err) | |
| 1049 | + | return _c | |
| 1050 | + | } | |
| 1051 | + | ||
| 1052 | + | func (_c *MockContext_ShrinkPaneHeight_Call) RunAndReturn(run func() error) *MockContext_ShrinkPaneHeight_Call { | |
| 1053 | + | _c.Call.Return(run) | |
| 1054 | + | return _c | |
| 1055 | + | } | |
| 1056 | + | ||
| 1057 | + | // ShrinkPaneWidth provides a mock function for the type MockContext | |
| 1058 | + | func (_mock *MockContext) ShrinkPaneWidth() error { | |
| 1059 | + | ret := _mock.Called() | |
| 1060 | + | ||
| 1061 | + | if len(ret) == 0 { | |
| 1062 | + | panic("no return value specified for ShrinkPaneWidth") | |
| 1063 | + | } | |
| 1064 | + | ||
| 1065 | + | var r0 error | |
| 1066 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 1067 | + | r0 = returnFunc() | |
| 1068 | + | } else { | |
| 1069 | + | r0 = ret.Error(0) | |
| 1070 | + | } | |
| 1071 | + | return r0 | |
| 1072 | + | } | |
| 1073 | + | ||
| 1074 | + | // MockContext_ShrinkPaneWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShrinkPaneWidth' | |
| 1075 | + | type MockContext_ShrinkPaneWidth_Call struct { | |
| 1076 | + | *mock.Call | |
| 1077 | + | } | |
| 1078 | + | ||
| 1079 | + | // ShrinkPaneWidth is a helper method to define mock.On call | |
| 1080 | + | func (_e *MockContext_Expecter) ShrinkPaneWidth() *MockContext_ShrinkPaneWidth_Call { | |
| 1081 | + | return &MockContext_ShrinkPaneWidth_Call{Call: _e.mock.On("ShrinkPaneWidth")} | |
| 1082 | + | } | |
| 1083 | + | ||
| 1084 | + | func (_c *MockContext_ShrinkPaneWidth_Call) Run(run func()) *MockContext_ShrinkPaneWidth_Call { | |
| 1085 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1086 | + | run() | |
| 1087 | + | }) | |
| 1088 | + | return _c | |
| 1089 | + | } | |
| 1090 | + | ||
| 1091 | + | func (_c *MockContext_ShrinkPaneWidth_Call) Return(err error) *MockContext_ShrinkPaneWidth_Call { | |
| 1092 | + | _c.Call.Return(err) | |
| 1093 | + | return _c | |
| 1094 | + | } | |
| 1095 | + | ||
| 1096 | + | func (_c *MockContext_ShrinkPaneWidth_Call) RunAndReturn(run func() error) *MockContext_ShrinkPaneWidth_Call { | |
| 1097 | + | _c.Call.Return(run) | |
| 1098 | + | return _c | |
| 1099 | + | } | |
| 1100 | + | ||
| 837 | 1101 | // SplitDown provides a mock function for the type MockContext | |
| 838 | 1102 | func (_mock *MockContext) SplitDown() error { | |
| 839 | 1103 | ret := _mock.Called() | |
| @@ -299,6 +299,50 @@ func (_c *MockContext_Detach_Call) RunAndReturn(run func() error) *MockContext_D | |||||
| 299 | return _c | 299 | return _c | ||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | + | // EnterResizeMode provides a mock function for the type MockContext | |||
| 303 | + | func (_mock *MockContext) EnterResizeMode() error { | |||
| 304 | + | ret := _mock.Called() | |||
| 305 | + | ||||
| 306 | + | if len(ret) == 0 { | |||
| 307 | + | panic("no return value specified for EnterResizeMode") | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | var r0 error | |||
| 311 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 312 | + | r0 = returnFunc() | |||
| 313 | + | } else { | |||
| 314 | + | r0 = ret.Error(0) | |||
| 315 | + | } | |||
| 316 | + | return r0 | |||
| 317 | + | } | |||
| 318 | + | ||||
| 319 | + | // MockContext_EnterResizeMode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnterResizeMode' | |||
| 320 | + | type MockContext_EnterResizeMode_Call struct { | |||
| 321 | + | *mock.Call | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // EnterResizeMode is a helper method to define mock.On call | |||
| 325 | + | func (_e *MockContext_Expecter) EnterResizeMode() *MockContext_EnterResizeMode_Call { | |||
| 326 | + | return &MockContext_EnterResizeMode_Call{Call: _e.mock.On("EnterResizeMode")} | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | func (_c *MockContext_EnterResizeMode_Call) Run(run func()) *MockContext_EnterResizeMode_Call { | |||
| 330 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 331 | + | run() | |||
| 332 | + | }) | |||
| 333 | + | return _c | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | func (_c *MockContext_EnterResizeMode_Call) Return(err error) *MockContext_EnterResizeMode_Call { | |||
| 337 | + | _c.Call.Return(err) | |||
| 338 | + | return _c | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | func (_c *MockContext_EnterResizeMode_Call) RunAndReturn(run func() error) *MockContext_EnterResizeMode_Call { | |||
| 342 | + | _c.Call.Return(run) | |||
| 343 | + | return _c | |||
| 344 | + | } | |||
| 345 | + | ||||
| 302 | // EnterVisualMode provides a mock function for the type MockContext | 346 | // EnterVisualMode provides a mock function for the type MockContext | ||
| 303 | func (_mock *MockContext) EnterVisualMode() error { | 347 | func (_mock *MockContext) EnterVisualMode() error { | ||
| 304 | ret := _mock.Called() | 348 | ret := _mock.Called() | ||
| @@ -343,6 +387,50 @@ func (_c *MockContext_EnterVisualMode_Call) RunAndReturn(run func() error) *Mock | |||||
| 343 | return _c | 387 | return _c | ||
| 344 | } | 388 | } | ||
| 345 | 389 | ||||
| 390 | + | // EqualizePanes provides a mock function for the type MockContext | |||
| 391 | + | func (_mock *MockContext) EqualizePanes() error { | |||
| 392 | + | ret := _mock.Called() | |||
| 393 | + | ||||
| 394 | + | if len(ret) == 0 { | |||
| 395 | + | panic("no return value specified for EqualizePanes") | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | var r0 error | |||
| 399 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 400 | + | r0 = returnFunc() | |||
| 401 | + | } else { | |||
| 402 | + | r0 = ret.Error(0) | |||
| 403 | + | } | |||
| 404 | + | return r0 | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | // MockContext_EqualizePanes_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EqualizePanes' | |||
| 408 | + | type MockContext_EqualizePanes_Call struct { | |||
| 409 | + | *mock.Call | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | // EqualizePanes is a helper method to define mock.On call | |||
| 413 | + | func (_e *MockContext_Expecter) EqualizePanes() *MockContext_EqualizePanes_Call { | |||
| 414 | + | return &MockContext_EqualizePanes_Call{Call: _e.mock.On("EqualizePanes")} | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func (_c *MockContext_EqualizePanes_Call) Run(run func()) *MockContext_EqualizePanes_Call { | |||
| 418 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 419 | + | run() | |||
| 420 | + | }) | |||
| 421 | + | return _c | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func (_c *MockContext_EqualizePanes_Call) Return(err error) *MockContext_EqualizePanes_Call { | |||
| 425 | + | _c.Call.Return(err) | |||
| 426 | + | return _c | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func (_c *MockContext_EqualizePanes_Call) RunAndReturn(run func() error) *MockContext_EqualizePanes_Call { | |||
| 430 | + | _c.Call.Return(run) | |||
| 431 | + | return _c | |||
| 432 | + | } | |||
| 433 | + | ||||
| 346 | // FocusPaneDown provides a mock function for the type MockContext | 434 | // FocusPaneDown provides a mock function for the type MockContext | ||
| 347 | func (_mock *MockContext) FocusPaneDown() error { | 435 | func (_mock *MockContext) FocusPaneDown() error { | ||
| 348 | ret := _mock.Called() | 436 | ret := _mock.Called() | ||
| @@ -519,6 +607,94 @@ func (_c *MockContext_FocusPaneUp_Call) RunAndReturn(run func() error) *MockCont | |||||
| 519 | return _c | 607 | return _c | ||
| 520 | } | 608 | } | ||
| 521 | 609 | ||||
| 610 | + | // GrowPaneHeight provides a mock function for the type MockContext | |||
| 611 | + | func (_mock *MockContext) GrowPaneHeight() error { | |||
| 612 | + | ret := _mock.Called() | |||
| 613 | + | ||||
| 614 | + | if len(ret) == 0 { | |||
| 615 | + | panic("no return value specified for GrowPaneHeight") | |||
| 616 | + | } | |||
| 617 | + | ||||
| 618 | + | var r0 error | |||
| 619 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 620 | + | r0 = returnFunc() | |||
| 621 | + | } else { | |||
| 622 | + | r0 = ret.Error(0) | |||
| 623 | + | } | |||
| 624 | + | return r0 | |||
| 625 | + | } | |||
| 626 | + | ||||
| 627 | + | // MockContext_GrowPaneHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrowPaneHeight' | |||
| 628 | + | type MockContext_GrowPaneHeight_Call struct { | |||
| 629 | + | *mock.Call | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | // GrowPaneHeight is a helper method to define mock.On call | |||
| 633 | + | func (_e *MockContext_Expecter) GrowPaneHeight() *MockContext_GrowPaneHeight_Call { | |||
| 634 | + | return &MockContext_GrowPaneHeight_Call{Call: _e.mock.On("GrowPaneHeight")} | |||
| 635 | + | } | |||
| 636 | + | ||||
| 637 | + | func (_c *MockContext_GrowPaneHeight_Call) Run(run func()) *MockContext_GrowPaneHeight_Call { | |||
| 638 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 639 | + | run() | |||
| 640 | + | }) | |||
| 641 | + | return _c | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | func (_c *MockContext_GrowPaneHeight_Call) Return(err error) *MockContext_GrowPaneHeight_Call { | |||
| 645 | + | _c.Call.Return(err) | |||
| 646 | + | return _c | |||
| 647 | + | } | |||
| 648 | + | ||||
| 649 | + | func (_c *MockContext_GrowPaneHeight_Call) RunAndReturn(run func() error) *MockContext_GrowPaneHeight_Call { | |||
| 650 | + | _c.Call.Return(run) | |||
| 651 | + | return _c | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | // GrowPaneWidth provides a mock function for the type MockContext | |||
| 655 | + | func (_mock *MockContext) GrowPaneWidth() error { | |||
| 656 | + | ret := _mock.Called() | |||
| 657 | + | ||||
| 658 | + | if len(ret) == 0 { | |||
| 659 | + | panic("no return value specified for GrowPaneWidth") | |||
| 660 | + | } | |||
| 661 | + | ||||
| 662 | + | var r0 error | |||
| 663 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 664 | + | r0 = returnFunc() | |||
| 665 | + | } else { | |||
| 666 | + | r0 = ret.Error(0) | |||
| 667 | + | } | |||
| 668 | + | return r0 | |||
| 669 | + | } | |||
| 670 | + | ||||
| 671 | + | // MockContext_GrowPaneWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GrowPaneWidth' | |||
| 672 | + | type MockContext_GrowPaneWidth_Call struct { | |||
| 673 | + | *mock.Call | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | // GrowPaneWidth is a helper method to define mock.On call | |||
| 677 | + | func (_e *MockContext_Expecter) GrowPaneWidth() *MockContext_GrowPaneWidth_Call { | |||
| 678 | + | return &MockContext_GrowPaneWidth_Call{Call: _e.mock.On("GrowPaneWidth")} | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | func (_c *MockContext_GrowPaneWidth_Call) Run(run func()) *MockContext_GrowPaneWidth_Call { | |||
| 682 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 683 | + | run() | |||
| 684 | + | }) | |||
| 685 | + | return _c | |||
| 686 | + | } | |||
| 687 | + | ||||
| 688 | + | func (_c *MockContext_GrowPaneWidth_Call) Return(err error) *MockContext_GrowPaneWidth_Call { | |||
| 689 | + | _c.Call.Return(err) | |||
| 690 | + | return _c | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | func (_c *MockContext_GrowPaneWidth_Call) RunAndReturn(run func() error) *MockContext_GrowPaneWidth_Call { | |||
| 694 | + | _c.Call.Return(run) | |||
| 695 | + | return _c | |||
| 696 | + | } | |||
| 697 | + | ||||
| 522 | // JumpRecentSession provides a mock function for the type MockContext | 698 | // JumpRecentSession provides a mock function for the type MockContext | ||
| 523 | func (_mock *MockContext) JumpRecentSession(rank int) error { | 699 | func (_mock *MockContext) JumpRecentSession(rank int) error { | ||
| 524 | ret := _mock.Called(rank) | 700 | ret := _mock.Called(rank) | ||
| @@ -834,6 +1010,94 @@ func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContex | |||||
| 834 | return _c | 1010 | return _c | ||
| 835 | } | 1011 | } | ||
| 836 | 1012 | ||||
| 1013 | + | // ShrinkPaneHeight provides a mock function for the type MockContext | |||
| 1014 | + | func (_mock *MockContext) ShrinkPaneHeight() error { | |||
| 1015 | + | ret := _mock.Called() | |||
| 1016 | + | ||||
| 1017 | + | if len(ret) == 0 { | |||
| 1018 | + | panic("no return value specified for ShrinkPaneHeight") | |||
| 1019 | + | } | |||
| 1020 | + | ||||
| 1021 | + | var r0 error | |||
| 1022 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 1023 | + | r0 = returnFunc() | |||
| 1024 | + | } else { | |||
| 1025 | + | r0 = ret.Error(0) | |||
| 1026 | + | } | |||
| 1027 | + | return r0 | |||
| 1028 | + | } | |||
| 1029 | + | ||||
| 1030 | + | // MockContext_ShrinkPaneHeight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShrinkPaneHeight' | |||
| 1031 | + | type MockContext_ShrinkPaneHeight_Call struct { | |||
| 1032 | + | *mock.Call | |||
| 1033 | + | } | |||
| 1034 | + | ||||
| 1035 | + | // ShrinkPaneHeight is a helper method to define mock.On call | |||
| 1036 | + | func (_e *MockContext_Expecter) ShrinkPaneHeight() *MockContext_ShrinkPaneHeight_Call { | |||
| 1037 | + | return &MockContext_ShrinkPaneHeight_Call{Call: _e.mock.On("ShrinkPaneHeight")} | |||
| 1038 | + | } | |||
| 1039 | + | ||||
| 1040 | + | func (_c *MockContext_ShrinkPaneHeight_Call) Run(run func()) *MockContext_ShrinkPaneHeight_Call { | |||
| 1041 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1042 | + | run() | |||
| 1043 | + | }) | |||
| 1044 | + | return _c | |||
| 1045 | + | } | |||
| 1046 | + | ||||
| 1047 | + | func (_c *MockContext_ShrinkPaneHeight_Call) Return(err error) *MockContext_ShrinkPaneHeight_Call { | |||
| 1048 | + | _c.Call.Return(err) | |||
| 1049 | + | return _c | |||
| 1050 | + | } | |||
| 1051 | + | ||||
| 1052 | + | func (_c *MockContext_ShrinkPaneHeight_Call) RunAndReturn(run func() error) *MockContext_ShrinkPaneHeight_Call { | |||
| 1053 | + | _c.Call.Return(run) | |||
| 1054 | + | return _c | |||
| 1055 | + | } | |||
| 1056 | + | ||||
| 1057 | + | // ShrinkPaneWidth provides a mock function for the type MockContext | |||
| 1058 | + | func (_mock *MockContext) ShrinkPaneWidth() error { | |||
| 1059 | + | ret := _mock.Called() | |||
| 1060 | + | ||||
| 1061 | + | if len(ret) == 0 { | |||
| 1062 | + | panic("no return value specified for ShrinkPaneWidth") | |||
| 1063 | + | } | |||
| 1064 | + | ||||
| 1065 | + | var r0 error | |||
| 1066 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 1067 | + | r0 = returnFunc() | |||
| 1068 | + | } else { | |||
| 1069 | + | r0 = ret.Error(0) | |||
| 1070 | + | } | |||
| 1071 | + | return r0 | |||
| 1072 | + | } | |||
| 1073 | + | ||||
| 1074 | + | // MockContext_ShrinkPaneWidth_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ShrinkPaneWidth' | |||
| 1075 | + | type MockContext_ShrinkPaneWidth_Call struct { | |||
| 1076 | + | *mock.Call | |||
| 1077 | + | } | |||
| 1078 | + | ||||
| 1079 | + | // ShrinkPaneWidth is a helper method to define mock.On call | |||
| 1080 | + | func (_e *MockContext_Expecter) ShrinkPaneWidth() *MockContext_ShrinkPaneWidth_Call { | |||
| 1081 | + | return &MockContext_ShrinkPaneWidth_Call{Call: _e.mock.On("ShrinkPaneWidth")} | |||
| 1082 | + | } | |||
| 1083 | + | ||||
| 1084 | + | func (_c *MockContext_ShrinkPaneWidth_Call) Run(run func()) *MockContext_ShrinkPaneWidth_Call { | |||
| 1085 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1086 | + | run() | |||
| 1087 | + | }) | |||
| 1088 | + | return _c | |||
| 1089 | + | } | |||
| 1090 | + | ||||
| 1091 | + | func (_c *MockContext_ShrinkPaneWidth_Call) Return(err error) *MockContext_ShrinkPaneWidth_Call { | |||
| 1092 | + | _c.Call.Return(err) | |||
| 1093 | + | return _c | |||
| 1094 | + | } | |||
| 1095 | + | ||||
| 1096 | + | func (_c *MockContext_ShrinkPaneWidth_Call) RunAndReturn(run func() error) *MockContext_ShrinkPaneWidth_Call { | |||
| 1097 | + | _c.Call.Return(run) | |||
| 1098 | + | return _c | |||
| 1099 | + | } | |||
| 1100 | + | ||||
| 837 | // SplitDown provides a mock function for the type MockContext | 1101 | // SplitDown provides a mock function for the type MockContext | ||
| 838 | func (_mock *MockContext) SplitDown() error { | 1102 | func (_mock *MockContext) SplitDown() error { | ||
| 839 | ret := _mock.Called() | 1103 | ret := _mock.Called() | ||
M internal/usecase/command/registry.go +6 -0
| @@ -20,6 +20,12 @@ func Registry() []Command { | |||
| 20 | 20 | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), | |
| 21 | 21 | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), | |
| 22 | 22 | paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }), | |
| 23 | + | paletteOnly("resize-pane", "RSZ", "Resize pane", "Enter pane resize mode", func(ctx Context, _ []string) error { return ctx.EnterResizeMode() }), | |
| 24 | + | paletteControl("grow-pane-width", "GPW", "Grow pane width", "Grow the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneWidth() }, func(ctx ControlContext) error { return ctx.GrowPaneWidth() }), | |
| 25 | + | paletteControl("shrink-pane-width", "SPW", "Shrink pane width", "Shrink the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneWidth() }, func(ctx ControlContext) error { return ctx.ShrinkPaneWidth() }), | |
| 26 | + | paletteControl("grow-pane-height", "GPH", "Grow pane height", "Grow the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneHeight() }, func(ctx ControlContext) error { return ctx.GrowPaneHeight() }), | |
| 27 | + | paletteControl("shrink-pane-height", "SPH", "Shrink pane height", "Shrink the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneHeight() }, func(ctx ControlContext) error { return ctx.ShrinkPaneHeight() }), | |
| 28 | + | paletteControl("equalize-panes", "EQP", "Equalize panes", "Restore equal pane shares in the tab", TargetTab, func(ctx Context, _ []string) error { return ctx.EqualizePanes() }, func(ctx ControlContext) error { return ctx.EqualizePanes() }), | |
| 23 | 29 | paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }), | |
| 24 | 30 | paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }), | |
| 25 | 31 | paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }), | |
| @@ -20,6 +20,12 @@ func Registry() []Command { | |||||
| 20 | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), | 20 | paletteControl("focus-pane-right", "FPR", "Focus pane right", "Focus the pane to the right", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneRight() }, func(ctx ControlContext) error { return ctx.FocusPaneRight() }), | ||
| 21 | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), | 21 | paletteControl("focus-pane-up", "FPU", "Focus pane up", "Focus the pane above", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneUp() }, func(ctx ControlContext) error { return ctx.FocusPaneUp() }), | ||
| 22 | paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }), | 22 | paletteControl("focus-pane-down", "FPD", "Focus pane down", "Focus the pane below", TargetPane, func(ctx Context, _ []string) error { return ctx.FocusPaneDown() }, func(ctx ControlContext) error { return ctx.FocusPaneDown() }), | ||
| 23 | + | paletteOnly("resize-pane", "RSZ", "Resize pane", "Enter pane resize mode", func(ctx Context, _ []string) error { return ctx.EnterResizeMode() }), | |||
| 24 | + | paletteControl("grow-pane-width", "GPW", "Grow pane width", "Grow the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneWidth() }, func(ctx ControlContext) error { return ctx.GrowPaneWidth() }), | |||
| 25 | + | paletteControl("shrink-pane-width", "SPW", "Shrink pane width", "Shrink the pane by two columns", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneWidth() }, func(ctx ControlContext) error { return ctx.ShrinkPaneWidth() }), | |||
| 26 | + | paletteControl("grow-pane-height", "GPH", "Grow pane height", "Grow the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.GrowPaneHeight() }, func(ctx ControlContext) error { return ctx.GrowPaneHeight() }), | |||
| 27 | + | paletteControl("shrink-pane-height", "SPH", "Shrink pane height", "Shrink the pane by one row", TargetPane, func(ctx Context, _ []string) error { return ctx.ShrinkPaneHeight() }, func(ctx ControlContext) error { return ctx.ShrinkPaneHeight() }), | |||
| 28 | + | paletteControl("equalize-panes", "EQP", "Equalize panes", "Restore equal pane shares in the tab", TargetTab, func(ctx Context, _ []string) error { return ctx.EqualizePanes() }, func(ctx ControlContext) error { return ctx.EqualizePanes() }), | |||
| 23 | paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }), | 29 | paletteControl("next-tab", "NXT", "Next tab", "Switch to the next tab", TargetSession, func(ctx Context, _ []string) error { return ctx.NextTab() }, func(ctx ControlContext) error { return ctx.NextTab() }), | ||
| 24 | paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }), | 30 | paletteControl("previous-tab", "PVT", "Previous tab", "Switch to the previous tab", TargetSession, func(ctx Context, _ []string) error { return ctx.PrevTab() }, func(ctx ControlContext) error { return ctx.PrevTab() }), | ||
| 25 | paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }), | 31 | paletteOnly("back-session", "BSK", "Previous session", "Toggle the previously active session", func(ctx Context, _ []string) error { return ctx.BackSession() }), | ||
M internal/usecase/command/registry_test.go +17 -2
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 11 | 11 | commands := PaletteRegistry() | |
| 12 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | |
| 12 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | |
| 13 | 13 | ||
| 14 | 14 | if len(commands) != len(wantCodes) { | |
| 15 | 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -68,11 +68,13 @@ func TestRegistryControlMetadata(t *testing.T) { | |||
| 68 | 68 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | |
| 69 | 69 | "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab, | |
| 70 | 70 | "new-session": TargetSession, "toast": TargetSession, | |
| 71 | + | "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane, | |
| 72 | + | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | |
| 71 | 73 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | |
| 72 | 74 | } | |
| 73 | 75 | notScriptable := []string{ | |
| 74 | 76 | "session-picker", "notifications", "visual-mode", "yank-last-notification", | |
| 75 | - | "detach", "back-session", "jump-recent-session", "toggle-floating-pane", | |
| 77 | + | "detach", "back-session", "jump-recent-session", "toggle-floating-pane", "resize-pane", | |
| 76 | 78 | } | |
| 77 | 79 | for slug, target := range scriptable { | |
| 78 | 80 | cmd, ok := BySlug(slug) | |
| @@ -115,6 +117,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||
| 115 | 117 | }{ | |
| 116 | 118 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | |
| 117 | 119 | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | |
| 120 | + | {name: "resize mutation delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"}, | |
| 121 | + | {name: "equalize mutation delegates", slug: "equalize-panes", wantCall: "equalize-panes"}, | |
| 118 | 122 | {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"}, | |
| 119 | 123 | {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments}, | |
| 120 | 124 | {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"}, | |
| @@ -153,6 +157,11 @@ func (s *controlSpy) SplitUp() error { return s.record("split | |||
| 153 | 157 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | |
| 154 | 158 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | |
| 155 | 159 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | |
| 160 | + | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | |
| 161 | + | func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") } | |
| 162 | + | func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") } | |
| 163 | + | func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") } | |
| 164 | + | func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") } | |
| 156 | 165 | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | |
| 157 | 166 | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | |
| 158 | 167 | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | |
| @@ -244,6 +253,12 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 244 | 253 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | |
| 245 | 254 | {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }}, | |
| 246 | 255 | {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }}, | |
| 256 | + | {code: "RSZ", expect: func(ctx *MockContext) { ctx.EXPECT().EnterResizeMode().Return(nil).Once() }}, | |
| 257 | + | {code: "GPW", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneWidth().Return(nil).Once() }}, | |
| 258 | + | {code: "SPW", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneWidth().Return(nil).Once() }}, | |
| 259 | + | {code: "GPH", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneHeight().Return(nil).Once() }}, | |
| 260 | + | {code: "SPH", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneHeight().Return(nil).Once() }}, | |
| 261 | + | {code: "EQP", expect: func(ctx *MockContext) { ctx.EXPECT().EqualizePanes().Return(nil).Once() }}, | |
| 247 | 262 | {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }}, | |
| 248 | 263 | {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }}, | |
| 249 | 264 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | |
| @@ -9,7 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | ||
| 11 | commands := PaletteRegistry() | 11 | commands := PaletteRegistry() | ||
| 12 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | 12 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} |
| 13 | 13 | ||||
| 14 | if len(commands) != len(wantCodes) { | 14 | if len(commands) != len(wantCodes) { | ||
| 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | ||
| @@ -68,11 +68,13 @@ func TestRegistryControlMetadata(t *testing.T) { | |||||
| 68 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | 68 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | ||
| 69 | "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab, | 69 | "previous-tab": TargetSession, "rename-session": TargetSession, "rename-tab": TargetTab, | ||
| 70 | "new-session": TargetSession, "toast": TargetSession, | 70 | "new-session": TargetSession, "toast": TargetSession, | ||
| 71 | + | "grow-pane-width": TargetPane, "shrink-pane-width": TargetPane, | |||
| 72 | + | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | |||
| 71 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | 73 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | ||
| 72 | } | 74 | } | ||
| 73 | notScriptable := []string{ | 75 | notScriptable := []string{ | ||
| 74 | "session-picker", "notifications", "visual-mode", "yank-last-notification", | 76 | "session-picker", "notifications", "visual-mode", "yank-last-notification", | ||
| 75 | - | "detach", "back-session", "jump-recent-session", "toggle-floating-pane", | 77 | + | "detach", "back-session", "jump-recent-session", "toggle-floating-pane", "resize-pane", |
| 76 | } | 78 | } | ||
| 77 | for slug, target := range scriptable { | 79 | for slug, target := range scriptable { | ||
| 78 | cmd, ok := BySlug(slug) | 80 | cmd, ok := BySlug(slug) | ||
| @@ -115,6 +117,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||||
| 115 | }{ | 117 | }{ | ||
| 116 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | 118 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | ||
| 117 | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | 119 | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | ||
| 120 | + | {name: "resize mutation delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"}, | |||
| 121 | + | {name: "equalize mutation delegates", slug: "equalize-panes", wantCall: "equalize-panes"}, | |||
| 118 | {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"}, | 122 | {name: "rename delegates name", slug: "rename-tab", args: []string{"editor"}, wantCall: "rename-tab:editor"}, | ||
| 119 | {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments}, | 123 | {name: "rename rejects empty name", slug: "rename-tab", args: []string{""}, wantErr: ErrInvalidArguments}, | ||
| 120 | {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"}, | 124 | {name: "toast delegates severity", slug: "toast", args: []string{"-l", "warn", "hello"}, wantCall: "toast:warn:hello"}, | ||
| @@ -153,6 +157,11 @@ func (s *controlSpy) SplitUp() error { return s.record("split | |||||
| 153 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | 157 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | ||
| 154 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | 158 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | ||
| 155 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | 159 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | ||
| 160 | + | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | |||
| 161 | + | func (s *controlSpy) ShrinkPaneWidth() error { return s.record("shrink-pane-width") } | |||
| 162 | + | func (s *controlSpy) GrowPaneHeight() error { return s.record("grow-pane-height") } | |||
| 163 | + | func (s *controlSpy) ShrinkPaneHeight() error { return s.record("shrink-pane-height") } | |||
| 164 | + | func (s *controlSpy) EqualizePanes() error { return s.record("equalize-panes") } | |||
| 156 | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | 165 | func (s *controlSpy) FocusPaneLeft() error { return s.record("focus-pane-left") } | ||
| 157 | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | 166 | func (s *controlSpy) FocusPaneRight() error { return s.record("focus-pane-right") } | ||
| 158 | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | 167 | func (s *controlSpy) FocusPaneUp() error { return s.record("focus-pane-up") } | ||
| @@ -244,6 +253,12 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 244 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | 253 | {code: "FPR", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneRight().Return(nil).Once() }}, | ||
| 245 | {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }}, | 254 | {code: "FPU", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneUp().Return(nil).Once() }}, | ||
| 246 | {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }}, | 255 | {code: "FPD", expect: func(ctx *MockContext) { ctx.EXPECT().FocusPaneDown().Return(nil).Once() }}, | ||
| 256 | + | {code: "RSZ", expect: func(ctx *MockContext) { ctx.EXPECT().EnterResizeMode().Return(nil).Once() }}, | |||
| 257 | + | {code: "GPW", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneWidth().Return(nil).Once() }}, | |||
| 258 | + | {code: "SPW", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneWidth().Return(nil).Once() }}, | |||
| 259 | + | {code: "GPH", expect: func(ctx *MockContext) { ctx.EXPECT().GrowPaneHeight().Return(nil).Once() }}, | |||
| 260 | + | {code: "SPH", expect: func(ctx *MockContext) { ctx.EXPECT().ShrinkPaneHeight().Return(nil).Once() }}, | |||
| 261 | + | {code: "EQP", expect: func(ctx *MockContext) { ctx.EXPECT().EqualizePanes().Return(nil).Once() }}, | |||
| 247 | {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }}, | 262 | {code: "NXT", expect: func(ctx *MockContext) { ctx.EXPECT().NextTab().Return(nil).Once() }}, | ||
| 248 | {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }}, | 263 | {code: "PVT", expect: func(ctx *MockContext) { ctx.EXPECT().PrevTab().Return(nil).Once() }}, | ||
| 249 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | 264 | {code: "BSK", expect: func(ctx *MockContext) { ctx.EXPECT().BackSession().Return(nil).Once() }}, | ||
M internal/usecase/daemon/control.go +22 -0
| @@ -70,6 +70,10 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports | |||
| 70 | 70 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | |
| 71 | 71 | case errors.Is(err, errSessionNameInUse): | |
| 72 | 72 | return commandFailure(ports.ErrNameTaken, err.Error()) | |
| 73 | + | case errors.Is(err, layout.ErrNotInSplit): | |
| 74 | + | return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split") | |
| 75 | + | case errors.Is(err, layout.ErrTooSmall): | |
| 76 | + | return commandFailure(ports.ErrNoSuchTarget, "pane cannot be resized further") | |
| 73 | 77 | default: | |
| 74 | 78 | return commandFailure(ports.ErrInternal, err.Error()) | |
| 75 | 79 | } | |
| @@ -198,6 +202,8 @@ const ( | |||
| 198 | 202 | daemonActionPreviousTab | |
| 199 | 203 | daemonActionRenameSession | |
| 200 | 204 | daemonActionRenameTab | |
| 205 | + | daemonActionResizePane | |
| 206 | + | daemonActionEqualizePanes | |
| 201 | 207 | ) | |
| 202 | 208 | ||
| 203 | 209 | type daemonActionTarget struct { | |
| @@ -210,6 +216,8 @@ type daemonActionRequest struct { | |||
| 210 | 216 | kind daemonActionKind | |
| 211 | 217 | target daemonActionTarget | |
| 212 | 218 | direction layout.Direction | |
| 219 | + | axis layout.Axis | |
| 220 | + | delta int | |
| 213 | 221 | viewport domain.Size | |
| 214 | 222 | name string | |
| 215 | 223 | } | |
| @@ -274,6 +282,10 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 274 | 282 | return a.d.renameSession(target.session, request.name) | |
| 275 | 283 | case daemonActionRenameTab: | |
| 276 | 284 | return a.d.renameTab(target.session, target.tab, request.name) | |
| 285 | + | case daemonActionResizePane: | |
| 286 | + | return a.d.resizePane(target, request.axis, request.delta) | |
| 287 | + | case daemonActionEqualizePanes: | |
| 288 | + | return a.d.equalizePanes(target) | |
| 277 | 289 | default: | |
| 278 | 290 | return errors.New("daemon: unknown action") | |
| 279 | 291 | } | |
| @@ -405,6 +417,16 @@ func (e controlExec) StackPane() error { | |||
| 405 | 417 | func (e controlExec) ToggleStack() error { | |
| 406 | 418 | return e.runAction(daemonActionRequest{kind: daemonActionToggleStack}) | |
| 407 | 419 | } | |
| 420 | + | func (e controlExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) } | |
| 421 | + | func (e controlExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) } | |
| 422 | + | func (e controlExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) } | |
| 423 | + | func (e controlExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) } | |
| 424 | + | func (e controlExec) resize(axis layout.Axis, delta int) error { | |
| 425 | + | return e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}) | |
| 426 | + | } | |
| 427 | + | func (e controlExec) EqualizePanes() error { | |
| 428 | + | return e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}) | |
| 429 | + | } | |
| 408 | 430 | func (e controlExec) focus(direction layout.Direction) error { | |
| 409 | 431 | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | |
| 410 | 432 | } | |
| @@ -70,6 +70,10 @@ func (d *Daemon) runControl(cmd command.Command, exec controlExec, request ports | |||||
| 70 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | 70 | return commandFailure(ports.ErrInvalidCommandArgs, "usage: "+cmd.Usage) | ||
| 71 | case errors.Is(err, errSessionNameInUse): | 71 | case errors.Is(err, errSessionNameInUse): | ||
| 72 | return commandFailure(ports.ErrNameTaken, err.Error()) | 72 | return commandFailure(ports.ErrNameTaken, err.Error()) | ||
| 73 | + | case errors.Is(err, layout.ErrNotInSplit): | |||
| 74 | + | return commandFailure(ports.ErrNoSuchTarget, "pane is not in a split") | |||
| 75 | + | case errors.Is(err, layout.ErrTooSmall): | |||
| 76 | + | return commandFailure(ports.ErrNoSuchTarget, "pane cannot be resized further") | |||
| 73 | default: | 77 | default: | ||
| 74 | return commandFailure(ports.ErrInternal, err.Error()) | 78 | return commandFailure(ports.ErrInternal, err.Error()) | ||
| 75 | } | 79 | } | ||
| @@ -198,6 +202,8 @@ const ( | |||||
| 198 | daemonActionPreviousTab | 202 | daemonActionPreviousTab | ||
| 199 | daemonActionRenameSession | 203 | daemonActionRenameSession | ||
| 200 | daemonActionRenameTab | 204 | daemonActionRenameTab | ||
| 205 | + | daemonActionResizePane | |||
| 206 | + | daemonActionEqualizePanes | |||
| 201 | ) | 207 | ) | ||
| 202 | 208 | ||||
| 203 | type daemonActionTarget struct { | 209 | type daemonActionTarget struct { | ||
| @@ -210,6 +216,8 @@ type daemonActionRequest struct { | |||||
| 210 | kind daemonActionKind | 216 | kind daemonActionKind | ||
| 211 | target daemonActionTarget | 217 | target daemonActionTarget | ||
| 212 | direction layout.Direction | 218 | direction layout.Direction | ||
| 219 | + | axis layout.Axis | |||
| 220 | + | delta int | |||
| 213 | viewport domain.Size | 221 | viewport domain.Size | ||
| 214 | name string | 222 | name string | ||
| 215 | } | 223 | } | ||
| @@ -274,6 +282,10 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||||
| 274 | return a.d.renameSession(target.session, request.name) | 282 | return a.d.renameSession(target.session, request.name) | ||
| 275 | case daemonActionRenameTab: | 283 | case daemonActionRenameTab: | ||
| 276 | return a.d.renameTab(target.session, target.tab, request.name) | 284 | return a.d.renameTab(target.session, target.tab, request.name) | ||
| 285 | + | case daemonActionResizePane: | |||
| 286 | + | return a.d.resizePane(target, request.axis, request.delta) | |||
| 287 | + | case daemonActionEqualizePanes: | |||
| 288 | + | return a.d.equalizePanes(target) | |||
| 277 | default: | 289 | default: | ||
| 278 | return errors.New("daemon: unknown action") | 290 | return errors.New("daemon: unknown action") | ||
| 279 | } | 291 | } | ||
| @@ -405,6 +417,16 @@ func (e controlExec) StackPane() error { | |||||
| 405 | func (e controlExec) ToggleStack() error { | 417 | func (e controlExec) ToggleStack() error { | ||
| 406 | return e.runAction(daemonActionRequest{kind: daemonActionToggleStack}) | 418 | return e.runAction(daemonActionRequest{kind: daemonActionToggleStack}) | ||
| 407 | } | 419 | } | ||
| 420 | + | func (e controlExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) } | |||
| 421 | + | func (e controlExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) } | |||
| 422 | + | func (e controlExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) } | |||
| 423 | + | func (e controlExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) } | |||
| 424 | + | func (e controlExec) resize(axis layout.Axis, delta int) error { | |||
| 425 | + | return e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}) | |||
| 426 | + | } | |||
| 427 | + | func (e controlExec) EqualizePanes() error { | |||
| 428 | + | return e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes}) | |||
| 429 | + | } | |||
| 408 | func (e controlExec) focus(direction layout.Direction) error { | 430 | func (e controlExec) focus(direction layout.Direction) error { | ||
| 409 | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | 431 | return e.runAction(daemonActionRequest{kind: daemonActionFocusPane, direction: direction}) | ||
| 410 | } | 432 | } | ||
M internal/usecase/daemon/input.go +1 -1
| @@ -36,7 +36,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||
| 36 | 36 | frameEvent := ev | |
| 37 | 37 | ac.initOverlays() | |
| 38 | 38 | rt := ac.overlays | |
| 39 | - | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() { | |
| 39 | + | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() { | |
| 40 | 40 | return | |
| 41 | 41 | } | |
| 42 | 42 | sess := ac.currentSession() | |
| @@ -36,7 +36,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 36 | frameEvent := ev | 36 | frameEvent := ev | ||
| 37 | ac.initOverlays() | 37 | ac.initOverlays() | ||
| 38 | rt := ac.overlays | 38 | rt := ac.overlays | ||
| 39 | - | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() { | 39 | + | if rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() { |
| 40 | return | 40 | return | ||
| 41 | } | 41 | } | ||
| 42 | sess := ac.currentSession() | 42 | sess := ac.currentSession() | ||
M internal/usecase/daemon/overlay.go +10 -0
| @@ -13,6 +13,8 @@ type overlayEvents struct { | |||
| 13 | 13 | cancel func() | |
| 14 | 14 | up func() | |
| 15 | 15 | down func() | |
| 16 | + | left func() | |
| 17 | + | right func() | |
| 16 | 18 | } | |
| 17 | 19 | ||
| 18 | 20 | func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) { | |
| @@ -92,6 +94,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete | |||
| 92 | 94 | call(ev.up) | |
| 93 | 95 | case 'B': | |
| 94 | 96 | call(ev.down) | |
| 97 | + | case 'C': | |
| 98 | + | call(ev.right) | |
| 99 | + | case 'D': | |
| 100 | + | call(ev.left) | |
| 95 | 101 | } | |
| 96 | 102 | return 3, false | |
| 97 | 103 | case '[': | |
| @@ -105,6 +111,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete | |||
| 105 | 111 | call(ev.up) | |
| 106 | 112 | case 'B': | |
| 107 | 113 | call(ev.down) | |
| 114 | + | case 'C': | |
| 115 | + | call(ev.right) | |
| 116 | + | case 'D': | |
| 117 | + | call(ev.left) | |
| 108 | 118 | } | |
| 109 | 119 | } | |
| 110 | 120 | return i + 1, false | |
| @@ -13,6 +13,8 @@ type overlayEvents struct { | |||||
| 13 | cancel func() | 13 | cancel func() | ||
| 14 | up func() | 14 | up func() | ||
| 15 | down func() | 15 | down func() | ||
| 16 | + | left func() | |||
| 17 | + | right func() | |||
| 16 | } | 18 | } | ||
| 17 | 19 | ||||
| 18 | func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) { | 20 | func routeOverlayBytes(data []byte, pending *[]byte, ev overlayEvents) { | ||
| @@ -92,6 +94,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete | |||||
| 92 | call(ev.up) | 94 | call(ev.up) | ||
| 93 | case 'B': | 95 | case 'B': | ||
| 94 | call(ev.down) | 96 | call(ev.down) | ||
| 97 | + | case 'C': | |||
| 98 | + | call(ev.right) | |||
| 99 | + | case 'D': | |||
| 100 | + | call(ev.left) | |||
| 95 | } | 101 | } | ||
| 96 | return 3, false | 102 | return 3, false | ||
| 97 | case '[': | 103 | case '[': | ||
| @@ -105,6 +111,10 @@ func routeOverlayEscape(data []byte, ev overlayEvents) (consumed int, incomplete | |||||
| 105 | call(ev.up) | 111 | call(ev.up) | ||
| 106 | case 'B': | 112 | case 'B': | ||
| 107 | call(ev.down) | 113 | call(ev.down) | ||
| 114 | + | case 'C': | |||
| 115 | + | call(ev.right) | |||
| 116 | + | case 'D': | |||
| 117 | + | call(ev.left) | |||
| 108 | } | 118 | } | ||
| 109 | } | 119 | } | ||
| 110 | return i + 1, false | 120 | return i + 1, false | ||
M internal/usecase/daemon/overlay_runtime.go +24 -1
| @@ -37,6 +37,11 @@ type overlayRuntime struct { | |||
| 37 | 37 | promptSubmit func(string) error | |
| 38 | 38 | promptPending []byte | |
| 39 | 39 | ||
| 40 | + | resizeMu sync.Mutex | |
| 41 | + | resizeActive bool | |
| 42 | + | resizePending []byte | |
| 43 | + | resizeESC pendingByteTimer | |
| 44 | + | ||
| 40 | 45 | copyMu sync.Mutex | |
| 41 | 46 | copyMode *scopy.Mode | |
| 42 | 47 | copyCandidate *scopy.Mode | |
| @@ -98,7 +103,7 @@ func (rt *overlayRuntime) Active() bool { | |||
| 98 | 103 | if rt == nil || rt.ac == nil { | |
| 99 | 104 | return false | |
| 100 | 105 | } | |
| 101 | - | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive() | |
| 106 | + | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive() | |
| 102 | 107 | } | |
| 103 | 108 | ||
| 104 | 109 | func (rt *overlayRuntime) promptActive() bool { | |
| @@ -137,6 +142,15 @@ func (rt *overlayRuntime) noticesActive() bool { | |||
| 137 | 142 | return rt.noticesOverlay != nil | |
| 138 | 143 | } | |
| 139 | 144 | ||
| 145 | + | func (rt *overlayRuntime) resizeModeActive() bool { | |
| 146 | + | if rt == nil { | |
| 147 | + | return false | |
| 148 | + | } | |
| 149 | + | rt.resizeMu.Lock() | |
| 150 | + | defer rt.resizeMu.Unlock() | |
| 151 | + | return rt.resizeActive | |
| 152 | + | } | |
| 153 | + | ||
| 140 | 154 | func (rt *overlayRuntime) copyActive() bool { | |
| 141 | 155 | if rt == nil { | |
| 142 | 156 | return false | |
| @@ -235,6 +249,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |||
| 235 | 249 | d.handleNoticesInput(ac, data) | |
| 236 | 250 | return true | |
| 237 | 251 | } | |
| 252 | + | if rt.resizeModeActive() { | |
| 253 | + | d.handleResizeInput(ac, data) | |
| 254 | + | return true | |
| 255 | + | } | |
| 238 | 256 | if rt.copyActive() { | |
| 239 | 257 | d.handleCopyInput(ac, data) | |
| 240 | 258 | return true | |
| @@ -250,6 +268,7 @@ type overlayRenderSnapshot struct { | |||
| 250 | 268 | copyPane *pane | |
| 251 | 269 | copySearchModel *visualsearch.Model | |
| 252 | 270 | statusFeedback string | |
| 271 | + | resizeActive bool | |
| 253 | 272 | ||
| 254 | 273 | pickerActive bool | |
| 255 | 274 | pickerModel *picker.Model | |
| @@ -316,6 +335,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||
| 316 | 335 | } | |
| 317 | 336 | rt.copyMu.Unlock() | |
| 318 | 337 | ||
| 338 | + | rt.resizeMu.Lock() | |
| 339 | + | snap.resizeActive = rt.resizeActive | |
| 340 | + | rt.resizeMu.Unlock() | |
| 341 | + | ||
| 319 | 342 | rt.pickerMu.Lock() | |
| 320 | 343 | snap.pickerActive = rt.picker != nil | |
| 321 | 344 | snap.pickerModel = rt.picker.Clone() | |
| @@ -37,6 +37,11 @@ type overlayRuntime struct { | |||||
| 37 | promptSubmit func(string) error | 37 | promptSubmit func(string) error | ||
| 38 | promptPending []byte | 38 | promptPending []byte | ||
| 39 | 39 | ||||
| 40 | + | resizeMu sync.Mutex | |||
| 41 | + | resizeActive bool | |||
| 42 | + | resizePending []byte | |||
| 43 | + | resizeESC pendingByteTimer | |||
| 44 | + | ||||
| 40 | copyMu sync.Mutex | 45 | copyMu sync.Mutex | ||
| 41 | copyMode *scopy.Mode | 46 | copyMode *scopy.Mode | ||
| 42 | copyCandidate *scopy.Mode | 47 | copyCandidate *scopy.Mode | ||
| @@ -98,7 +103,7 @@ func (rt *overlayRuntime) Active() bool { | |||||
| 98 | if rt == nil || rt.ac == nil { | 103 | if rt == nil || rt.ac == nil { | ||
| 99 | return false | 104 | return false | ||
| 100 | } | 105 | } | ||
| 101 | - | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.copyActive() | 106 | + | return rt.promptActive() || rt.paletteActive() || rt.pickerActive() || rt.noticesActive() || rt.resizeModeActive() || rt.copyActive() |
| 102 | } | 107 | } | ||
| 103 | 108 | ||||
| 104 | func (rt *overlayRuntime) promptActive() bool { | 109 | func (rt *overlayRuntime) promptActive() bool { | ||
| @@ -137,6 +142,15 @@ func (rt *overlayRuntime) noticesActive() bool { | |||||
| 137 | return rt.noticesOverlay != nil | 142 | return rt.noticesOverlay != nil | ||
| 138 | } | 143 | } | ||
| 139 | 144 | ||||
| 145 | + | func (rt *overlayRuntime) resizeModeActive() bool { | |||
| 146 | + | if rt == nil { | |||
| 147 | + | return false | |||
| 148 | + | } | |||
| 149 | + | rt.resizeMu.Lock() | |||
| 150 | + | defer rt.resizeMu.Unlock() | |||
| 151 | + | return rt.resizeActive | |||
| 152 | + | } | |||
| 153 | + | ||||
| 140 | func (rt *overlayRuntime) copyActive() bool { | 154 | func (rt *overlayRuntime) copyActive() bool { | ||
| 141 | if rt == nil { | 155 | if rt == nil { | ||
| 142 | return false | 156 | return false | ||
| @@ -235,6 +249,10 @@ func (rt *overlayRuntime) HandleInput(d *Daemon, data []byte) bool { | |||||
| 235 | d.handleNoticesInput(ac, data) | 249 | d.handleNoticesInput(ac, data) | ||
| 236 | return true | 250 | return true | ||
| 237 | } | 251 | } | ||
| 252 | + | if rt.resizeModeActive() { | |||
| 253 | + | d.handleResizeInput(ac, data) | |||
| 254 | + | return true | |||
| 255 | + | } | |||
| 238 | if rt.copyActive() { | 256 | if rt.copyActive() { | ||
| 239 | d.handleCopyInput(ac, data) | 257 | d.handleCopyInput(ac, data) | ||
| 240 | return true | 258 | return true | ||
| @@ -250,6 +268,7 @@ type overlayRenderSnapshot struct { | |||||
| 250 | copyPane *pane | 268 | copyPane *pane | ||
| 251 | copySearchModel *visualsearch.Model | 269 | copySearchModel *visualsearch.Model | ||
| 252 | statusFeedback string | 270 | statusFeedback string | ||
| 271 | + | resizeActive bool | |||
| 253 | 272 | ||||
| 254 | pickerActive bool | 273 | pickerActive bool | ||
| 255 | pickerModel *picker.Model | 274 | pickerModel *picker.Model | ||
| @@ -316,6 +335,10 @@ func (rt *overlayRuntime) SnapshotForRender() *overlayRenderSnapshot { | |||||
| 316 | } | 335 | } | ||
| 317 | rt.copyMu.Unlock() | 336 | rt.copyMu.Unlock() | ||
| 318 | 337 | ||||
| 338 | + | rt.resizeMu.Lock() | |||
| 339 | + | snap.resizeActive = rt.resizeActive | |||
| 340 | + | rt.resizeMu.Unlock() | |||
| 341 | + | ||||
| 319 | rt.pickerMu.Lock() | 342 | rt.pickerMu.Lock() | ||
| 320 | snap.pickerActive = rt.picker != nil | 343 | snap.pickerActive = rt.picker != nil | ||
| 321 | snap.pickerModel = rt.picker.Clone() | 344 | snap.pickerModel = rt.picker.Clone() | ||
M internal/usecase/daemon/palette.go +12 -0
| @@ -418,6 +418,18 @@ func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) } | |||
| 418 | 418 | func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) } | |
| 419 | 419 | func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) } | |
| 420 | 420 | ||
| 421 | + | func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) } | |
| 422 | + | func (e paletteExec) resize(axis layout.Axis, delta int) error { | |
| 423 | + | return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})) | |
| 424 | + | } | |
| 425 | + | func (e paletteExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) } | |
| 426 | + | func (e paletteExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) } | |
| 427 | + | func (e paletteExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) } | |
| 428 | + | func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) } | |
| 429 | + | func (e paletteExec) EqualizePanes() error { | |
| 430 | + | return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes})) | |
| 431 | + | } | |
| 432 | + | ||
| 421 | 433 | func (e paletteExec) NextTab() error { | |
| 422 | 434 | return e.runAction(daemonActionRequest{kind: daemonActionNextTab}) | |
| 423 | 435 | } | |
| @@ -418,6 +418,18 @@ func (e paletteExec) FocusPaneRight() error { return e.focus(layout.Right) } | |||||
| 418 | func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) } | 418 | func (e paletteExec) FocusPaneUp() error { return e.focus(layout.Up) } | ||
| 419 | func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) } | 419 | func (e paletteExec) FocusPaneDown() error { return e.focus(layout.Down) } | ||
| 420 | 420 | ||||
| 421 | + | func (e paletteExec) EnterResizeMode() error { return e.d.enterResizeMode(e.sess, e.ac) } | |||
| 422 | + | func (e paletteExec) resize(axis layout.Axis, delta int) error { | |||
| 423 | + | return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta})) | |||
| 424 | + | } | |||
| 425 | + | func (e paletteExec) GrowPaneWidth() error { return e.resize(layout.Width, resizeStepCols) } | |||
| 426 | + | func (e paletteExec) ShrinkPaneWidth() error { return e.resize(layout.Width, -resizeStepCols) } | |||
| 427 | + | func (e paletteExec) GrowPaneHeight() error { return e.resize(layout.Height, resizeStepRows) } | |||
| 428 | + | func (e paletteExec) ShrinkPaneHeight() error { return e.resize(layout.Height, -resizeStepRows) } | |||
| 429 | + | func (e paletteExec) EqualizePanes() error { | |||
| 430 | + | return resizeUserError(e.runAction(daemonActionRequest{kind: daemonActionEqualizePanes})) | |||
| 431 | + | } | |||
| 432 | + | ||||
| 421 | func (e paletteExec) NextTab() error { | 433 | func (e paletteExec) NextTab() error { | ||
| 422 | return e.runAction(daemonActionRequest{kind: daemonActionNextTab}) | 434 | return e.runAction(daemonActionRequest{kind: daemonActionNextTab}) | ||
| 423 | } | 435 | } | ||
M internal/usecase/daemon/palette_test.go +2 -2
| @@ -617,12 +617,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 617 | 617 | for i, cmd := range commands { | |
| 618 | 618 | codes[i] = cmd.Code | |
| 619 | 619 | } | |
| 620 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | |
| 620 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | |
| 621 | 621 | } | |
| 622 | 622 | ||
| 623 | 623 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 624 | 624 | d := &Daemon{} | |
| 625 | - | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} | |
| 625 | + | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} | |
| 626 | 626 | ||
| 627 | 627 | var wg sync.WaitGroup | |
| 628 | 628 | for range 50 { | |
| @@ -617,12 +617,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||||
| 617 | for i, cmd := range commands { | 617 | for i, cmd := range commands { | ||
| 618 | codes[i] = cmd.Code | 618 | codes[i] = cmd.Code | ||
| 619 | } | 619 | } | ||
| 620 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | 620 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) |
| 621 | } | 621 | } | ||
| 622 | 622 | ||||
| 623 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | 623 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | ||
| 624 | d := &Daemon{} | 624 | d := &Daemon{} | ||
| 625 | - | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} | 625 | + | codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "SSP", "VIS", "RNS", "RNT", "DET"} |
| 626 | 626 | ||||
| 627 | var wg sync.WaitGroup | 627 | var wg sync.WaitGroup | ||
| 628 | for range 50 { | 628 | for range 50 { | ||
M internal/usecase/daemon/render.go +6 -2
| @@ -298,7 +298,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 298 | 298 | d.refreshSessionFocusedTitles(sess) | |
| 299 | 299 | // Contextual ranks are completely captured under paletteMu. Compose them | |
| 300 | 300 | // without reading the live MRU, whose order may have changed mid-interaction. | |
| 301 | - | bars := d.barStateForPaletteHints(sess, overlays.statusFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 301 | + | statusFeedback := overlays.statusFeedback | |
| 302 | + | if statusFeedback == "" && overlays.resizeActive { | |
| 303 | + | statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit" | |
| 304 | + | } | |
| 305 | + | bars := d.barStateForPaletteHints(sess, statusFeedback, overlays.paletteHints, overlays.paletteRecent) | |
| 302 | 306 | applied := ac.getAppliedTheme() | |
| 303 | 307 | bars.theme = applied.Raw | |
| 304 | 308 | attentionVisible := pulseVisible(bars.attentionFrame) | |
| @@ -327,7 +331,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||
| 327 | 331 | capturedOverlays := capturedOverlayRenderState{ | |
| 328 | 332 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | |
| 329 | 333 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | |
| 330 | - | statusFeedback: overlays.statusFeedback, | |
| 334 | + | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, | |
| 331 | 335 | } | |
| 332 | 336 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | |
| 333 | 337 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | |
| @@ -298,7 +298,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 298 | d.refreshSessionFocusedTitles(sess) | 298 | d.refreshSessionFocusedTitles(sess) | ||
| 299 | // Contextual ranks are completely captured under paletteMu. Compose them | 299 | // Contextual ranks are completely captured under paletteMu. Compose them | ||
| 300 | // without reading the live MRU, whose order may have changed mid-interaction. | 300 | // without reading the live MRU, whose order may have changed mid-interaction. | ||
| 301 | - | bars := d.barStateForPaletteHints(sess, overlays.statusFeedback, overlays.paletteHints, overlays.paletteRecent) | 301 | + | statusFeedback := overlays.statusFeedback |
| 302 | + | if statusFeedback == "" && overlays.resizeActive { | |||
| 303 | + | statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit" | |||
| 304 | + | } | |||
| 305 | + | bars := d.barStateForPaletteHints(sess, statusFeedback, overlays.paletteHints, overlays.paletteRecent) | |||
| 302 | applied := ac.getAppliedTheme() | 306 | applied := ac.getAppliedTheme() | ||
| 303 | bars.theme = applied.Raw | 307 | bars.theme = applied.Raw | ||
| 304 | attentionVisible := pulseVisible(bars.attentionFrame) | 308 | attentionVisible := pulseVisible(bars.attentionFrame) | ||
| @@ -327,7 +331,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att | |||||
| 327 | capturedOverlays := capturedOverlayRenderState{ | 331 | capturedOverlays := capturedOverlayRenderState{ | ||
| 328 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | 332 | copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil, | ||
| 329 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | 333 | pickerActive: overlays.pickerActive, paletteActive: overlays.paletteActive, promptActive: overlays.promptActive, | ||
| 330 | - | statusFeedback: overlays.statusFeedback, | 334 | + | resizeActive: overlays.resizeActive, statusFeedback: statusFeedback, |
| 331 | } | 335 | } | ||
| 332 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | 336 | endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0) | ||
| 333 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | 337 | state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{ | ||
M internal/usecase/daemon/render_capture.go +1 -1
| @@ -86,7 +86,7 @@ type capturedFloatingRenderState struct { | |||
| 86 | 86 | ||
| 87 | 87 | type capturedOverlayRenderState struct { | |
| 88 | 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |
| 89 | - | noticesOverlayActive bool | |
| 89 | + | noticesOverlayActive, resizeActive bool | |
| 90 | 90 | copyMode *scopy.Mode | |
| 91 | 91 | copyPaneID layout.PaneID | |
| 92 | 92 | statusFeedback string | |
| @@ -86,7 +86,7 @@ type capturedFloatingRenderState struct { | |||||
| 86 | 86 | ||||
| 87 | type capturedOverlayRenderState struct { | 87 | type capturedOverlayRenderState struct { | ||
| 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | 88 | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | ||
| 89 | - | noticesOverlayActive bool | 89 | + | noticesOverlayActive, resizeActive bool |
| 90 | copyMode *scopy.Mode | 90 | copyMode *scopy.Mode | ||
| 91 | copyPaneID layout.PaneID | 91 | copyPaneID layout.PaneID | ||
| 92 | statusFeedback string | 92 | statusFeedback string | ||
A internal/usecase/daemon/resize_mode.go +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | resizeStepCols = 2 | |
| 14 | + | resizeStepRows = 1 | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func (d *Daemon) resizePane(target daemonActionTarget, axis layout.Axis, delta int) error { | |
| 18 | + | return d.mutateTargetLayout(target, true, func(candidate *layout.Tree, area domain.Rect) error { | |
| 19 | + | originalFocus := candidate.Focus | |
| 20 | + | candidate.Focus = target.pane.id | |
| 21 | + | if err := candidate.ResizeFocus(axis, delta, area); err != nil { | |
| 22 | + | return err | |
| 23 | + | } | |
| 24 | + | candidate.Focus = originalFocus | |
| 25 | + | return nil | |
| 26 | + | }) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (d *Daemon) equalizePanes(target daemonActionTarget) error { | |
| 30 | + | return d.mutateTargetLayout(target, false, func(candidate *layout.Tree, area domain.Rect) error { | |
| 31 | + | return candidate.Equalize(area) | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error { | |
| 36 | + | if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) { | |
| 37 | + | return layout.ErrNotFound | |
| 38 | + | } | |
| 39 | + | d.mu.Lock() | |
| 40 | + | if d.sessions[target.session.id] != target.session { | |
| 41 | + | d.mu.Unlock() | |
| 42 | + | return layout.ErrNotFound | |
| 43 | + | } | |
| 44 | + | target.session.mu.Lock() | |
| 45 | + | foundTab := false | |
| 46 | + | for _, tb := range target.session.tabs { | |
| 47 | + | if tb == target.tab { | |
| 48 | + | foundTab = true | |
| 49 | + | break | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | if !foundTab { | |
| 53 | + | target.session.mu.Unlock() | |
| 54 | + | d.mu.Unlock() | |
| 55 | + | return layout.ErrNotFound | |
| 56 | + | } | |
| 57 | + | target.tab.mu.Lock() | |
| 58 | + | if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) { | |
| 59 | + | target.tab.mu.Unlock() | |
| 60 | + | target.session.mu.Unlock() | |
| 61 | + | d.mu.Unlock() | |
| 62 | + | return layout.ErrNotFound | |
| 63 | + | } | |
| 64 | + | candidate := target.tab.tree.Clone() | |
| 65 | + | area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows} | |
| 66 | + | if err := mutate(candidate, area); err != nil { | |
| 67 | + | target.tab.mu.Unlock() | |
| 68 | + | target.session.mu.Unlock() | |
| 69 | + | d.mu.Unlock() | |
| 70 | + | return err | |
| 71 | + | } | |
| 72 | + | target.tab.tree = candidate | |
| 73 | + | target.tab.bumpLayoutGenerationLocked() | |
| 74 | + | target.tab.mu.Unlock() | |
| 75 | + | target.session.mu.Unlock() | |
| 76 | + | d.mu.Unlock() | |
| 77 | + | if !d.applyTabLayout(target.session, target.tab) { | |
| 78 | + | return layout.ErrNotFound | |
| 79 | + | } | |
| 80 | + | return nil | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func resizeUserError(err error) error { | |
| 84 | + | switch { | |
| 85 | + | case errors.Is(err, layout.ErrNotInSplit): | |
| 86 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane is not in a split", err) | |
| 87 | + | case errors.Is(err, layout.ErrTooSmall): | |
| 88 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane cannot be resized further", err) | |
| 89 | + | default: | |
| 90 | + | return err | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (d *Daemon) enterResizeMode(sess *session, ac *attachedClient) error { | |
| 95 | + | if sess == nil || ac == nil { | |
| 96 | + | return layout.ErrNotFound | |
| 97 | + | } | |
| 98 | + | target := resolveDaemonActionTarget(sess) | |
| 99 | + | if target.tab == nil || target.pane == nil { | |
| 100 | + | return layout.ErrNotFound | |
| 101 | + | } | |
| 102 | + | target.tab.mu.Lock() | |
| 103 | + | canResize := target.tab.tree != nil && target.tab.tree.CanResize() | |
| 104 | + | target.tab.mu.Unlock() | |
| 105 | + | if !canResize { | |
| 106 | + | return resizeUserError(layout.ErrNotInSplit) | |
| 107 | + | } | |
| 108 | + | d.exitCopyMode(ac) | |
| 109 | + | ac.initOverlays() | |
| 110 | + | ac.overlays.resizeMu.Lock() | |
| 111 | + | ac.overlays.resizeESC.stop() | |
| 112 | + | ac.overlays.resizeActive = true | |
| 113 | + | ac.overlays.resizePending = nil | |
| 114 | + | ac.overlays.resizeMu.Unlock() | |
| 115 | + | d.invalidateRender(sess, ac, true, "resize_mode.go") | |
| 116 | + | return nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) { | |
| 120 | + | if ac == nil || ac.overlays == nil { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | rt := ac.overlays | |
| 124 | + | var requests []daemonActionRequest | |
| 125 | + | exit := false | |
| 126 | + | rt.resizeMu.Lock() | |
| 127 | + | if !rt.resizeActive { | |
| 128 | + | rt.resizePending = nil | |
| 129 | + | rt.resizeESC.stop() | |
| 130 | + | rt.resizeMu.Unlock() | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | hadPending := len(rt.resizePending) != 0 | |
| 134 | + | if rt.resizeESC.timer != nil { | |
| 135 | + | rt.resizeESC.stop() | |
| 136 | + | } | |
| 137 | + | retainEscape := !hadPending && len(data) != 0 && data[len(data)-1] == keys.ESC | |
| 138 | + | if retainEscape { | |
| 139 | + | data = data[:len(data)-1] | |
| 140 | + | } | |
| 141 | + | appendResize := func(axis layout.Axis, delta int) { | |
| 142 | + | requests = append(requests, daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}) | |
| 143 | + | } | |
| 144 | + | routeOverlayBytes(data, &rt.resizePending, overlayEvents{ | |
| 145 | + | rune: func(r rune) { | |
| 146 | + | switch r { | |
| 147 | + | case 'h': | |
| 148 | + | appendResize(layout.Width, -resizeStepCols) | |
| 149 | + | case 'l': | |
| 150 | + | appendResize(layout.Width, resizeStepCols) | |
| 151 | + | case 'k': | |
| 152 | + | appendResize(layout.Height, -resizeStepRows) | |
| 153 | + | case 'j': | |
| 154 | + | appendResize(layout.Height, resizeStepRows) | |
| 155 | + | case '=': | |
| 156 | + | requests = append(requests, daemonActionRequest{kind: daemonActionEqualizePanes}) | |
| 157 | + | case 'q': | |
| 158 | + | exit = true | |
| 159 | + | } | |
| 160 | + | }, | |
| 161 | + | left: func() { appendResize(layout.Width, -resizeStepCols) }, | |
| 162 | + | right: func() { appendResize(layout.Width, resizeStepCols) }, | |
| 163 | + | up: func() { appendResize(layout.Height, -resizeStepRows) }, | |
| 164 | + | down: func() { appendResize(layout.Height, resizeStepRows) }, | |
| 165 | + | cancel: func() { exit = true }, | |
| 166 | + | enter: func() { exit = true }, | |
| 167 | + | }) | |
| 168 | + | if exit { | |
| 169 | + | rt.resizeActive = false | |
| 170 | + | rt.resizePending = nil | |
| 171 | + | rt.resizeESC.stop() | |
| 172 | + | } else if retainEscape { | |
| 173 | + | d.retainResizeESCLocked(ac) | |
| 174 | + | } | |
| 175 | + | rt.resizeMu.Unlock() | |
| 176 | + | ||
| 177 | + | sess := ac.currentSession() | |
| 178 | + | if sess == nil { | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | for _, request := range requests { | |
| 182 | + | request.target = resolveDaemonActionTarget(sess) | |
| 183 | + | if err := (daemonActions{d: d}).Run(request); err != nil { | |
| 184 | + | d.reportError(sess, resizeUserError(err)) | |
| 185 | + | continue | |
| 186 | + | } | |
| 187 | + | finishDaemonActionForClient(d, request, ac, "resize_mode.go") | |
| 188 | + | } | |
| 189 | + | if exit { | |
| 190 | + | d.invalidateRender(sess, ac, true, "resize_mode.go") | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (d *Daemon) retainResizeESCLocked(ac *attachedClient) { | |
| 195 | + | rt := ac.overlays | |
| 196 | + | rt.resizePending = append(rt.resizePending[:0], keys.ESC) | |
| 197 | + | rt.resizeESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |
| 198 | + | rt.resizeMu.Lock() | |
| 199 | + | if rt.resizeESC.timer != timer { | |
| 200 | + | rt.resizeMu.Unlock() | |
| 201 | + | return | |
| 202 | + | } | |
| 203 | + | rt.resizePending = nil | |
| 204 | + | rt.resizeESC.timer = nil | |
| 205 | + | rt.resizeESC.done = nil | |
| 206 | + | active := rt.resizeActive | |
| 207 | + | rt.resizeActive = false | |
| 208 | + | rt.resizeMu.Unlock() | |
| 209 | + | if active { | |
| 210 | + | if sess := ac.currentSession(); sess != nil { | |
| 211 | + | d.invalidateRender(sess, ac, true, "resize_mode.go") | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | }) | |
| 215 | + | } | |
| @@ -0,0 +1,215 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | resizeStepCols = 2 | |||
| 14 | + | resizeStepRows = 1 | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func (d *Daemon) resizePane(target daemonActionTarget, axis layout.Axis, delta int) error { | |||
| 18 | + | return d.mutateTargetLayout(target, true, func(candidate *layout.Tree, area domain.Rect) error { | |||
| 19 | + | originalFocus := candidate.Focus | |||
| 20 | + | candidate.Focus = target.pane.id | |||
| 21 | + | if err := candidate.ResizeFocus(axis, delta, area); err != nil { | |||
| 22 | + | return err | |||
| 23 | + | } | |||
| 24 | + | candidate.Focus = originalFocus | |||
| 25 | + | return nil | |||
| 26 | + | }) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (d *Daemon) equalizePanes(target daemonActionTarget) error { | |||
| 30 | + | return d.mutateTargetLayout(target, false, func(candidate *layout.Tree, area domain.Rect) error { | |||
| 31 | + | return candidate.Equalize(area) | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error { | |||
| 36 | + | if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) { | |||
| 37 | + | return layout.ErrNotFound | |||
| 38 | + | } | |||
| 39 | + | d.mu.Lock() | |||
| 40 | + | if d.sessions[target.session.id] != target.session { | |||
| 41 | + | d.mu.Unlock() | |||
| 42 | + | return layout.ErrNotFound | |||
| 43 | + | } | |||
| 44 | + | target.session.mu.Lock() | |||
| 45 | + | foundTab := false | |||
| 46 | + | for _, tb := range target.session.tabs { | |||
| 47 | + | if tb == target.tab { | |||
| 48 | + | foundTab = true | |||
| 49 | + | break | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | if !foundTab { | |||
| 53 | + | target.session.mu.Unlock() | |||
| 54 | + | d.mu.Unlock() | |||
| 55 | + | return layout.ErrNotFound | |||
| 56 | + | } | |||
| 57 | + | target.tab.mu.Lock() | |||
| 58 | + | if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) { | |||
| 59 | + | target.tab.mu.Unlock() | |||
| 60 | + | target.session.mu.Unlock() | |||
| 61 | + | d.mu.Unlock() | |||
| 62 | + | return layout.ErrNotFound | |||
| 63 | + | } | |||
| 64 | + | candidate := target.tab.tree.Clone() | |||
| 65 | + | area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows} | |||
| 66 | + | if err := mutate(candidate, area); err != nil { | |||
| 67 | + | target.tab.mu.Unlock() | |||
| 68 | + | target.session.mu.Unlock() | |||
| 69 | + | d.mu.Unlock() | |||
| 70 | + | return err | |||
| 71 | + | } | |||
| 72 | + | target.tab.tree = candidate | |||
| 73 | + | target.tab.bumpLayoutGenerationLocked() | |||
| 74 | + | target.tab.mu.Unlock() | |||
| 75 | + | target.session.mu.Unlock() | |||
| 76 | + | d.mu.Unlock() | |||
| 77 | + | if !d.applyTabLayout(target.session, target.tab) { | |||
| 78 | + | return layout.ErrNotFound | |||
| 79 | + | } | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func resizeUserError(err error) error { | |||
| 84 | + | switch { | |||
| 85 | + | case errors.Is(err, layout.ErrNotInSplit): | |||
| 86 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane is not in a split", err) | |||
| 87 | + | case errors.Is(err, layout.ErrTooSmall): | |||
| 88 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane cannot be resized further", err) | |||
| 89 | + | default: | |||
| 90 | + | return err | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (d *Daemon) enterResizeMode(sess *session, ac *attachedClient) error { | |||
| 95 | + | if sess == nil || ac == nil { | |||
| 96 | + | return layout.ErrNotFound | |||
| 97 | + | } | |||
| 98 | + | target := resolveDaemonActionTarget(sess) | |||
| 99 | + | if target.tab == nil || target.pane == nil { | |||
| 100 | + | return layout.ErrNotFound | |||
| 101 | + | } | |||
| 102 | + | target.tab.mu.Lock() | |||
| 103 | + | canResize := target.tab.tree != nil && target.tab.tree.CanResize() | |||
| 104 | + | target.tab.mu.Unlock() | |||
| 105 | + | if !canResize { | |||
| 106 | + | return resizeUserError(layout.ErrNotInSplit) | |||
| 107 | + | } | |||
| 108 | + | d.exitCopyMode(ac) | |||
| 109 | + | ac.initOverlays() | |||
| 110 | + | ac.overlays.resizeMu.Lock() | |||
| 111 | + | ac.overlays.resizeESC.stop() | |||
| 112 | + | ac.overlays.resizeActive = true | |||
| 113 | + | ac.overlays.resizePending = nil | |||
| 114 | + | ac.overlays.resizeMu.Unlock() | |||
| 115 | + | d.invalidateRender(sess, ac, true, "resize_mode.go") | |||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (d *Daemon) handleResizeInput(ac *attachedClient, data []byte) { | |||
| 120 | + | if ac == nil || ac.overlays == nil { | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | rt := ac.overlays | |||
| 124 | + | var requests []daemonActionRequest | |||
| 125 | + | exit := false | |||
| 126 | + | rt.resizeMu.Lock() | |||
| 127 | + | if !rt.resizeActive { | |||
| 128 | + | rt.resizePending = nil | |||
| 129 | + | rt.resizeESC.stop() | |||
| 130 | + | rt.resizeMu.Unlock() | |||
| 131 | + | return | |||
| 132 | + | } | |||
| 133 | + | hadPending := len(rt.resizePending) != 0 | |||
| 134 | + | if rt.resizeESC.timer != nil { | |||
| 135 | + | rt.resizeESC.stop() | |||
| 136 | + | } | |||
| 137 | + | retainEscape := !hadPending && len(data) != 0 && data[len(data)-1] == keys.ESC | |||
| 138 | + | if retainEscape { | |||
| 139 | + | data = data[:len(data)-1] | |||
| 140 | + | } | |||
| 141 | + | appendResize := func(axis layout.Axis, delta int) { | |||
| 142 | + | requests = append(requests, daemonActionRequest{kind: daemonActionResizePane, axis: axis, delta: delta}) | |||
| 143 | + | } | |||
| 144 | + | routeOverlayBytes(data, &rt.resizePending, overlayEvents{ | |||
| 145 | + | rune: func(r rune) { | |||
| 146 | + | switch r { | |||
| 147 | + | case 'h': | |||
| 148 | + | appendResize(layout.Width, -resizeStepCols) | |||
| 149 | + | case 'l': | |||
| 150 | + | appendResize(layout.Width, resizeStepCols) | |||
| 151 | + | case 'k': | |||
| 152 | + | appendResize(layout.Height, -resizeStepRows) | |||
| 153 | + | case 'j': | |||
| 154 | + | appendResize(layout.Height, resizeStepRows) | |||
| 155 | + | case '=': | |||
| 156 | + | requests = append(requests, daemonActionRequest{kind: daemonActionEqualizePanes}) | |||
| 157 | + | case 'q': | |||
| 158 | + | exit = true | |||
| 159 | + | } | |||
| 160 | + | }, | |||
| 161 | + | left: func() { appendResize(layout.Width, -resizeStepCols) }, | |||
| 162 | + | right: func() { appendResize(layout.Width, resizeStepCols) }, | |||
| 163 | + | up: func() { appendResize(layout.Height, -resizeStepRows) }, | |||
| 164 | + | down: func() { appendResize(layout.Height, resizeStepRows) }, | |||
| 165 | + | cancel: func() { exit = true }, | |||
| 166 | + | enter: func() { exit = true }, | |||
| 167 | + | }) | |||
| 168 | + | if exit { | |||
| 169 | + | rt.resizeActive = false | |||
| 170 | + | rt.resizePending = nil | |||
| 171 | + | rt.resizeESC.stop() | |||
| 172 | + | } else if retainEscape { | |||
| 173 | + | d.retainResizeESCLocked(ac) | |||
| 174 | + | } | |||
| 175 | + | rt.resizeMu.Unlock() | |||
| 176 | + | ||||
| 177 | + | sess := ac.currentSession() | |||
| 178 | + | if sess == nil { | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | for _, request := range requests { | |||
| 182 | + | request.target = resolveDaemonActionTarget(sess) | |||
| 183 | + | if err := (daemonActions{d: d}).Run(request); err != nil { | |||
| 184 | + | d.reportError(sess, resizeUserError(err)) | |||
| 185 | + | continue | |||
| 186 | + | } | |||
| 187 | + | finishDaemonActionForClient(d, request, ac, "resize_mode.go") | |||
| 188 | + | } | |||
| 189 | + | if exit { | |||
| 190 | + | d.invalidateRender(sess, ac, true, "resize_mode.go") | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (d *Daemon) retainResizeESCLocked(ac *attachedClient) { | |||
| 195 | + | rt := ac.overlays | |||
| 196 | + | rt.resizePending = append(rt.resizePending[:0], keys.ESC) | |||
| 197 | + | rt.resizeESC.retain(d.clock, keys.ESCDelay, func(timer ports.Timer) { | |||
| 198 | + | rt.resizeMu.Lock() | |||
| 199 | + | if rt.resizeESC.timer != timer { | |||
| 200 | + | rt.resizeMu.Unlock() | |||
| 201 | + | return | |||
| 202 | + | } | |||
| 203 | + | rt.resizePending = nil | |||
| 204 | + | rt.resizeESC.timer = nil | |||
| 205 | + | rt.resizeESC.done = nil | |||
| 206 | + | active := rt.resizeActive | |||
| 207 | + | rt.resizeActive = false | |||
| 208 | + | rt.resizeMu.Unlock() | |||
| 209 | + | if active { | |||
| 210 | + | if sess := ac.currentSession(); sess != nil { | |||
| 211 | + | d.invalidateRender(sess, ac, true, "resize_mode.go") | |||
| 212 | + | } | |||
| 213 | + | } | |||
| 214 | + | }) | |||
| 215 | + | } | |||
A internal/usecase/daemon/resize_mode_test.go +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | + | "github.com/bnema/vev/internal/usecase/mouse" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestPaletteAndControlShareResizeAction(t *testing.T) { | |
| 15 | + | tests := []struct { | |
| 16 | + | name string | |
| 17 | + | palette func(paletteExec) error | |
| 18 | + | control func(controlExec) error | |
| 19 | + | kind daemonActionKind | |
| 20 | + | axis layout.Axis | |
| 21 | + | delta int | |
| 22 | + | }{ | |
| 23 | + | {"grow width", func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }, daemonActionResizePane, layout.Width, 2}, | |
| 24 | + | {"shrink width", func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }, daemonActionResizePane, layout.Width, -2}, | |
| 25 | + | {"grow height", func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }, daemonActionResizePane, layout.Height, 1}, | |
| 26 | + | {"shrink height", func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }, daemonActionResizePane, layout.Height, -1}, | |
| 27 | + | {"equalize", func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }, daemonActionEqualizePanes, layout.Width, 0}, | |
| 28 | + | } | |
| 29 | + | for _, tt := range tests { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | p := &actionRunnerSpy{} | |
| 32 | + | c := &actionRunnerSpy{} | |
| 33 | + | require.NoError(t, tt.palette(paletteExec{actions: p})) | |
| 34 | + | require.NoError(t, tt.control(controlExec{actions: c})) | |
| 35 | + | require.Len(t, p.requests, 1) | |
| 36 | + | require.Equal(t, p.requests, c.requests) | |
| 37 | + | require.Equal(t, tt.kind, p.requests[0].kind) | |
| 38 | + | require.Equal(t, tt.axis, p.requests[0].axis) | |
| 39 | + | require.Equal(t, tt.delta, p.requests[0].delta) | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestResizeControlHeadlessAndErrors(t *testing.T) { | |
| 45 | + | factory := &controlPTYFactory{} | |
| 46 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 47 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 48 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 49 | + | ||
| 50 | + | tooEarly := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | |
| 51 | + | require.False(t, tooEarly.OK) | |
| 52 | + | require.Equal(t, ports.ErrNoSuchTarget, tooEarly.Code) | |
| 53 | + | require.Equal(t, "pane is not in a split", tooEarly.Text) | |
| 54 | + | ||
| 55 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 56 | + | tb := sess.activeTab() | |
| 57 | + | tb.mu.Lock() | |
| 58 | + | beforeFocus := tb.tree.Focus | |
| 59 | + | beforeGeneration := tb.layoutGeneration | |
| 60 | + | var target *pane | |
| 61 | + | for id, candidate := range tb.panes { | |
| 62 | + | if id != beforeFocus { | |
| 63 | + | target = candidate | |
| 64 | + | break | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | tb.mu.Unlock() | |
| 68 | + | require.NotNil(t, target) | |
| 69 | + | ||
| 70 | + | require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{ | |
| 71 | + | kind: daemonActionResizePane, | |
| 72 | + | target: daemonActionTarget{session: sess, tab: tb, pane: target}, | |
| 73 | + | axis: layout.Width, | |
| 74 | + | delta: resizeStepCols, | |
| 75 | + | })) | |
| 76 | + | tb.mu.Lock() | |
| 77 | + | require.Equal(t, beforeFocus, tb.tree.Focus, "exact pane targeting must not refocus the tab") | |
| 78 | + | require.Equal(t, beforeGeneration+1, tb.layoutGeneration) | |
| 79 | + | tb.mu.Unlock() | |
| 80 | + | ||
| 81 | + | equalized := sendCommand(t, d, ports.CommandRequest{Slug: "equalize-panes", TargetSession: "work"}) | |
| 82 | + | require.True(t, equalized.OK, equalized.Text) | |
| 83 | + | require.Empty(t, equalized.Output) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) { | |
| 87 | + | factory := &controlPTYFactory{} | |
| 88 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 89 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |
| 90 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 91 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |
| 92 | + | ||
| 93 | + | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 94 | + | ac.initOverlays() | |
| 95 | + | ac.setSession(sess) | |
| 96 | + | require.NoError(t, d.enterResizeMode(sess, ac)) | |
| 97 | + | require.True(t, ac.overlays.resizeModeActive()) | |
| 98 | + | d.handleResizeInput(ac, []byte("\x1b")) | |
| 99 | + | require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix") | |
| 100 | + | d.handleResizeInput(ac, []byte("[D")) | |
| 101 | + | require.True(t, ac.overlays.resizeModeActive()) | |
| 102 | + | ||
| 103 | + | snapshot := ac.overlays.SnapshotForRender() | |
| 104 | + | require.True(t, snapshot.resizeActive) | |
| 105 | + | snapshot.Unlock() | |
| 106 | + | require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer") | |
| 107 | + | ||
| 108 | + | tb := sess.activeTab() | |
| 109 | + | tb.mu.Lock() | |
| 110 | + | focused := tb.focusedPane() | |
| 111 | + | tb.mu.Unlock() | |
| 112 | + | focused.mu.Lock() | |
| 113 | + | cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true}) | |
| 114 | + | focused.mu.Unlock() | |
| 115 | + | require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live") | |
| 116 | + | ||
| 117 | + | ac.overlays.copyMu.Lock() | |
| 118 | + | ac.overlays.copyPointer.valid = true | |
| 119 | + | ac.overlays.copyMu.Unlock() | |
| 120 | + | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | |
| 121 | + | ac.overlays.copyMu.Lock() | |
| 122 | + | require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing") | |
| 123 | + | ac.overlays.copyMu.Unlock() | |
| 124 | + | ||
| 125 | + | ac.overlays.copyMu.Lock() | |
| 126 | + | ac.overlays.copyMode = &scopy.Mode{} | |
| 127 | + | ac.overlays.copyMu.Unlock() | |
| 128 | + | require.True(t, ac.overlays.HandleInput(d, []byte("q"))) | |
| 129 | + | require.False(t, ac.overlays.resizeModeActive()) | |
| 130 | + | require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) { | |
| 134 | + | var left, right int | |
| 135 | + | pending := []byte(nil) | |
| 136 | + | events := overlayEvents{left: func() { left++ }, right: func() { right++ }} | |
| 137 | + | routeOverlayBytes([]byte("\x1b["), &pending, events) | |
| 138 | + | require.Equal(t, []byte("\x1b["), pending) | |
| 139 | + | routeOverlayBytes([]byte("D\x1bOC"), &pending, events) | |
| 140 | + | require.Equal(t, 1, left) | |
| 141 | + | require.Equal(t, 1, right) | |
| 142 | + | require.Empty(t, pending) | |
| 143 | + | } | |
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 9 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | + | "github.com/bnema/vev/internal/usecase/mouse" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | func TestPaletteAndControlShareResizeAction(t *testing.T) { | |||
| 15 | + | tests := []struct { | |||
| 16 | + | name string | |||
| 17 | + | palette func(paletteExec) error | |||
| 18 | + | control func(controlExec) error | |||
| 19 | + | kind daemonActionKind | |||
| 20 | + | axis layout.Axis | |||
| 21 | + | delta int | |||
| 22 | + | }{ | |||
| 23 | + | {"grow width", func(e paletteExec) error { return e.GrowPaneWidth() }, func(e controlExec) error { return e.GrowPaneWidth() }, daemonActionResizePane, layout.Width, 2}, | |||
| 24 | + | {"shrink width", func(e paletteExec) error { return e.ShrinkPaneWidth() }, func(e controlExec) error { return e.ShrinkPaneWidth() }, daemonActionResizePane, layout.Width, -2}, | |||
| 25 | + | {"grow height", func(e paletteExec) error { return e.GrowPaneHeight() }, func(e controlExec) error { return e.GrowPaneHeight() }, daemonActionResizePane, layout.Height, 1}, | |||
| 26 | + | {"shrink height", func(e paletteExec) error { return e.ShrinkPaneHeight() }, func(e controlExec) error { return e.ShrinkPaneHeight() }, daemonActionResizePane, layout.Height, -1}, | |||
| 27 | + | {"equalize", func(e paletteExec) error { return e.EqualizePanes() }, func(e controlExec) error { return e.EqualizePanes() }, daemonActionEqualizePanes, layout.Width, 0}, | |||
| 28 | + | } | |||
| 29 | + | for _, tt := range tests { | |||
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | + | p := &actionRunnerSpy{} | |||
| 32 | + | c := &actionRunnerSpy{} | |||
| 33 | + | require.NoError(t, tt.palette(paletteExec{actions: p})) | |||
| 34 | + | require.NoError(t, tt.control(controlExec{actions: c})) | |||
| 35 | + | require.Len(t, p.requests, 1) | |||
| 36 | + | require.Equal(t, p.requests, c.requests) | |||
| 37 | + | require.Equal(t, tt.kind, p.requests[0].kind) | |||
| 38 | + | require.Equal(t, tt.axis, p.requests[0].axis) | |||
| 39 | + | require.Equal(t, tt.delta, p.requests[0].delta) | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestResizeControlHeadlessAndErrors(t *testing.T) { | |||
| 45 | + | factory := &controlPTYFactory{} | |||
| 46 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 47 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 48 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 49 | + | ||||
| 50 | + | tooEarly := sendCommand(t, d, ports.CommandRequest{Slug: "grow-pane-width", TargetSession: "work"}) | |||
| 51 | + | require.False(t, tooEarly.OK) | |||
| 52 | + | require.Equal(t, ports.ErrNoSuchTarget, tooEarly.Code) | |||
| 53 | + | require.Equal(t, "pane is not in a split", tooEarly.Text) | |||
| 54 | + | ||||
| 55 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |||
| 56 | + | tb := sess.activeTab() | |||
| 57 | + | tb.mu.Lock() | |||
| 58 | + | beforeFocus := tb.tree.Focus | |||
| 59 | + | beforeGeneration := tb.layoutGeneration | |||
| 60 | + | var target *pane | |||
| 61 | + | for id, candidate := range tb.panes { | |||
| 62 | + | if id != beforeFocus { | |||
| 63 | + | target = candidate | |||
| 64 | + | break | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | tb.mu.Unlock() | |||
| 68 | + | require.NotNil(t, target) | |||
| 69 | + | ||||
| 70 | + | require.NoError(t, (daemonActions{d: d}).Run(daemonActionRequest{ | |||
| 71 | + | kind: daemonActionResizePane, | |||
| 72 | + | target: daemonActionTarget{session: sess, tab: tb, pane: target}, | |||
| 73 | + | axis: layout.Width, | |||
| 74 | + | delta: resizeStepCols, | |||
| 75 | + | })) | |||
| 76 | + | tb.mu.Lock() | |||
| 77 | + | require.Equal(t, beforeFocus, tb.tree.Focus, "exact pane targeting must not refocus the tab") | |||
| 78 | + | require.Equal(t, beforeGeneration+1, tb.layoutGeneration) | |||
| 79 | + | tb.mu.Unlock() | |||
| 80 | + | ||||
| 81 | + | equalized := sendCommand(t, d, ports.CommandRequest{Slug: "equalize-panes", TargetSession: "work"}) | |||
| 82 | + | require.True(t, equalized.OK, equalized.Text) | |||
| 83 | + | require.Empty(t, equalized.Output) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestResizeModeStateRenderingAndMouseConsumption(t *testing.T) { | |||
| 87 | + | factory := &controlPTYFactory{} | |||
| 88 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 89 | + | t.Cleanup(func() { factory.close(); d.sessWg.Wait() }) | |||
| 90 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 91 | + | require.True(t, sendCommand(t, d, ports.CommandRequest{Slug: "split-right", TargetSession: "work"}).OK) | |||
| 92 | + | ||||
| 93 | + | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 94 | + | ac.initOverlays() | |||
| 95 | + | ac.setSession(sess) | |||
| 96 | + | require.NoError(t, d.enterResizeMode(sess, ac)) | |||
| 97 | + | require.True(t, ac.overlays.resizeModeActive()) | |||
| 98 | + | d.handleResizeInput(ac, []byte("\x1b")) | |||
| 99 | + | require.True(t, ac.overlays.resizeModeActive(), "a bare ESC read must wait for a possible arrow suffix") | |||
| 100 | + | d.handleResizeInput(ac, []byte("[D")) | |||
| 101 | + | require.True(t, ac.overlays.resizeModeActive()) | |||
| 102 | + | ||||
| 103 | + | snapshot := ac.overlays.SnapshotForRender() | |||
| 104 | + | require.True(t, snapshot.resizeActive) | |||
| 105 | + | snapshot.Unlock() | |||
| 106 | + | require.False(t, (capturedOverlayRenderState{resizeActive: true}).active(), "resize must not create a modal render layer") | |||
| 107 | + | ||||
| 108 | + | tb := sess.activeTab() | |||
| 109 | + | tb.mu.Lock() | |||
| 110 | + | focused := tb.focusedPane() | |||
| 111 | + | tb.mu.Unlock() | |||
| 112 | + | focused.mu.Lock() | |||
| 113 | + | cursor := captureCursorInputsLocked(focused, domain.Rect{Width: 40, Height: 10}, capturedOverlayRenderState{resizeActive: true}) | |||
| 114 | + | focused.mu.Unlock() | |||
| 115 | + | require.False(t, cursor.hiddenByOverlay, "resize must leave the pane cursor live") | |||
| 116 | + | ||||
| 117 | + | ac.overlays.copyMu.Lock() | |||
| 118 | + | ac.overlays.copyPointer.valid = true | |||
| 119 | + | ac.overlays.copyMu.Unlock() | |||
| 120 | + | d.handleMouse(ac, mouse.Event{Button: mouse.Left, Type: mouse.Release}) | |||
| 121 | + | ac.overlays.copyMu.Lock() | |||
| 122 | + | require.True(t, ac.overlays.copyPointer.valid, "resize mode must consume mouse input before copy/terminal routing") | |||
| 123 | + | ac.overlays.copyMu.Unlock() | |||
| 124 | + | ||||
| 125 | + | ac.overlays.copyMu.Lock() | |||
| 126 | + | ac.overlays.copyMode = &scopy.Mode{} | |||
| 127 | + | ac.overlays.copyMu.Unlock() | |||
| 128 | + | require.True(t, ac.overlays.HandleInput(d, []byte("q"))) | |||
| 129 | + | require.False(t, ac.overlays.resizeModeActive()) | |||
| 130 | + | require.True(t, ac.overlays.copyActive(), "resize input must take priority over copy mode") | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestRouteOverlayDecodesHorizontalArrows(t *testing.T) { | |||
| 134 | + | var left, right int | |||
| 135 | + | pending := []byte(nil) | |||
| 136 | + | events := overlayEvents{left: func() { left++ }, right: func() { right++ }} | |||
| 137 | + | routeOverlayBytes([]byte("\x1b["), &pending, events) | |||
| 138 | + | require.Equal(t, []byte("\x1b["), pending) | |||
| 139 | + | routeOverlayBytes([]byte("D\x1bOC"), &pending, events) | |||
| 140 | + | require.Equal(t, 1, left) | |||
| 141 | + | require.Equal(t, 1, right) | |||
| 142 | + | require.Empty(t, pending) | |||
| 143 | + | } | |||