Merge pull request #141 from bnema/feat/directional-pane-consume-expel
feat(panes): add directional consume and expel
26 files · +2090 -133
M README.md +1 -1
| @@ -50,7 +50,7 @@ No prefix key; everything is Alt. | |||
| 50 | 50 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | |
| 51 | 51 | | Alt+a | jump to a session needing attention | | |
| 52 | 52 | ||
| 53 | - | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | |
| 53 | + | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | |
| 54 | 54 | ||
| 55 | 55 | ## Remote attach | |
| 56 | 56 | ||
| @@ -50,7 +50,7 @@ No prefix key; everything is Alt. | |||||
| 50 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | 50 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | ||
| 51 | | Alt+a | jump to a session needing attention | | 51 | | Alt+a | jump to a session needing attention | | ||
| 52 | 52 | ||||
| 53 | - | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. | 53 | + | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52. |
| 54 | 54 | ||||
| 55 | ## Remote attach | 55 | ## Remote attach | ||
| 56 | 56 | ||||
M docs/configuration.md +13 -0
| @@ -34,6 +34,9 @@ focus-pane-left = alt+h | |||
| 34 | 34 | focus-pane-right = alt+l | |
| 35 | 35 | focus-pane-up = alt+k | |
| 36 | 36 | focus-pane-down = alt+j | |
| 37 | + | # Optional pane rearrangement actions are unbound by default. | |
| 38 | + | # consume-or-expel-pane-left = alt+H | |
| 39 | + | # consume-or-expel-pane-right = alt+L | |
| 37 | 40 | switch-tab-1 = alt+1 | |
| 38 | 41 | # ... through switch-tab-9 = alt+9 | |
| 39 | 42 | ||
| @@ -53,6 +56,8 @@ code.split-right = SPR | |||
| 53 | 56 | code.split-left = SPL | |
| 54 | 57 | code.split-up = SPU | |
| 55 | 58 | code.split-down = SPD | |
| 59 | + | code.consume-or-expel-pane-left = CEL | |
| 60 | + | code.consume-or-expel-pane-right = CER | |
| 56 | 61 | code.stack-pane = STP | |
| 57 | 62 | code.toggle-stack = TST | |
| 58 | 63 | code.close-pane = CLP | |
| @@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b | |||
| 96 | 101 | ||
| 97 | 102 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | |
| 98 | 103 | ||
| 104 | + | `alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev. | |
| 105 | + | ||
| 106 | + | ## Pane consume or expel | |
| 107 | + | ||
| 108 | + | The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`. | |
| 109 | + | ||
| 110 | + | A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported. | |
| 111 | + | ||
| 99 | 112 | ## Navigation overflow | |
| 100 | 113 | ||
| 101 | 114 | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | |
| @@ -34,6 +34,9 @@ focus-pane-left = alt+h | |||||
| 34 | focus-pane-right = alt+l | 34 | focus-pane-right = alt+l | ||
| 35 | focus-pane-up = alt+k | 35 | focus-pane-up = alt+k | ||
| 36 | focus-pane-down = alt+j | 36 | focus-pane-down = alt+j | ||
| 37 | + | # Optional pane rearrangement actions are unbound by default. | |||
| 38 | + | # consume-or-expel-pane-left = alt+H | |||
| 39 | + | # consume-or-expel-pane-right = alt+L | |||
| 37 | switch-tab-1 = alt+1 | 40 | switch-tab-1 = alt+1 | ||
| 38 | # ... through switch-tab-9 = alt+9 | 41 | # ... through switch-tab-9 = alt+9 | ||
| 39 | 42 | ||||
| @@ -53,6 +56,8 @@ code.split-right = SPR | |||||
| 53 | code.split-left = SPL | 56 | code.split-left = SPL | ||
| 54 | code.split-up = SPU | 57 | code.split-up = SPU | ||
| 55 | code.split-down = SPD | 58 | code.split-down = SPD | ||
| 59 | + | code.consume-or-expel-pane-left = CEL | |||
| 60 | + | code.consume-or-expel-pane-right = CER | |||
| 56 | code.stack-pane = STP | 61 | code.stack-pane = STP | ||
| 57 | code.toggle-stack = TST | 62 | code.toggle-stack = TST | ||
| 58 | code.close-pane = CLP | 63 | code.close-pane = CLP | ||
| @@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b | |||||
| 96 | 101 | ||||
| 97 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | 102 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | ||
| 98 | 103 | ||||
| 104 | + | `alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev. | |||
| 105 | + | ||||
| 106 | + | ## Pane consume or expel | |||
| 107 | + | ||||
| 108 | + | The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`. | |||
| 109 | + | ||||
| 110 | + | A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported. | |||
| 111 | + | ||||
| 99 | ## Navigation overflow | 112 | ## Navigation overflow | ||
| 100 | 113 | ||||
| 101 | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | 114 | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | ||
M internal/app/cmd_test.go +13 -3
| @@ -25,6 +25,8 @@ func TestParseCmdArgs(t *testing.T) { | |||
| 25 | 25 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | |
| 26 | 26 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | |
| 27 | 27 | {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false}, | |
| 28 | + | {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false}, | |
| 29 | + | {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false}, | |
| 28 | 30 | {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, | |
| 29 | 31 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | |
| 30 | 32 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | |
| @@ -93,7 +95,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||
| 93 | 95 | if called { | |
| 94 | 96 | t.Fatal("help dialed daemon") | |
| 95 | 97 | } | |
| 96 | - | 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"} { | |
| 98 | + | for _, want := range []string{"usage: vev cmd", "split-right", "consume-or-expel-pane-left", "consume-or-expel-pane-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} { | |
| 97 | 99 | if !strings.Contains(out.String(), want) { | |
| 98 | 100 | t.Fatalf("help %q missing %q", out.String(), want) | |
| 99 | 101 | } | |
| @@ -105,8 +107,16 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||
| 105 | 107 | } | |
| 106 | 108 | } | |
| 107 | 109 | ||
| 108 | - | func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) { | |
| 109 | - | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | |
| 110 | + | func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { | |
| 111 | + | for _, slug := range []string{ | |
| 112 | + | "grow-pane-width", | |
| 113 | + | "shrink-pane-width", | |
| 114 | + | "grow-pane-height", | |
| 115 | + | "shrink-pane-height", | |
| 116 | + | "equalize-panes", | |
| 117 | + | "consume-or-expel-pane-left", | |
| 118 | + | "consume-or-expel-pane-right", | |
| 119 | + | } { | |
| 110 | 120 | t.Run(slug, func(t *testing.T) { | |
| 111 | 121 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | |
| 112 | 122 | require.NoError(t, err) | |
| @@ -25,6 +25,8 @@ func TestParseCmdArgs(t *testing.T) { | |||||
| 25 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | 25 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | ||
| 26 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | 26 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | ||
| 27 | {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false}, | 27 | {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false}, | ||
| 28 | + | {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false}, | |||
| 29 | + | {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false}, | |||
| 28 | {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, | 30 | {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, | ||
| 29 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | 31 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | ||
| 30 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | 32 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | ||
| @@ -93,7 +95,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||||
| 93 | if called { | 95 | if called { | ||
| 94 | t.Fatal("help dialed daemon") | 96 | t.Fatal("help dialed daemon") | ||
| 95 | } | 97 | } | ||
| 96 | - | 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"} { | 98 | + | for _, want := range []string{"usage: vev cmd", "split-right", "consume-or-expel-pane-left", "consume-or-expel-pane-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} { |
| 97 | if !strings.Contains(out.String(), want) { | 99 | if !strings.Contains(out.String(), want) { | ||
| 98 | t.Fatalf("help %q missing %q", out.String(), want) | 100 | t.Fatalf("help %q missing %q", out.String(), want) | ||
| 99 | } | 101 | } | ||
| @@ -105,8 +107,16 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||||
| 105 | } | 107 | } | ||
| 106 | } | 108 | } | ||
| 107 | 109 | ||||
| 108 | - | func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) { | 110 | + | func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { |
| 109 | - | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | 111 | + | for _, slug := range []string{ |
| 112 | + | "grow-pane-width", | |||
| 113 | + | "shrink-pane-width", | |||
| 114 | + | "grow-pane-height", | |||
| 115 | + | "shrink-pane-height", | |||
| 116 | + | "equalize-panes", | |||
| 117 | + | "consume-or-expel-pane-left", | |||
| 118 | + | "consume-or-expel-pane-right", | |||
| 119 | + | } { | |||
| 110 | t.Run(slug, func(t *testing.T) { | 120 | t.Run(slug, func(t *testing.T) { | ||
| 111 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | 121 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | ||
| 112 | require.NoError(t, err) | 122 | require.NoError(t, err) | ||
M internal/usecase/command/command.go +4 -0
| @@ -44,6 +44,8 @@ type Context interface { | |||
| 44 | 44 | SplitLeft() error | |
| 45 | 45 | SplitUp() error | |
| 46 | 46 | SplitDown() error | |
| 47 | + | ConsumeOrExpelPaneLeft() error | |
| 48 | + | ConsumeOrExpelPaneRight() error | |
| 47 | 49 | StackPane() error | |
| 48 | 50 | ToggleStack() error | |
| 49 | 51 | ToggleFloatingPane() error | |
| @@ -117,6 +119,8 @@ type ControlContext interface { | |||
| 117 | 119 | SplitLeft() error | |
| 118 | 120 | SplitUp() error | |
| 119 | 121 | SplitDown() error | |
| 122 | + | ConsumeOrExpelPaneLeft() error | |
| 123 | + | ConsumeOrExpelPaneRight() error | |
| 120 | 124 | StackPane() error | |
| 121 | 125 | ToggleStack() error | |
| 122 | 126 | GrowPaneWidth() error | |
| @@ -44,6 +44,8 @@ type Context interface { | |||||
| 44 | SplitLeft() error | 44 | SplitLeft() error | ||
| 45 | SplitUp() error | 45 | SplitUp() error | ||
| 46 | SplitDown() error | 46 | SplitDown() error | ||
| 47 | + | ConsumeOrExpelPaneLeft() error | |||
| 48 | + | ConsumeOrExpelPaneRight() error | |||
| 47 | StackPane() error | 49 | StackPane() error | ||
| 48 | ToggleStack() error | 50 | ToggleStack() error | ||
| 49 | ToggleFloatingPane() error | 51 | ToggleFloatingPane() error | ||
| @@ -117,6 +119,8 @@ type ControlContext interface { | |||||
| 117 | SplitLeft() error | 119 | SplitLeft() error | ||
| 118 | SplitUp() error | 120 | SplitUp() error | ||
| 119 | SplitDown() error | 121 | SplitDown() error | ||
| 122 | + | ConsumeOrExpelPaneLeft() error | |||
| 123 | + | ConsumeOrExpelPaneRight() error | |||
| 120 | StackPane() error | 124 | StackPane() error | ||
| 121 | ToggleStack() error | 125 | ToggleStack() error | ||
| 122 | GrowPaneWidth() error | 126 | GrowPaneWidth() error | ||
M internal/usecase/command/context_mock_test.go +88 -0
| @@ -167,6 +167,94 @@ func (_c *MockContext_CloseTab_Call) RunAndReturn(run func() error) *MockContext | |||
| 167 | 167 | return _c | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | + | // ConsumeOrExpelPaneLeft provides a mock function for the type MockContext | |
| 171 | + | func (_mock *MockContext) ConsumeOrExpelPaneLeft() error { | |
| 172 | + | ret := _mock.Called() | |
| 173 | + | ||
| 174 | + | if len(ret) == 0 { | |
| 175 | + | panic("no return value specified for ConsumeOrExpelPaneLeft") | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | var r0 error | |
| 179 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 180 | + | r0 = returnFunc() | |
| 181 | + | } else { | |
| 182 | + | r0 = ret.Error(0) | |
| 183 | + | } | |
| 184 | + | return r0 | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // MockContext_ConsumeOrExpelPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneLeft' | |
| 188 | + | type MockContext_ConsumeOrExpelPaneLeft_Call struct { | |
| 189 | + | *mock.Call | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // ConsumeOrExpelPaneLeft is a helper method to define mock.On call | |
| 193 | + | func (_e *MockContext_Expecter) ConsumeOrExpelPaneLeft() *MockContext_ConsumeOrExpelPaneLeft_Call { | |
| 194 | + | return &MockContext_ConsumeOrExpelPaneLeft_Call{Call: _e.mock.On("ConsumeOrExpelPaneLeft")} | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneLeft_Call { | |
| 198 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 199 | + | run() | |
| 200 | + | }) | |
| 201 | + | return _c | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Return(err error) *MockContext_ConsumeOrExpelPaneLeft_Call { | |
| 205 | + | _c.Call.Return(err) | |
| 206 | + | return _c | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneLeft_Call { | |
| 210 | + | _c.Call.Return(run) | |
| 211 | + | return _c | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // ConsumeOrExpelPaneRight provides a mock function for the type MockContext | |
| 215 | + | func (_mock *MockContext) ConsumeOrExpelPaneRight() error { | |
| 216 | + | ret := _mock.Called() | |
| 217 | + | ||
| 218 | + | if len(ret) == 0 { | |
| 219 | + | panic("no return value specified for ConsumeOrExpelPaneRight") | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | var r0 error | |
| 223 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 224 | + | r0 = returnFunc() | |
| 225 | + | } else { | |
| 226 | + | r0 = ret.Error(0) | |
| 227 | + | } | |
| 228 | + | return r0 | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // MockContext_ConsumeOrExpelPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneRight' | |
| 232 | + | type MockContext_ConsumeOrExpelPaneRight_Call struct { | |
| 233 | + | *mock.Call | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // ConsumeOrExpelPaneRight is a helper method to define mock.On call | |
| 237 | + | func (_e *MockContext_Expecter) ConsumeOrExpelPaneRight() *MockContext_ConsumeOrExpelPaneRight_Call { | |
| 238 | + | return &MockContext_ConsumeOrExpelPaneRight_Call{Call: _e.mock.On("ConsumeOrExpelPaneRight")} | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneRight_Call { | |
| 242 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 243 | + | run() | |
| 244 | + | }) | |
| 245 | + | return _c | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Return(err error) *MockContext_ConsumeOrExpelPaneRight_Call { | |
| 249 | + | _c.Call.Return(err) | |
| 250 | + | return _c | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneRight_Call { | |
| 254 | + | _c.Call.Return(run) | |
| 255 | + | return _c | |
| 256 | + | } | |
| 257 | + | ||
| 170 | 258 | // CreateSession provides a mock function for the type MockContext | |
| 171 | 259 | func (_mock *MockContext) CreateSession() error { | |
| 172 | 260 | ret := _mock.Called() | |
| @@ -167,6 +167,94 @@ func (_c *MockContext_CloseTab_Call) RunAndReturn(run func() error) *MockContext | |||||
| 167 | return _c | 167 | return _c | ||
| 168 | } | 168 | } | ||
| 169 | 169 | ||||
| 170 | + | // ConsumeOrExpelPaneLeft provides a mock function for the type MockContext | |||
| 171 | + | func (_mock *MockContext) ConsumeOrExpelPaneLeft() error { | |||
| 172 | + | ret := _mock.Called() | |||
| 173 | + | ||||
| 174 | + | if len(ret) == 0 { | |||
| 175 | + | panic("no return value specified for ConsumeOrExpelPaneLeft") | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | var r0 error | |||
| 179 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 180 | + | r0 = returnFunc() | |||
| 181 | + | } else { | |||
| 182 | + | r0 = ret.Error(0) | |||
| 183 | + | } | |||
| 184 | + | return r0 | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // MockContext_ConsumeOrExpelPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneLeft' | |||
| 188 | + | type MockContext_ConsumeOrExpelPaneLeft_Call struct { | |||
| 189 | + | *mock.Call | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | // ConsumeOrExpelPaneLeft is a helper method to define mock.On call | |||
| 193 | + | func (_e *MockContext_Expecter) ConsumeOrExpelPaneLeft() *MockContext_ConsumeOrExpelPaneLeft_Call { | |||
| 194 | + | return &MockContext_ConsumeOrExpelPaneLeft_Call{Call: _e.mock.On("ConsumeOrExpelPaneLeft")} | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneLeft_Call { | |||
| 198 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 199 | + | run() | |||
| 200 | + | }) | |||
| 201 | + | return _c | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Return(err error) *MockContext_ConsumeOrExpelPaneLeft_Call { | |||
| 205 | + | _c.Call.Return(err) | |||
| 206 | + | return _c | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneLeft_Call { | |||
| 210 | + | _c.Call.Return(run) | |||
| 211 | + | return _c | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // ConsumeOrExpelPaneRight provides a mock function for the type MockContext | |||
| 215 | + | func (_mock *MockContext) ConsumeOrExpelPaneRight() error { | |||
| 216 | + | ret := _mock.Called() | |||
| 217 | + | ||||
| 218 | + | if len(ret) == 0 { | |||
| 219 | + | panic("no return value specified for ConsumeOrExpelPaneRight") | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | var r0 error | |||
| 223 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |||
| 224 | + | r0 = returnFunc() | |||
| 225 | + | } else { | |||
| 226 | + | r0 = ret.Error(0) | |||
| 227 | + | } | |||
| 228 | + | return r0 | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // MockContext_ConsumeOrExpelPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneRight' | |||
| 232 | + | type MockContext_ConsumeOrExpelPaneRight_Call struct { | |||
| 233 | + | *mock.Call | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | // ConsumeOrExpelPaneRight is a helper method to define mock.On call | |||
| 237 | + | func (_e *MockContext_Expecter) ConsumeOrExpelPaneRight() *MockContext_ConsumeOrExpelPaneRight_Call { | |||
| 238 | + | return &MockContext_ConsumeOrExpelPaneRight_Call{Call: _e.mock.On("ConsumeOrExpelPaneRight")} | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneRight_Call { | |||
| 242 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 243 | + | run() | |||
| 244 | + | }) | |||
| 245 | + | return _c | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Return(err error) *MockContext_ConsumeOrExpelPaneRight_Call { | |||
| 249 | + | _c.Call.Return(err) | |||
| 250 | + | return _c | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneRight_Call { | |||
| 254 | + | _c.Call.Return(run) | |||
| 255 | + | return _c | |||
| 256 | + | } | |||
| 257 | + | ||||
| 170 | // CreateSession provides a mock function for the type MockContext | 258 | // CreateSession provides a mock function for the type MockContext | ||
| 171 | func (_mock *MockContext) CreateSession() error { | 259 | func (_mock *MockContext) CreateSession() error { | ||
| 172 | ret := _mock.Called() | 260 | ret := _mock.Called() | ||
M internal/usecase/command/registry.go +2 -0
| @@ -12,6 +12,8 @@ func Registry() []Command { | |||
| 12 | 12 | paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }), | |
| 13 | 13 | paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }), | |
| 14 | 14 | paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }), | |
| 15 | + | paletteControl("consume-or-expel-pane-left", "CEL", "Consume or expel pane left", "Move the focused pane into or out of the column on the left", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneLeft() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneLeft() }), | |
| 16 | + | paletteControl("consume-or-expel-pane-right", "CER", "Consume or expel pane right", "Move the focused pane into or out of the column on the right", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneRight() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneRight() }), | |
| 15 | 17 | paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }), | |
| 16 | 18 | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | |
| 17 | 19 | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | |
| @@ -12,6 +12,8 @@ func Registry() []Command { | |||||
| 12 | paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }), | 12 | paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }), | ||
| 13 | paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }), | 13 | paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }), | ||
| 14 | paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }), | 14 | paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }), | ||
| 15 | + | paletteControl("consume-or-expel-pane-left", "CEL", "Consume or expel pane left", "Move the focused pane into or out of the column on the left", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneLeft() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneLeft() }), | |||
| 16 | + | paletteControl("consume-or-expel-pane-right", "CER", "Consume or expel pane right", "Move the focused pane into or out of the column on the right", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneRight() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneRight() }), | |||
| 15 | paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }), | 17 | paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }), | ||
| 16 | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | 18 | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | ||
| 17 | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | 19 | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | ||
M internal/usecase/command/registry_test.go +11 -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", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | |
| 12 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "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)) | |
| @@ -45,6 +45,8 @@ func TestBySlugExactMatch(t *testing.T) { | |||
| 45 | 45 | ok bool | |
| 46 | 46 | }{ | |
| 47 | 47 | {slug: "split-right", ok: true}, | |
| 48 | + | {slug: "consume-or-expel-pane-left", ok: true}, | |
| 49 | + | {slug: "consume-or-expel-pane-right", ok: true}, | |
| 48 | 50 | {slug: "SPLIT-RIGHT", ok: false}, | |
| 49 | 51 | {slug: "toast", ok: true}, | |
| 50 | 52 | {slug: "list-panes", ok: true}, | |
| @@ -62,7 +64,8 @@ func TestBySlugExactMatch(t *testing.T) { | |||
| 62 | 64 | func TestRegistryControlMetadata(t *testing.T) { | |
| 63 | 65 | scriptable := map[string]TargetKind{ | |
| 64 | 66 | "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane, | |
| 65 | - | "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane, | |
| 67 | + | "split-down": TargetPane, "consume-or-expel-pane-left": TargetPane, | |
| 68 | + | "consume-or-expel-pane-right": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane, | |
| 66 | 69 | "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane, | |
| 67 | 70 | "focus-pane-up": TargetPane, "focus-pane-down": TargetPane, | |
| 68 | 71 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | |
| @@ -146,6 +149,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||
| 146 | 149 | }{ | |
| 147 | 150 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | |
| 148 | 151 | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | |
| 152 | + | {name: "consume or expel left delegates", slug: "consume-or-expel-pane-left", wantCall: "consume-or-expel-pane-left"}, | |
| 153 | + | {name: "consume or expel right delegates", slug: "consume-or-expel-pane-right", wantCall: "consume-or-expel-pane-right"}, | |
| 149 | 154 | {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"}, | |
| 150 | 155 | {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"}, | |
| 151 | 156 | {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"}, | |
| @@ -192,6 +197,8 @@ func (s *controlSpy) SplitRight() error { return s.record("split | |||
| 192 | 197 | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | |
| 193 | 198 | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | |
| 194 | 199 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | |
| 200 | + | func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") } | |
| 201 | + | func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") } | |
| 195 | 202 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | |
| 196 | 203 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | |
| 197 | 204 | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | |
| @@ -285,6 +292,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 285 | 292 | {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }}, | |
| 286 | 293 | {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }}, | |
| 287 | 294 | {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }}, | |
| 295 | + | {code: "CEL", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneLeft().Return(nil).Once() }}, | |
| 296 | + | {code: "CER", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneRight().Return(nil).Once() }}, | |
| 288 | 297 | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | |
| 289 | 298 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | |
| 290 | 299 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().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", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"} | 12 | + | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "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)) | ||
| @@ -45,6 +45,8 @@ func TestBySlugExactMatch(t *testing.T) { | |||||
| 45 | ok bool | 45 | ok bool | ||
| 46 | }{ | 46 | }{ | ||
| 47 | {slug: "split-right", ok: true}, | 47 | {slug: "split-right", ok: true}, | ||
| 48 | + | {slug: "consume-or-expel-pane-left", ok: true}, | |||
| 49 | + | {slug: "consume-or-expel-pane-right", ok: true}, | |||
| 48 | {slug: "SPLIT-RIGHT", ok: false}, | 50 | {slug: "SPLIT-RIGHT", ok: false}, | ||
| 49 | {slug: "toast", ok: true}, | 51 | {slug: "toast", ok: true}, | ||
| 50 | {slug: "list-panes", ok: true}, | 52 | {slug: "list-panes", ok: true}, | ||
| @@ -62,7 +64,8 @@ func TestBySlugExactMatch(t *testing.T) { | |||||
| 62 | func TestRegistryControlMetadata(t *testing.T) { | 64 | func TestRegistryControlMetadata(t *testing.T) { | ||
| 63 | scriptable := map[string]TargetKind{ | 65 | scriptable := map[string]TargetKind{ | ||
| 64 | "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane, | 66 | "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane, | ||
| 65 | - | "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane, | 67 | + | "split-down": TargetPane, "consume-or-expel-pane-left": TargetPane, |
| 68 | + | "consume-or-expel-pane-right": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane, | |||
| 66 | "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane, | 69 | "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane, | ||
| 67 | "focus-pane-up": TargetPane, "focus-pane-down": TargetPane, | 70 | "focus-pane-up": TargetPane, "focus-pane-down": TargetPane, | ||
| 68 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | 71 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | ||
| @@ -146,6 +149,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||||
| 146 | }{ | 149 | }{ | ||
| 147 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | 150 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | ||
| 148 | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | 151 | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | ||
| 152 | + | {name: "consume or expel left delegates", slug: "consume-or-expel-pane-left", wantCall: "consume-or-expel-pane-left"}, | |||
| 153 | + | {name: "consume or expel right delegates", slug: "consume-or-expel-pane-right", wantCall: "consume-or-expel-pane-right"}, | |||
| 149 | {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"}, | 154 | {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"}, | ||
| 150 | {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"}, | 155 | {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"}, | ||
| 151 | {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"}, | 156 | {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"}, | ||
| @@ -192,6 +197,8 @@ func (s *controlSpy) SplitRight() error { return s.record("split | |||||
| 192 | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | 197 | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | ||
| 193 | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | 198 | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | ||
| 194 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | 199 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | ||
| 200 | + | func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") } | |||
| 201 | + | func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") } | |||
| 195 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | 202 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | ||
| 196 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | 203 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | ||
| 197 | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | 204 | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | ||
| @@ -285,6 +292,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 285 | {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }}, | 292 | {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }}, | ||
| 286 | {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }}, | 293 | {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }}, | ||
| 287 | {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }}, | 294 | {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }}, | ||
| 295 | + | {code: "CEL", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneLeft().Return(nil).Once() }}, | |||
| 296 | + | {code: "CER", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneRight().Return(nil).Once() }}, | |||
| 288 | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | 297 | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | ||
| 289 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | 298 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | ||
| 290 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | 299 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | ||
M internal/usecase/daemon/control.go +20 -2
| @@ -206,6 +206,7 @@ const ( | |||
| 206 | 206 | daemonActionRenameTab | |
| 207 | 207 | daemonActionResizePane | |
| 208 | 208 | daemonActionEqualizePanes | |
| 209 | + | daemonActionConsumeOrExpelPane | |
| 209 | 210 | ) | |
| 210 | 211 | ||
| 211 | 212 | type daemonActionTarget struct { | |
| @@ -289,6 +290,8 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 289 | 290 | return a.d.resizePane(target, request.axis, request.delta) | |
| 290 | 291 | case daemonActionEqualizePanes: | |
| 291 | 292 | return a.d.equalizePanes(target) | |
| 293 | + | case daemonActionConsumeOrExpelPane: | |
| 294 | + | return a.d.consumeOrExpelPane(target, request.direction) | |
| 292 | 295 | default: | |
| 293 | 296 | return errors.New("daemon: unknown action") | |
| 294 | 297 | } | |
| @@ -345,13 +348,19 @@ func (e controlExec) runAction(request daemonActionRequest) error { | |||
| 345 | 348 | runner = daemonActions{d: e.d} | |
| 346 | 349 | } | |
| 347 | 350 | err := runner.Run(request) | |
| 348 | - | if err == nil && e.actions == nil { | |
| 351 | + | if errors.Is(err, errDaemonActionNoChange) { | |
| 352 | + | return nil | |
| 353 | + | } | |
| 354 | + | if err != nil { | |
| 355 | + | return err | |
| 356 | + | } | |
| 357 | + | if e.actions == nil { | |
| 349 | 358 | e.sess.mu.Lock() | |
| 350 | 359 | ac := e.sess.client | |
| 351 | 360 | e.sess.mu.Unlock() | |
| 352 | 361 | finishDaemonActionForClient(e.d, request, ac, "control.go") | |
| 353 | 362 | } | |
| 354 | - | return err | |
| 363 | + | return nil | |
| 355 | 364 | } | |
| 356 | 365 | ||
| 357 | 366 | func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) { | |
| @@ -400,6 +409,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) } | |||
| 400 | 409 | func (e controlExec) SplitLeft() error { return e.split(layout.Left) } | |
| 401 | 410 | func (e controlExec) SplitUp() error { return e.split(layout.Up) } | |
| 402 | 411 | func (e controlExec) SplitDown() error { return e.split(layout.Down) } | |
| 412 | + | func (e controlExec) consumeOrExpelPane(direction layout.Direction) error { | |
| 413 | + | return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction}) | |
| 414 | + | } | |
| 415 | + | func (e controlExec) ConsumeOrExpelPaneLeft() error { | |
| 416 | + | return e.consumeOrExpelPane(layout.Left) | |
| 417 | + | } | |
| 418 | + | func (e controlExec) ConsumeOrExpelPaneRight() error { | |
| 419 | + | return e.consumeOrExpelPane(layout.Right) | |
| 420 | + | } | |
| 403 | 421 | func (e controlExec) StackPane() error { | |
| 404 | 422 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | |
| 405 | 423 | } | |
| @@ -206,6 +206,7 @@ const ( | |||||
| 206 | daemonActionRenameTab | 206 | daemonActionRenameTab | ||
| 207 | daemonActionResizePane | 207 | daemonActionResizePane | ||
| 208 | daemonActionEqualizePanes | 208 | daemonActionEqualizePanes | ||
| 209 | + | daemonActionConsumeOrExpelPane | |||
| 209 | ) | 210 | ) | ||
| 210 | 211 | ||||
| 211 | type daemonActionTarget struct { | 212 | type daemonActionTarget struct { | ||
| @@ -289,6 +290,8 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||||
| 289 | return a.d.resizePane(target, request.axis, request.delta) | 290 | return a.d.resizePane(target, request.axis, request.delta) | ||
| 290 | case daemonActionEqualizePanes: | 291 | case daemonActionEqualizePanes: | ||
| 291 | return a.d.equalizePanes(target) | 292 | return a.d.equalizePanes(target) | ||
| 293 | + | case daemonActionConsumeOrExpelPane: | |||
| 294 | + | return a.d.consumeOrExpelPane(target, request.direction) | |||
| 292 | default: | 295 | default: | ||
| 293 | return errors.New("daemon: unknown action") | 296 | return errors.New("daemon: unknown action") | ||
| 294 | } | 297 | } | ||
| @@ -345,13 +348,19 @@ func (e controlExec) runAction(request daemonActionRequest) error { | |||||
| 345 | runner = daemonActions{d: e.d} | 348 | runner = daemonActions{d: e.d} | ||
| 346 | } | 349 | } | ||
| 347 | err := runner.Run(request) | 350 | err := runner.Run(request) | ||
| 348 | - | if err == nil && e.actions == nil { | 351 | + | if errors.Is(err, errDaemonActionNoChange) { |
| 352 | + | return nil | |||
| 353 | + | } | |||
| 354 | + | if err != nil { | |||
| 355 | + | return err | |||
| 356 | + | } | |||
| 357 | + | if e.actions == nil { | |||
| 349 | e.sess.mu.Lock() | 358 | e.sess.mu.Lock() | ||
| 350 | ac := e.sess.client | 359 | ac := e.sess.client | ||
| 351 | e.sess.mu.Unlock() | 360 | e.sess.mu.Unlock() | ||
| 352 | finishDaemonActionForClient(e.d, request, ac, "control.go") | 361 | finishDaemonActionForClient(e.d, request, ac, "control.go") | ||
| 353 | } | 362 | } | ||
| 354 | - | return err | 363 | + | return nil |
| 355 | } | 364 | } | ||
| 356 | 365 | ||||
| 357 | func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) { | 366 | func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) { | ||
| @@ -400,6 +409,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) } | |||||
| 400 | func (e controlExec) SplitLeft() error { return e.split(layout.Left) } | 409 | func (e controlExec) SplitLeft() error { return e.split(layout.Left) } | ||
| 401 | func (e controlExec) SplitUp() error { return e.split(layout.Up) } | 410 | func (e controlExec) SplitUp() error { return e.split(layout.Up) } | ||
| 402 | func (e controlExec) SplitDown() error { return e.split(layout.Down) } | 411 | func (e controlExec) SplitDown() error { return e.split(layout.Down) } | ||
| 412 | + | func (e controlExec) consumeOrExpelPane(direction layout.Direction) error { | |||
| 413 | + | return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction}) | |||
| 414 | + | } | |||
| 415 | + | func (e controlExec) ConsumeOrExpelPaneLeft() error { | |||
| 416 | + | return e.consumeOrExpelPane(layout.Left) | |||
| 417 | + | } | |||
| 418 | + | func (e controlExec) ConsumeOrExpelPaneRight() error { | |||
| 419 | + | return e.consumeOrExpelPane(layout.Right) | |||
| 420 | + | } | |||
| 403 | func (e controlExec) StackPane() error { | 421 | func (e controlExec) StackPane() error { | ||
| 404 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | 422 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | ||
| 405 | } | 423 | } | ||
M internal/usecase/daemon/control_test.go +92 -21
| @@ -16,28 +16,99 @@ import ( | |||
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) { | |
| 19 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 20 | - | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 21 | - | tb := sess.activeTab() | |
| 22 | - | tb.mu.Lock() | |
| 23 | - | pane := tb.focusedPane() | |
| 24 | - | tb.mu.Unlock() | |
| 25 | - | spy := &actionRunnerSpy{} | |
| 26 | - | ||
| 27 | - | require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight()) | |
| 28 | - | require.NoError(t, (controlExec{ | |
| 29 | - | d: d, sess: sess, tab: tb, actions: spy, | |
| 30 | - | target: daemonActionTarget{session: sess, tab: tb, pane: pane}, | |
| 31 | - | }).SplitRight()) | |
| 32 | - | ||
| 33 | - | require.Len(t, spy.requests, 2) | |
| 34 | - | for _, request := range spy.requests { | |
| 35 | - | require.Equal(t, daemonActionSplitPane, request.kind) | |
| 36 | - | require.Same(t, sess, request.target.session) | |
| 37 | - | require.Same(t, tb, request.target.tab) | |
| 38 | - | require.Same(t, pane, request.target.pane) | |
| 39 | - | require.Equal(t, layout.Right, request.direction) | |
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | palette func(paletteExec) error | |
| 22 | + | control func(controlExec) error | |
| 23 | + | kind daemonActionKind | |
| 24 | + | direction layout.Direction | |
| 25 | + | }{ | |
| 26 | + | {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right}, | |
| 27 | + | {name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left}, | |
| 28 | + | {name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right}, | |
| 40 | 29 | } | |
| 30 | + | ||
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |
| 33 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 34 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 35 | + | tb := sess.activeTab() | |
| 36 | + | tb.mu.Lock() | |
| 37 | + | pane := tb.focusedPane() | |
| 38 | + | tb.mu.Unlock() | |
| 39 | + | spy := &actionRunnerSpy{} | |
| 40 | + | target := daemonActionTarget{session: sess, tab: tb, pane: pane} | |
| 41 | + | ||
| 42 | + | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy})) | |
| 43 | + | require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target})) | |
| 44 | + | ||
| 45 | + | require.Len(t, spy.requests, 2) | |
| 46 | + | for _, request := range spy.requests { | |
| 47 | + | require.Equal(t, tt.kind, request.kind) | |
| 48 | + | require.Same(t, sess, request.target.session) | |
| 49 | + | require.Same(t, tb, request.target.tab) | |
| 50 | + | require.Same(t, pane, request.target.pane) | |
| 51 | + | require.Equal(t, tt.direction, request.direction) | |
| 52 | + | } | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) { | |
| 58 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 59 | + | h.tab.mu.Lock() | |
| 60 | + | h.tab.tree.Focus = "pane-1" | |
| 61 | + | beforeGeneration := h.tab.layoutGeneration | |
| 62 | + | tabStableID := h.tab.stableID | |
| 63 | + | targetStableID := h.panes["pane-3"].stableID | |
| 64 | + | h.tab.mu.Unlock() | |
| 65 | + | ||
| 66 | + | result := sendCommand(t, h.daemon, ports.CommandRequest{ | |
| 67 | + | Slug: "consume-or-expel-pane-left", | |
| 68 | + | Self: true, | |
| 69 | + | TargetSession: "work", | |
| 70 | + | TargetTab: tabStableID, | |
| 71 | + | TargetPane: targetStableID, | |
| 72 | + | }) | |
| 73 | + | ||
| 74 | + | require.True(t, result.OK, result.Text) | |
| 75 | + | require.Empty(t, result.Output) | |
| 76 | + | h.session.mu.Lock() | |
| 77 | + | require.Zero(t, h.session.active, "--self must not change the active tab") | |
| 78 | + | h.session.mu.Unlock() | |
| 79 | + | h.tab.mu.Lock() | |
| 80 | + | require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused") | |
| 81 | + | require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration) | |
| 82 | + | require.Len(t, h.tab.tree.Root.Children, 2) | |
| 83 | + | h.tab.mu.Unlock() | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |
| 87 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 88 | + | ac := &attachedClient{} | |
| 89 | + | ac.setSession(h.session) | |
| 90 | + | h.session.mu.Lock() | |
| 91 | + | h.session.client = ac | |
| 92 | + | h.session.mu.Unlock() | |
| 93 | + | invalidations := make(chan renderInvalidation, 1) | |
| 94 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| 95 | + | rc.attach(ac) | |
| 96 | + | h.session.installRenderCoordinator(rc) | |
| 97 | + | before := h.snapshot() | |
| 98 | + | ||
| 99 | + | result := sendCommand(t, h.daemon, ports.CommandRequest{ | |
| 100 | + | Slug: "consume-or-expel-pane-left", | |
| 101 | + | Self: true, | |
| 102 | + | TargetSession: "work", | |
| 103 | + | TargetTab: h.tab.stableID, | |
| 104 | + | TargetPane: h.panes["pane-1"].stableID, | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | require.True(t, result.OK, result.Text) | |
| 108 | + | require.Empty(t, result.Output) | |
| 109 | + | require.Equal(t, before, h.snapshot()) | |
| 110 | + | require.Empty(t, h.daemon.notices.history()) | |
| 111 | + | requireNoInvalidation(t, invalidations) | |
| 41 | 112 | } | |
| 42 | 113 | ||
| 43 | 114 | type actionRunnerSpy struct { | |
| @@ -16,28 +16,99 @@ import ( | |||||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) { | 18 | func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) { | ||
| 19 | - | d := newTestDaemon(t, nil, stubClock{}) | 19 | + | tests := []struct { |
| 20 | - | sess := addControlSession(d, "work", "t_work", "p_work") | 20 | + | name string |
| 21 | - | tb := sess.activeTab() | 21 | + | palette func(paletteExec) error |
| 22 | - | tb.mu.Lock() | 22 | + | control func(controlExec) error |
| 23 | - | pane := tb.focusedPane() | 23 | + | kind daemonActionKind |
| 24 | - | tb.mu.Unlock() | 24 | + | direction layout.Direction |
| 25 | - | spy := &actionRunnerSpy{} | 25 | + | }{ |
| 26 | - | 26 | + | {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right}, | |
| 27 | - | require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight()) | 27 | + | {name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left}, |
| 28 | - | require.NoError(t, (controlExec{ | 28 | + | {name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right}, |
| 29 | - | d: d, sess: sess, tab: tb, actions: spy, | |||
| 30 | - | target: daemonActionTarget{session: sess, tab: tb, pane: pane}, | |||
| 31 | - | }).SplitRight()) | |||
| 32 | - | ||||
| 33 | - | require.Len(t, spy.requests, 2) | |||
| 34 | - | for _, request := range spy.requests { | |||
| 35 | - | require.Equal(t, daemonActionSplitPane, request.kind) | |||
| 36 | - | require.Same(t, sess, request.target.session) | |||
| 37 | - | require.Same(t, tb, request.target.tab) | |||
| 38 | - | require.Same(t, pane, request.target.pane) | |||
| 39 | - | require.Equal(t, layout.Right, request.direction) | |||
| 40 | } | 29 | } | ||
| 30 | + | ||||
| 31 | + | for _, tt := range tests { | |||
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 33 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 34 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 35 | + | tb := sess.activeTab() | |||
| 36 | + | tb.mu.Lock() | |||
| 37 | + | pane := tb.focusedPane() | |||
| 38 | + | tb.mu.Unlock() | |||
| 39 | + | spy := &actionRunnerSpy{} | |||
| 40 | + | target := daemonActionTarget{session: sess, tab: tb, pane: pane} | |||
| 41 | + | ||||
| 42 | + | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy})) | |||
| 43 | + | require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target})) | |||
| 44 | + | ||||
| 45 | + | require.Len(t, spy.requests, 2) | |||
| 46 | + | for _, request := range spy.requests { | |||
| 47 | + | require.Equal(t, tt.kind, request.kind) | |||
| 48 | + | require.Same(t, sess, request.target.session) | |||
| 49 | + | require.Same(t, tb, request.target.tab) | |||
| 50 | + | require.Same(t, pane, request.target.pane) | |||
| 51 | + | require.Equal(t, tt.direction, request.direction) | |||
| 52 | + | } | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) { | |||
| 58 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 59 | + | h.tab.mu.Lock() | |||
| 60 | + | h.tab.tree.Focus = "pane-1" | |||
| 61 | + | beforeGeneration := h.tab.layoutGeneration | |||
| 62 | + | tabStableID := h.tab.stableID | |||
| 63 | + | targetStableID := h.panes["pane-3"].stableID | |||
| 64 | + | h.tab.mu.Unlock() | |||
| 65 | + | ||||
| 66 | + | result := sendCommand(t, h.daemon, ports.CommandRequest{ | |||
| 67 | + | Slug: "consume-or-expel-pane-left", | |||
| 68 | + | Self: true, | |||
| 69 | + | TargetSession: "work", | |||
| 70 | + | TargetTab: tabStableID, | |||
| 71 | + | TargetPane: targetStableID, | |||
| 72 | + | }) | |||
| 73 | + | ||||
| 74 | + | require.True(t, result.OK, result.Text) | |||
| 75 | + | require.Empty(t, result.Output) | |||
| 76 | + | h.session.mu.Lock() | |||
| 77 | + | require.Zero(t, h.session.active, "--self must not change the active tab") | |||
| 78 | + | h.session.mu.Unlock() | |||
| 79 | + | h.tab.mu.Lock() | |||
| 80 | + | require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused") | |||
| 81 | + | require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration) | |||
| 82 | + | require.Len(t, h.tab.tree.Root.Children, 2) | |||
| 83 | + | h.tab.mu.Unlock() | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |||
| 87 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 88 | + | ac := &attachedClient{} | |||
| 89 | + | ac.setSession(h.session) | |||
| 90 | + | h.session.mu.Lock() | |||
| 91 | + | h.session.client = ac | |||
| 92 | + | h.session.mu.Unlock() | |||
| 93 | + | invalidations := make(chan renderInvalidation, 1) | |||
| 94 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 95 | + | rc.attach(ac) | |||
| 96 | + | h.session.installRenderCoordinator(rc) | |||
| 97 | + | before := h.snapshot() | |||
| 98 | + | ||||
| 99 | + | result := sendCommand(t, h.daemon, ports.CommandRequest{ | |||
| 100 | + | Slug: "consume-or-expel-pane-left", | |||
| 101 | + | Self: true, | |||
| 102 | + | TargetSession: "work", | |||
| 103 | + | TargetTab: h.tab.stableID, | |||
| 104 | + | TargetPane: h.panes["pane-1"].stableID, | |||
| 105 | + | }) | |||
| 106 | + | ||||
| 107 | + | require.True(t, result.OK, result.Text) | |||
| 108 | + | require.Empty(t, result.Output) | |||
| 109 | + | require.Equal(t, before, h.snapshot()) | |||
| 110 | + | require.Empty(t, h.daemon.notices.history()) | |||
| 111 | + | requireNoInvalidation(t, invalidations) | |||
| 41 | } | 112 | } | ||
| 42 | 113 | ||||
| 43 | type actionRunnerSpy struct { | 114 | type actionRunnerSpy struct { | ||
M internal/usecase/daemon/input.go +19 -7
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "bytes" | |
| 6 | + | "errors" | |
| 6 | 7 | "strconv" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/domain" | |
| @@ -291,14 +292,21 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 291 | 292 | if sess == nil { | |
| 292 | 293 | return | |
| 293 | 294 | } | |
| 294 | - | runResizeAction := func(request daemonActionRequest) { | |
| 295 | + | runAction := func(request daemonActionRequest) { | |
| 295 | 296 | request.target = resolveDaemonActionTarget(sess) | |
| 296 | 297 | runner := h.actions | |
| 297 | 298 | if runner == nil { | |
| 298 | 299 | runner = daemonActions{d: h.d} | |
| 299 | 300 | } | |
| 300 | 301 | if err := runner.Run(request); err != nil { | |
| 301 | - | h.d.reportError(sess, resizeUserError(err)) | |
| 302 | + | if errors.Is(err, errDaemonActionNoChange) { | |
| 303 | + | return | |
| 304 | + | } | |
| 305 | + | var userErr *domain.UserError | |
| 306 | + | if !errors.As(err, &userErr) { | |
| 307 | + | err = resizeUserError(err) | |
| 308 | + | } | |
| 309 | + | h.d.reportError(sess, err) | |
| 302 | 310 | return | |
| 303 | 311 | } | |
| 304 | 312 | if h.actions == nil { | |
| @@ -334,15 +342,19 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 334 | 342 | h.d.reportError(sess, err) | |
| 335 | 343 | } | |
| 336 | 344 | case keys.ActionGrowPaneWidth: | |
| 337 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) | |
| 345 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) | |
| 338 | 346 | case keys.ActionShrinkPaneWidth: | |
| 339 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) | |
| 347 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) | |
| 340 | 348 | case keys.ActionGrowPaneHeight: | |
| 341 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) | |
| 349 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) | |
| 342 | 350 | case keys.ActionShrinkPaneHeight: | |
| 343 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) | |
| 351 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) | |
| 344 | 352 | case keys.ActionEqualizePanes: | |
| 345 | - | runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes}) | |
| 353 | + | runAction(daemonActionRequest{kind: daemonActionEqualizePanes}) | |
| 354 | + | case keys.ActionConsumeOrExpelPaneLeft: | |
| 355 | + | runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left}) | |
| 356 | + | case keys.ActionConsumeOrExpelPaneRight: | |
| 357 | + | runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right}) | |
| 346 | 358 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 347 | 359 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 348 | 360 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | + | "errors" | |||
| 6 | "strconv" | 7 | "strconv" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| @@ -291,14 +292,21 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 291 | if sess == nil { | 292 | if sess == nil { | ||
| 292 | return | 293 | return | ||
| 293 | } | 294 | } | ||
| 294 | - | runResizeAction := func(request daemonActionRequest) { | 295 | + | runAction := func(request daemonActionRequest) { |
| 295 | request.target = resolveDaemonActionTarget(sess) | 296 | request.target = resolveDaemonActionTarget(sess) | ||
| 296 | runner := h.actions | 297 | runner := h.actions | ||
| 297 | if runner == nil { | 298 | if runner == nil { | ||
| 298 | runner = daemonActions{d: h.d} | 299 | runner = daemonActions{d: h.d} | ||
| 299 | } | 300 | } | ||
| 300 | if err := runner.Run(request); err != nil { | 301 | if err := runner.Run(request); err != nil { | ||
| 301 | - | h.d.reportError(sess, resizeUserError(err)) | 302 | + | if errors.Is(err, errDaemonActionNoChange) { |
| 303 | + | return | |||
| 304 | + | } | |||
| 305 | + | var userErr *domain.UserError | |||
| 306 | + | if !errors.As(err, &userErr) { | |||
| 307 | + | err = resizeUserError(err) | |||
| 308 | + | } | |||
| 309 | + | h.d.reportError(sess, err) | |||
| 302 | return | 310 | return | ||
| 303 | } | 311 | } | ||
| 304 | if h.actions == nil { | 312 | if h.actions == nil { | ||
| @@ -334,15 +342,19 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 334 | h.d.reportError(sess, err) | 342 | h.d.reportError(sess, err) | ||
| 335 | } | 343 | } | ||
| 336 | case keys.ActionGrowPaneWidth: | 344 | case keys.ActionGrowPaneWidth: | ||
| 337 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) | 345 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) |
| 338 | case keys.ActionShrinkPaneWidth: | 346 | case keys.ActionShrinkPaneWidth: | ||
| 339 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) | 347 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) |
| 340 | case keys.ActionGrowPaneHeight: | 348 | case keys.ActionGrowPaneHeight: | ||
| 341 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) | 349 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) |
| 342 | case keys.ActionShrinkPaneHeight: | 350 | case keys.ActionShrinkPaneHeight: | ||
| 343 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) | 351 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) |
| 344 | case keys.ActionEqualizePanes: | 352 | case keys.ActionEqualizePanes: | ||
| 345 | - | runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes}) | 353 | + | runAction(daemonActionRequest{kind: daemonActionEqualizePanes}) |
| 354 | + | case keys.ActionConsumeOrExpelPaneLeft: | |||
| 355 | + | runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left}) | |||
| 356 | + | case keys.ActionConsumeOrExpelPaneRight: | |||
| 357 | + | runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right}) | |||
| 346 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | 358 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | ||
| 347 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | 359 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | ||
| 348 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 360 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
M internal/usecase/daemon/input_test.go +102 -0
| @@ -26,6 +26,108 @@ import ( | |||
| 26 | 26 | ||
| 27 | 27 | // --- test doubles ----------------------------------------------------------- | |
| 28 | 28 | ||
| 29 | + | func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) { | |
| 30 | + | tests := []struct { | |
| 31 | + | name string | |
| 32 | + | binding string | |
| 33 | + | key byte | |
| 34 | + | focus layout.PaneID | |
| 35 | + | wantTarget layout.PaneID | |
| 36 | + | }{ | |
| 37 | + | {name: "left", binding: "consume-or-expel-pane-left", key: 'H', focus: "pane-3", wantTarget: "pane-3"}, | |
| 38 | + | {name: "right", binding: "consume-or-expel-pane-right", key: 'L', focus: "pane-1", wantTarget: "pane-1"}, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | for _, tt := range tests { | |
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |
| 43 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 44 | + | h.tab.mu.Lock() | |
| 45 | + | h.tab.tree.Focus = tt.focus | |
| 46 | + | beforeGeneration := h.tab.layoutGeneration | |
| 47 | + | h.tab.mu.Unlock() | |
| 48 | + | bindings, warnings := keys.BuildBindings(map[string]string{tt.binding: "alt+" + string(tt.key)}) | |
| 49 | + | require.Empty(t, warnings) | |
| 50 | + | h.daemon.bindings.Store(bindings) | |
| 51 | + | ac := &attachedClient{} | |
| 52 | + | ac.setSession(h.session) | |
| 53 | + | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |
| 54 | + | h.session.mu.Lock() | |
| 55 | + | h.session.client = ac | |
| 56 | + | h.session.mu.Unlock() | |
| 57 | + | invalidations := make(chan renderInvalidation, 1) | |
| 58 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| 59 | + | rc.attach(ac) | |
| 60 | + | h.session.installRenderCoordinator(rc) | |
| 61 | + | ||
| 62 | + | h.daemon.handleInput(h.session, ac, []byte{keys.ESC, tt.key}) | |
| 63 | + | ||
| 64 | + | h.tab.mu.Lock() | |
| 65 | + | require.Equal(t, tt.wantTarget, h.tab.tree.Focus) | |
| 66 | + | require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration) | |
| 67 | + | require.Len(t, h.tab.tree.Root.Children, 2) | |
| 68 | + | h.tab.mu.Unlock() | |
| 69 | + | awaitInvalidation(t, invalidations) | |
| 70 | + | requireNoInvalidation(t, invalidations) | |
| 71 | + | }) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) { | |
| 76 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 77 | + | bindings, warnings := keys.BuildBindings(map[string]string{"consume-or-expel-pane-left": "alt+H"}) | |
| 78 | + | require.Empty(t, warnings) | |
| 79 | + | h.daemon.bindings.Store(bindings) | |
| 80 | + | ac := &attachedClient{} | |
| 81 | + | ac.setSession(h.session) | |
| 82 | + | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |
| 83 | + | h.session.mu.Lock() | |
| 84 | + | h.session.client = ac | |
| 85 | + | h.session.mu.Unlock() | |
| 86 | + | invalidations := make(chan renderInvalidation, 1) | |
| 87 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| 88 | + | rc.attach(ac) | |
| 89 | + | h.session.installRenderCoordinator(rc) | |
| 90 | + | before := h.snapshot() | |
| 91 | + | ||
| 92 | + | h.daemon.handleInput(h.session, ac, []byte{keys.ESC, 'H'}) | |
| 93 | + | ||
| 94 | + | require.Equal(t, before, h.snapshot()) | |
| 95 | + | require.Empty(t, h.daemon.notices.history()) | |
| 96 | + | requireNoInvalidation(t, invalidations) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) { | |
| 100 | + | tests := []struct { | |
| 101 | + | name string | |
| 102 | + | action keys.Action | |
| 103 | + | }{ | |
| 104 | + | {name: "left", action: keys.ActionConsumeOrExpelPaneLeft}, | |
| 105 | + | {name: "right", action: keys.ActionConsumeOrExpelPaneRight}, | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | for _, tt := range tests { | |
| 109 | + | t.Run(tt.name, func(t *testing.T) { | |
| 110 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 111 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 112 | + | ac := &attachedClient{} | |
| 113 | + | ac.setSession(sess) | |
| 114 | + | runner := &actionRunnerSpy{err: domain.UserWarn( | |
| 115 | + | domain.NoticeLayoutTooSmall, | |
| 116 | + | "not enough space to rearrange pane", | |
| 117 | + | layout.ErrTooSmall, | |
| 118 | + | )} | |
| 119 | + | ||
| 120 | + | daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action) | |
| 121 | + | ||
| 122 | + | history := d.notices.history() | |
| 123 | + | require.Len(t, history, 1) | |
| 124 | + | require.Equal(t, domain.NoticeLayoutTooSmall, history[0].Code) | |
| 125 | + | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |
| 126 | + | require.Equal(t, "not enough space to rearrange pane", history[0].Message) | |
| 127 | + | }) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 29 | 131 | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | |
| 30 | 132 | tests := []struct { | |
| 31 | 133 | name string | |
| @@ -26,6 +26,108 @@ import ( | |||||
| 26 | 26 | ||||
| 27 | // --- test doubles ----------------------------------------------------------- | 27 | // --- test doubles ----------------------------------------------------------- | ||
| 28 | 28 | ||||
| 29 | + | func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) { | |||
| 30 | + | tests := []struct { | |||
| 31 | + | name string | |||
| 32 | + | binding string | |||
| 33 | + | key byte | |||
| 34 | + | focus layout.PaneID | |||
| 35 | + | wantTarget layout.PaneID | |||
| 36 | + | }{ | |||
| 37 | + | {name: "left", binding: "consume-or-expel-pane-left", key: 'H', focus: "pane-3", wantTarget: "pane-3"}, | |||
| 38 | + | {name: "right", binding: "consume-or-expel-pane-right", key: 'L', focus: "pane-1", wantTarget: "pane-1"}, | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | for _, tt := range tests { | |||
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 43 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 44 | + | h.tab.mu.Lock() | |||
| 45 | + | h.tab.tree.Focus = tt.focus | |||
| 46 | + | beforeGeneration := h.tab.layoutGeneration | |||
| 47 | + | h.tab.mu.Unlock() | |||
| 48 | + | bindings, warnings := keys.BuildBindings(map[string]string{tt.binding: "alt+" + string(tt.key)}) | |||
| 49 | + | require.Empty(t, warnings) | |||
| 50 | + | h.daemon.bindings.Store(bindings) | |||
| 51 | + | ac := &attachedClient{} | |||
| 52 | + | ac.setSession(h.session) | |||
| 53 | + | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |||
| 54 | + | h.session.mu.Lock() | |||
| 55 | + | h.session.client = ac | |||
| 56 | + | h.session.mu.Unlock() | |||
| 57 | + | invalidations := make(chan renderInvalidation, 1) | |||
| 58 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 59 | + | rc.attach(ac) | |||
| 60 | + | h.session.installRenderCoordinator(rc) | |||
| 61 | + | ||||
| 62 | + | h.daemon.handleInput(h.session, ac, []byte{keys.ESC, tt.key}) | |||
| 63 | + | ||||
| 64 | + | h.tab.mu.Lock() | |||
| 65 | + | require.Equal(t, tt.wantTarget, h.tab.tree.Focus) | |||
| 66 | + | require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration) | |||
| 67 | + | require.Len(t, h.tab.tree.Root.Children, 2) | |||
| 68 | + | h.tab.mu.Unlock() | |||
| 69 | + | awaitInvalidation(t, invalidations) | |||
| 70 | + | requireNoInvalidation(t, invalidations) | |||
| 71 | + | }) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) { | |||
| 76 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 77 | + | bindings, warnings := keys.BuildBindings(map[string]string{"consume-or-expel-pane-left": "alt+H"}) | |||
| 78 | + | require.Empty(t, warnings) | |||
| 79 | + | h.daemon.bindings.Store(bindings) | |||
| 80 | + | ac := &attachedClient{} | |||
| 81 | + | ac.setSession(h.session) | |||
| 82 | + | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |||
| 83 | + | h.session.mu.Lock() | |||
| 84 | + | h.session.client = ac | |||
| 85 | + | h.session.mu.Unlock() | |||
| 86 | + | invalidations := make(chan renderInvalidation, 1) | |||
| 87 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 88 | + | rc.attach(ac) | |||
| 89 | + | h.session.installRenderCoordinator(rc) | |||
| 90 | + | before := h.snapshot() | |||
| 91 | + | ||||
| 92 | + | h.daemon.handleInput(h.session, ac, []byte{keys.ESC, 'H'}) | |||
| 93 | + | ||||
| 94 | + | require.Equal(t, before, h.snapshot()) | |||
| 95 | + | require.Empty(t, h.daemon.notices.history()) | |||
| 96 | + | requireNoInvalidation(t, invalidations) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) { | |||
| 100 | + | tests := []struct { | |||
| 101 | + | name string | |||
| 102 | + | action keys.Action | |||
| 103 | + | }{ | |||
| 104 | + | {name: "left", action: keys.ActionConsumeOrExpelPaneLeft}, | |||
| 105 | + | {name: "right", action: keys.ActionConsumeOrExpelPaneRight}, | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | for _, tt := range tests { | |||
| 109 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 110 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 111 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 112 | + | ac := &attachedClient{} | |||
| 113 | + | ac.setSession(sess) | |||
| 114 | + | runner := &actionRunnerSpy{err: domain.UserWarn( | |||
| 115 | + | domain.NoticeLayoutTooSmall, | |||
| 116 | + | "not enough space to rearrange pane", | |||
| 117 | + | layout.ErrTooSmall, | |||
| 118 | + | )} | |||
| 119 | + | ||||
| 120 | + | daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action) | |||
| 121 | + | ||||
| 122 | + | history := d.notices.history() | |||
| 123 | + | require.Len(t, history, 1) | |||
| 124 | + | require.Equal(t, domain.NoticeLayoutTooSmall, history[0].Code) | |||
| 125 | + | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |||
| 126 | + | require.Equal(t, "not enough space to rearrange pane", history[0].Message) | |||
| 127 | + | }) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 29 | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | 131 | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | ||
| 30 | tests := []struct { | 132 | tests := []struct { | ||
| 31 | name string | 133 | name string | ||
A internal/usecase/daemon/layout_mutation.go +80 -0
| @@ -0,0 +1,80 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // mutateTargetLayout preserves the publication semantics used by resize and | |
| 9 | + | // equalize mutations, which always publish a successful candidate. | |
| 10 | + | func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error { | |
| 11 | + | _, err := d.mutateTargetLayoutChanged(target, requirePane, func(candidate *layout.Tree, area domain.Rect) (bool, error) { | |
| 12 | + | if err := mutate(candidate, area); err != nil { | |
| 13 | + | return false, err | |
| 14 | + | } | |
| 15 | + | return true, nil | |
| 16 | + | }) | |
| 17 | + | return err | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // mutateTargetLayoutChanged validates an explicit target and publishes the | |
| 21 | + | // candidate only when mutate reports a change. State locks are released before | |
| 22 | + | // applying pane geometry so PTY.Resize never runs under daemon state locks. | |
| 23 | + | func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) (bool, error)) (bool, error) { | |
| 24 | + | if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) { | |
| 25 | + | return false, layout.ErrNotFound | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | d.mu.Lock() | |
| 29 | + | if d.sessions[target.session.id] != target.session { | |
| 30 | + | d.mu.Unlock() | |
| 31 | + | return false, layout.ErrNotFound | |
| 32 | + | } | |
| 33 | + | target.session.mu.Lock() | |
| 34 | + | foundTab := false | |
| 35 | + | for _, tb := range target.session.tabs { | |
| 36 | + | if tb == target.tab { | |
| 37 | + | foundTab = true | |
| 38 | + | break | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | if !foundTab { | |
| 42 | + | target.session.mu.Unlock() | |
| 43 | + | d.mu.Unlock() | |
| 44 | + | return false, layout.ErrNotFound | |
| 45 | + | } | |
| 46 | + | target.tab.mu.Lock() | |
| 47 | + | if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) { | |
| 48 | + | target.tab.mu.Unlock() | |
| 49 | + | target.session.mu.Unlock() | |
| 50 | + | d.mu.Unlock() | |
| 51 | + | return false, layout.ErrNotFound | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | candidate := target.tab.tree.Clone() | |
| 55 | + | area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows} | |
| 56 | + | changed, err := mutate(candidate, area) | |
| 57 | + | if err != nil { | |
| 58 | + | target.tab.mu.Unlock() | |
| 59 | + | target.session.mu.Unlock() | |
| 60 | + | d.mu.Unlock() | |
| 61 | + | return false, err | |
| 62 | + | } | |
| 63 | + | if !changed { | |
| 64 | + | target.tab.mu.Unlock() | |
| 65 | + | target.session.mu.Unlock() | |
| 66 | + | d.mu.Unlock() | |
| 67 | + | return false, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | target.tab.tree = candidate | |
| 71 | + | target.tab.bumpLayoutGenerationLocked() | |
| 72 | + | target.tab.mu.Unlock() | |
| 73 | + | target.session.mu.Unlock() | |
| 74 | + | d.mu.Unlock() | |
| 75 | + | ||
| 76 | + | if !d.applyTabLayout(target.session, target.tab) { | |
| 77 | + | return false, layout.ErrNotFound | |
| 78 | + | } | |
| 79 | + | return true, nil | |
| 80 | + | } | |
| @@ -0,0 +1,80 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // mutateTargetLayout preserves the publication semantics used by resize and | |||
| 9 | + | // equalize mutations, which always publish a successful candidate. | |||
| 10 | + | func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error { | |||
| 11 | + | _, err := d.mutateTargetLayoutChanged(target, requirePane, func(candidate *layout.Tree, area domain.Rect) (bool, error) { | |||
| 12 | + | if err := mutate(candidate, area); err != nil { | |||
| 13 | + | return false, err | |||
| 14 | + | } | |||
| 15 | + | return true, nil | |||
| 16 | + | }) | |||
| 17 | + | return err | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // mutateTargetLayoutChanged validates an explicit target and publishes the | |||
| 21 | + | // candidate only when mutate reports a change. State locks are released before | |||
| 22 | + | // applying pane geometry so PTY.Resize never runs under daemon state locks. | |||
| 23 | + | func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) (bool, error)) (bool, error) { | |||
| 24 | + | if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) { | |||
| 25 | + | return false, layout.ErrNotFound | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | d.mu.Lock() | |||
| 29 | + | if d.sessions[target.session.id] != target.session { | |||
| 30 | + | d.mu.Unlock() | |||
| 31 | + | return false, layout.ErrNotFound | |||
| 32 | + | } | |||
| 33 | + | target.session.mu.Lock() | |||
| 34 | + | foundTab := false | |||
| 35 | + | for _, tb := range target.session.tabs { | |||
| 36 | + | if tb == target.tab { | |||
| 37 | + | foundTab = true | |||
| 38 | + | break | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | if !foundTab { | |||
| 42 | + | target.session.mu.Unlock() | |||
| 43 | + | d.mu.Unlock() | |||
| 44 | + | return false, layout.ErrNotFound | |||
| 45 | + | } | |||
| 46 | + | target.tab.mu.Lock() | |||
| 47 | + | if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) { | |||
| 48 | + | target.tab.mu.Unlock() | |||
| 49 | + | target.session.mu.Unlock() | |||
| 50 | + | d.mu.Unlock() | |||
| 51 | + | return false, layout.ErrNotFound | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | candidate := target.tab.tree.Clone() | |||
| 55 | + | area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows} | |||
| 56 | + | changed, err := mutate(candidate, area) | |||
| 57 | + | if err != nil { | |||
| 58 | + | target.tab.mu.Unlock() | |||
| 59 | + | target.session.mu.Unlock() | |||
| 60 | + | d.mu.Unlock() | |||
| 61 | + | return false, err | |||
| 62 | + | } | |||
| 63 | + | if !changed { | |||
| 64 | + | target.tab.mu.Unlock() | |||
| 65 | + | target.session.mu.Unlock() | |||
| 66 | + | d.mu.Unlock() | |||
| 67 | + | return false, nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | target.tab.tree = candidate | |||
| 71 | + | target.tab.bumpLayoutGenerationLocked() | |||
| 72 | + | target.tab.mu.Unlock() | |||
| 73 | + | target.session.mu.Unlock() | |||
| 74 | + | d.mu.Unlock() | |||
| 75 | + | ||||
| 76 | + | if !d.applyTabLayout(target.session, target.tab) { | |||
| 77 | + | return false, layout.ErrNotFound | |||
| 78 | + | } | |||
| 79 | + | return true, nil | |||
| 80 | + | } | |||
M internal/usecase/daemon/palette.go +28 -8
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "sort" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -304,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||
| 304 | 305 | return | |
| 305 | 306 | } | |
| 306 | 307 | sess.dispatchMu.Lock() | |
| 307 | - | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args) | |
| 308 | + | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, redrawClosedPalette: true}, args) | |
| 308 | 309 | sess.dispatchMu.Unlock() | |
| 309 | 310 | if err != nil { | |
| 310 | 311 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | |
| @@ -351,11 +352,12 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin | |||
| 351 | 352 | } | |
| 352 | 353 | ||
| 353 | 354 | type paletteExec struct { | |
| 354 | - | d *Daemon | |
| 355 | - | sess *session | |
| 356 | - | ac *attachedClient | |
| 357 | - | recent []recentSession | |
| 358 | - | actions daemonActionRunner | |
| 355 | + | d *Daemon | |
| 356 | + | sess *session | |
| 357 | + | ac *attachedClient | |
| 358 | + | recent []recentSession | |
| 359 | + | actions daemonActionRunner | |
| 360 | + | redrawClosedPalette bool | |
| 359 | 361 | } | |
| 360 | 362 | ||
| 361 | 363 | func (e paletteExec) runAction(request daemonActionRequest) error { | |
| @@ -367,10 +369,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error { | |||
| 367 | 369 | runner = daemonActions{d: e.d} | |
| 368 | 370 | } | |
| 369 | 371 | err := runner.Run(request) | |
| 370 | - | if err == nil && e.actions == nil { | |
| 372 | + | if errors.Is(err, errDaemonActionNoChange) { | |
| 373 | + | if e.redrawClosedPalette { | |
| 374 | + | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | |
| 375 | + | } | |
| 376 | + | return nil | |
| 377 | + | } | |
| 378 | + | if err != nil { | |
| 379 | + | return err | |
| 380 | + | } | |
| 381 | + | if e.actions == nil { | |
| 371 | 382 | finishDaemonActionForClient(e.d, request, e.ac, "palette.go") | |
| 372 | 383 | } | |
| 373 | - | return err | |
| 384 | + | return nil | |
| 374 | 385 | } | |
| 375 | 386 | ||
| 376 | 387 | func (e paletteExec) CreateTab() error { | |
| @@ -396,6 +407,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) } | |||
| 396 | 407 | func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } | |
| 397 | 408 | func (e paletteExec) SplitUp() error { return e.split(layout.Up) } | |
| 398 | 409 | func (e paletteExec) SplitDown() error { return e.split(layout.Down) } | |
| 410 | + | func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error { | |
| 411 | + | return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction}) | |
| 412 | + | } | |
| 413 | + | func (e paletteExec) ConsumeOrExpelPaneLeft() error { | |
| 414 | + | return e.consumeOrExpelPane(layout.Left) | |
| 415 | + | } | |
| 416 | + | func (e paletteExec) ConsumeOrExpelPaneRight() error { | |
| 417 | + | return e.consumeOrExpelPane(layout.Right) | |
| 418 | + | } | |
| 399 | 419 | ||
| 400 | 420 | func (e paletteExec) StackPane() error { | |
| 401 | 421 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "sort" | 5 | "sort" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -304,7 +305,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) { | |||||
| 304 | return | 305 | return | ||
| 305 | } | 306 | } | ||
| 306 | sess.dispatchMu.Lock() | 307 | sess.dispatchMu.Lock() | ||
| 307 | - | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac}, args) | 308 | + | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, redrawClosedPalette: true}, args) |
| 308 | sess.dispatchMu.Unlock() | 309 | sess.dispatchMu.Unlock() | ||
| 309 | if err != nil { | 310 | if err != nil { | ||
| 310 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | 311 | d.log.Error("palette command failed", "err", err, "command", cmd.Code) | ||
| @@ -351,11 +352,12 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin | |||||
| 351 | } | 352 | } | ||
| 352 | 353 | ||||
| 353 | type paletteExec struct { | 354 | type paletteExec struct { | ||
| 354 | - | d *Daemon | 355 | + | d *Daemon |
| 355 | - | sess *session | 356 | + | sess *session |
| 356 | - | ac *attachedClient | 357 | + | ac *attachedClient |
| 357 | - | recent []recentSession | 358 | + | recent []recentSession |
| 358 | - | actions daemonActionRunner | 359 | + | actions daemonActionRunner |
| 360 | + | redrawClosedPalette bool | |||
| 359 | } | 361 | } | ||
| 360 | 362 | ||||
| 361 | func (e paletteExec) runAction(request daemonActionRequest) error { | 363 | func (e paletteExec) runAction(request daemonActionRequest) error { | ||
| @@ -367,10 +369,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error { | |||||
| 367 | runner = daemonActions{d: e.d} | 369 | runner = daemonActions{d: e.d} | ||
| 368 | } | 370 | } | ||
| 369 | err := runner.Run(request) | 371 | err := runner.Run(request) | ||
| 370 | - | if err == nil && e.actions == nil { | 372 | + | if errors.Is(err, errDaemonActionNoChange) { |
| 373 | + | if e.redrawClosedPalette { | |||
| 374 | + | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | |||
| 375 | + | } | |||
| 376 | + | return nil | |||
| 377 | + | } | |||
| 378 | + | if err != nil { | |||
| 379 | + | return err | |||
| 380 | + | } | |||
| 381 | + | if e.actions == nil { | |||
| 371 | finishDaemonActionForClient(e.d, request, e.ac, "palette.go") | 382 | finishDaemonActionForClient(e.d, request, e.ac, "palette.go") | ||
| 372 | } | 383 | } | ||
| 373 | - | return err | 384 | + | return nil |
| 374 | } | 385 | } | ||
| 375 | 386 | ||||
| 376 | func (e paletteExec) CreateTab() error { | 387 | func (e paletteExec) CreateTab() error { | ||
| @@ -396,6 +407,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) } | |||||
| 396 | func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } | 407 | func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } | ||
| 397 | func (e paletteExec) SplitUp() error { return e.split(layout.Up) } | 408 | func (e paletteExec) SplitUp() error { return e.split(layout.Up) } | ||
| 398 | func (e paletteExec) SplitDown() error { return e.split(layout.Down) } | 409 | func (e paletteExec) SplitDown() error { return e.split(layout.Down) } | ||
| 410 | + | func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error { | |||
| 411 | + | return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction}) | |||
| 412 | + | } | |||
| 413 | + | func (e paletteExec) ConsumeOrExpelPaneLeft() error { | |||
| 414 | + | return e.consumeOrExpelPane(layout.Left) | |||
| 415 | + | } | |||
| 416 | + | func (e paletteExec) ConsumeOrExpelPaneRight() error { | |||
| 417 | + | return e.consumeOrExpelPane(layout.Right) | |||
| 418 | + | } | |||
| 399 | 419 | ||||
| 400 | func (e paletteExec) StackPane() error { | 420 | func (e paletteExec) StackPane() error { | ||
| 401 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | 421 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | ||
M internal/usecase/daemon/palette_test.go +12 -4
| @@ -409,14 +409,22 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||
| 409 | 409 | } | |
| 410 | 410 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 411 | 411 | awaitFrame(t, sends, ports.MsgOutput) | |
| 412 | - | go d.handleInput(sess, ac, []byte("JRS 1\r")) | |
| 413 | - | <-validated | |
| 412 | + | inputHandled := make(chan struct{}) | |
| 413 | + | go func() { | |
| 414 | + | d.handleInput(sess, ac, []byte("JRS 1\r")) | |
| 415 | + | close(inputHandled) | |
| 416 | + | }() | |
| 417 | + | awaitTestCompletion(t, validated, "JRS did not validate its captured target") | |
| 414 | 418 | d.mu.Lock() | |
| 415 | 419 | delete(d.sessions, target.id) | |
| 416 | 420 | d.mu.Unlock() | |
| 417 | 421 | close(releaseHandoff) | |
| 418 | 422 | ||
| 419 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 423 | + | // switchToTarget repaints its failed hand-off before handlePaletteInput | |
| 424 | + | // records the generation-safe feedback and schedules the feedback repaint. | |
| 425 | + | // Wait for the input transaction, rather than mistaking that intermediate | |
| 426 | + | // repaint for publication of the final palette state. | |
| 427 | + | awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete") | |
| 420 | 428 | require.True(t, ac.overlays.paletteActive()) | |
| 421 | 429 | ac.overlays.paletteMu.Lock() | |
| 422 | 430 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | |
| @@ -617,7 +625,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 617 | 625 | for i, cmd := range commands { | |
| 618 | 626 | codes[i] = cmd.Code | |
| 619 | 627 | } | |
| 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) | |
| 628 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "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 | 629 | } | |
| 622 | 630 | ||
| 623 | 631 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| @@ -409,14 +409,22 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||||
| 409 | } | 409 | } | ||
| 410 | d.handleInput(sess, ac, []byte("\x1b ")) | 410 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 411 | awaitFrame(t, sends, ports.MsgOutput) | 411 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 412 | - | go d.handleInput(sess, ac, []byte("JRS 1\r")) | 412 | + | inputHandled := make(chan struct{}) |
| 413 | - | <-validated | 413 | + | go func() { |
| 414 | + | d.handleInput(sess, ac, []byte("JRS 1\r")) | |||
| 415 | + | close(inputHandled) | |||
| 416 | + | }() | |||
| 417 | + | awaitTestCompletion(t, validated, "JRS did not validate its captured target") | |||
| 414 | d.mu.Lock() | 418 | d.mu.Lock() | ||
| 415 | delete(d.sessions, target.id) | 419 | delete(d.sessions, target.id) | ||
| 416 | d.mu.Unlock() | 420 | d.mu.Unlock() | ||
| 417 | close(releaseHandoff) | 421 | close(releaseHandoff) | ||
| 418 | 422 | ||||
| 419 | - | awaitFrame(t, sends, ports.MsgOutput) | 423 | + | // switchToTarget repaints its failed hand-off before handlePaletteInput |
| 424 | + | // records the generation-safe feedback and schedules the feedback repaint. | |||
| 425 | + | // Wait for the input transaction, rather than mistaking that intermediate | |||
| 426 | + | // repaint for publication of the final palette state. | |||
| 427 | + | awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete") | |||
| 420 | require.True(t, ac.overlays.paletteActive()) | 428 | require.True(t, ac.overlays.paletteActive()) | ||
| 421 | ac.overlays.paletteMu.Lock() | 429 | ac.overlays.paletteMu.Lock() | ||
| 422 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | 430 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | ||
| @@ -617,7 +625,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||||
| 617 | for i, cmd := range commands { | 625 | for i, cmd := range commands { | ||
| 618 | codes[i] = cmd.Code | 626 | codes[i] = cmd.Code | ||
| 619 | } | 627 | } | ||
| 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) | 628 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "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 | } | 629 | } | ||
| 622 | 630 | ||||
| 623 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | 631 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | ||
A internal/usecase/daemon/pane_rearrange.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var errDaemonActionNoChange = errors.New("daemon action made no change") | |
| 11 | + | ||
| 12 | + | func (d *Daemon) consumeOrExpelPane(target daemonActionTarget, direction layout.Direction) error { | |
| 13 | + | changed, err := d.mutateTargetLayoutChanged(target, true, func(candidate *layout.Tree, area domain.Rect) (bool, error) { | |
| 14 | + | return candidate.ConsumeOrExpelPane(target.pane.id, direction, area) | |
| 15 | + | }) | |
| 16 | + | if err != nil { | |
| 17 | + | return paneRearrangeUserError(err) | |
| 18 | + | } | |
| 19 | + | if !changed { | |
| 20 | + | return errDaemonActionNoChange | |
| 21 | + | } | |
| 22 | + | return nil | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func paneRearrangeUserError(err error) error { | |
| 26 | + | switch { | |
| 27 | + | case errors.Is(err, layout.ErrUnsupportedColumnLayout): | |
| 28 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane rearrangement requires a column layout", err) | |
| 29 | + | case errors.Is(err, layout.ErrTooSmall): | |
| 30 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to rearrange pane", err) | |
| 31 | + | default: | |
| 32 | + | return err | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | var errDaemonActionNoChange = errors.New("daemon action made no change") | |||
| 11 | + | ||||
| 12 | + | func (d *Daemon) consumeOrExpelPane(target daemonActionTarget, direction layout.Direction) error { | |||
| 13 | + | changed, err := d.mutateTargetLayoutChanged(target, true, func(candidate *layout.Tree, area domain.Rect) (bool, error) { | |||
| 14 | + | return candidate.ConsumeOrExpelPane(target.pane.id, direction, area) | |||
| 15 | + | }) | |||
| 16 | + | if err != nil { | |||
| 17 | + | return paneRearrangeUserError(err) | |||
| 18 | + | } | |||
| 19 | + | if !changed { | |||
| 20 | + | return errDaemonActionNoChange | |||
| 21 | + | } | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func paneRearrangeUserError(err error) error { | |||
| 26 | + | switch { | |||
| 27 | + | case errors.Is(err, layout.ErrUnsupportedColumnLayout): | |||
| 28 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane rearrangement requires a column layout", err) | |||
| 29 | + | case errors.Is(err, layout.ErrTooSmall): | |||
| 30 | + | return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to rearrange pane", err) | |||
| 31 | + | default: | |||
| 32 | + | return err | |||
| 33 | + | } | |||
| 34 | + | } | |||
A internal/usecase/daemon/pane_rearrange_test.go +605 -0
| @@ -0,0 +1,605 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/keys" | |
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) { | |
| 21 | + | tests := []struct { | |
| 22 | + | name string | |
| 23 | + | mutate func(*layout.Tree, domain.Rect) (bool, error) | |
| 24 | + | wantChanged bool | |
| 25 | + | wantGeneration uint64 | |
| 26 | + | wantSnapshotGen uint64 | |
| 27 | + | wantResizes bool | |
| 28 | + | }{ | |
| 29 | + | { | |
| 30 | + | name: "changed candidate publishes once", | |
| 31 | + | mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) { | |
| 32 | + | return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area) | |
| 33 | + | }, | |
| 34 | + | wantChanged: true, | |
| 35 | + | wantGeneration: 1, | |
| 36 | + | wantSnapshotGen: 1, | |
| 37 | + | wantResizes: true, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | name: "unchanged candidate is discarded", | |
| 41 | + | mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) { | |
| 42 | + | candidate.Focus = "pane-2" | |
| 43 | + | return false, nil | |
| 44 | + | }, | |
| 45 | + | }, | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | for _, tt := range tests { | |
| 49 | + | t.Run(tt.name, func(t *testing.T) { | |
| 50 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 51 | + | before := h.tab.tree.Clone() | |
| 52 | + | ||
| 53 | + | changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate) | |
| 54 | + | ||
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.Equal(t, tt.wantChanged, changed) | |
| 57 | + | h.tab.mu.Lock() | |
| 58 | + | require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration) | |
| 59 | + | if tt.wantChanged { | |
| 60 | + | require.NotEqual(t, before, h.tab.tree) | |
| 61 | + | } else { | |
| 62 | + | require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate") | |
| 63 | + | } | |
| 64 | + | h.tab.mu.Unlock() | |
| 65 | + | h.session.snapshotMu.Lock() | |
| 66 | + | require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration) | |
| 67 | + | h.session.snapshotMu.Unlock() | |
| 68 | + | if tt.wantResizes { | |
| 69 | + | require.NotZero(t, h.totalResizes()) | |
| 70 | + | } else { | |
| 71 | + | require.Zero(t, h.totalResizes()) | |
| 72 | + | } | |
| 73 | + | }) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) { | |
| 78 | + | tests := []struct { | |
| 79 | + | name string | |
| 80 | + | stale func(*paneRearrangeHarness) daemonActionTarget | |
| 81 | + | }{ | |
| 82 | + | { | |
| 83 | + | name: "session", | |
| 84 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |
| 85 | + | target := h.target("pane-1") | |
| 86 | + | h.daemon.sessions[h.session.id] = &session{id: h.session.id} | |
| 87 | + | return target | |
| 88 | + | }, | |
| 89 | + | }, | |
| 90 | + | { | |
| 91 | + | name: "tab", | |
| 92 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |
| 93 | + | target := h.target("pane-1") | |
| 94 | + | h.session.tabs = []*tab{newTab(nil, h.tab.size)} | |
| 95 | + | return target | |
| 96 | + | }, | |
| 97 | + | }, | |
| 98 | + | { | |
| 99 | + | name: "pane identity", | |
| 100 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |
| 101 | + | target := h.target("pane-1") | |
| 102 | + | h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size) | |
| 103 | + | return target | |
| 104 | + | }, | |
| 105 | + | }, | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | for _, tt := range tests { | |
| 109 | + | t.Run(tt.name, func(t *testing.T) { | |
| 110 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 111 | + | before := h.tab.tree.Clone() | |
| 112 | + | called := false | |
| 113 | + | ||
| 114 | + | changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) { | |
| 115 | + | called = true | |
| 116 | + | return true, nil | |
| 117 | + | }) | |
| 118 | + | ||
| 119 | + | require.False(t, changed) | |
| 120 | + | require.ErrorIs(t, err, layout.ErrNotFound) | |
| 121 | + | require.False(t, called, "stale identity must be rejected before invoking the mutation") | |
| 122 | + | require.Equal(t, before, h.tab.tree) | |
| 123 | + | require.Zero(t, h.tab.layoutGeneration) | |
| 124 | + | require.Zero(t, h.totalResizes()) | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) { | |
| 130 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 131 | + | locksAvailable := make(chan bool, 8) | |
| 132 | + | for _, tracked := range h.ptys { | |
| 133 | + | tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() } | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | done := make(chan error, 1) | |
| 137 | + | go func() { | |
| 138 | + | done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
| 139 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right, | |
| 140 | + | }) | |
| 141 | + | }() | |
| 142 | + | ||
| 143 | + | select { | |
| 144 | + | case err := <-done: | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | case <-time.After(2 * time.Second): | |
| 147 | + | t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks") | |
| 148 | + | } | |
| 149 | + | require.NotZero(t, h.totalResizes()) | |
| 150 | + | close(locksAvailable) | |
| 151 | + | for available := range locksAvailable { | |
| 152 | + | require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held") | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) { | |
| 157 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 158 | + | h.tab.mu.Lock() | |
| 159 | + | h.tab.tree.Focus = "pane-3" | |
| 160 | + | before := make(map[layout.PaneID]*pane, len(h.tab.panes)) | |
| 161 | + | for id, p := range h.tab.panes { | |
| 162 | + | before[id] = p | |
| 163 | + | } | |
| 164 | + | h.tab.mu.Unlock() | |
| 165 | + | ||
| 166 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
| 167 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right, | |
| 168 | + | }) | |
| 169 | + | ||
| 170 | + | require.NoError(t, err) | |
| 171 | + | h.tab.mu.Lock() | |
| 172 | + | require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused") | |
| 173 | + | require.Len(t, h.tab.panes, len(before)) | |
| 174 | + | for id, p := range before { | |
| 175 | + | require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id) | |
| 176 | + | } | |
| 177 | + | h.tab.mu.Unlock() | |
| 178 | + | h.factory.AssertNotCalled(t, "Open") | |
| 179 | + | for _, tracked := range h.ptys { | |
| 180 | + | require.Zero(t, tracked.closeCount()) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) { | |
| 185 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 186 | + | ac := &attachedClient{} | |
| 187 | + | ac.setSession(h.session) | |
| 188 | + | h.session.mu.Lock() | |
| 189 | + | h.session.client = ac | |
| 190 | + | h.session.mu.Unlock() | |
| 191 | + | invalidations := make(chan renderInvalidation, 2) | |
| 192 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| 193 | + | rc.attach(ac) | |
| 194 | + | h.session.installRenderCoordinator(rc) | |
| 195 | + | before := h.snapshot() | |
| 196 | + | request := daemonActionRequest{ | |
| 197 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left, | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | err := (daemonActions{d: h.daemon}).Run(request) | |
| 201 | + | require.ErrorIs(t, err, errDaemonActionNoChange, "the daemon action must retain the package no-change sentinel") | |
| 202 | + | ||
| 203 | + | require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size") | |
| 204 | + | require.Zero(t, h.totalResizes()) | |
| 205 | + | h.factory.AssertNotCalled(t, "Open") | |
| 206 | + | for _, tracked := range h.ptys { | |
| 207 | + | require.Zero(t, tracked.closeCount()) | |
| 208 | + | } | |
| 209 | + | require.Empty(t, h.daemon.notices.history()) | |
| 210 | + | requireNoInvalidation(t, invalidations) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) { | |
| 214 | + | tests := []struct { | |
| 215 | + | name string | |
| 216 | + | size domain.Size | |
| 217 | + | tree *layout.Tree | |
| 218 | + | pane layout.PaneID | |
| 219 | + | dir layout.Direction | |
| 220 | + | cause error | |
| 221 | + | wantMsg string | |
| 222 | + | }{ | |
| 223 | + | { | |
| 224 | + | name: "unsupported layout", | |
| 225 | + | size: domain.Size{Cols: 80, Rows: 22}, | |
| 226 | + | tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{ | |
| 227 | + | layout.NewLeaf("pane-1"), | |
| 228 | + | {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}}, | |
| 229 | + | }}, Focus: "pane-1"}, | |
| 230 | + | pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout, | |
| 231 | + | wantMsg: "pane rearrangement requires a column layout", | |
| 232 | + | }, | |
| 233 | + | { | |
| 234 | + | name: "too small", | |
| 235 | + | size: domain.Size{Cols: 40, Rows: 5}, | |
| 236 | + | tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{ | |
| 237 | + | layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), | |
| 238 | + | }}, Focus: "pane-1"}, | |
| 239 | + | pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall, | |
| 240 | + | wantMsg: "not enough space to rearrange pane", | |
| 241 | + | }, | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | for _, tt := range tests { | |
| 245 | + | t.Run(tt.name, func(t *testing.T) { | |
| 246 | + | h := newPaneRearrangeHarness(t, tt.size, tt.tree) | |
| 247 | + | before := h.snapshot() | |
| 248 | + | ||
| 249 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
| 250 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir, | |
| 251 | + | }) | |
| 252 | + | ||
| 253 | + | require.ErrorIs(t, err, tt.cause) | |
| 254 | + | var userErr *domain.UserError | |
| 255 | + | require.ErrorAs(t, err, &userErr) | |
| 256 | + | require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code) | |
| 257 | + | require.Equal(t, domain.NoticeWarn, userErr.Severity) | |
| 258 | + | require.Equal(t, tt.wantMsg, userErr.Msg) | |
| 259 | + | require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic") | |
| 260 | + | require.Zero(t, h.totalResizes()) | |
| 261 | + | h.factory.AssertNotCalled(t, "Open") | |
| 262 | + | for _, tracked := range h.ptys { | |
| 263 | + | require.Zero(t, tracked.closeCount()) | |
| 264 | + | } | |
| 265 | + | }) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) { | |
| 270 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 271 | + | ac := &attachedClient{} | |
| 272 | + | ac.setSession(h.session) | |
| 273 | + | h.session.mu.Lock() | |
| 274 | + | h.session.client = ac | |
| 275 | + | h.session.mu.Unlock() | |
| 276 | + | invalidations := make(chan renderInvalidation, 2) | |
| 277 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| 278 | + | rc.attach(ac) | |
| 279 | + | h.session.installRenderCoordinator(rc) | |
| 280 | + | ||
| 281 | + | err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{ | |
| 282 | + | kind: daemonActionConsumeOrExpelPane, direction: layout.Right, | |
| 283 | + | }) | |
| 284 | + | ||
| 285 | + | require.NoError(t, err) | |
| 286 | + | h.tab.mu.Lock() | |
| 287 | + | require.Equal(t, uint64(1), h.tab.layoutGeneration) | |
| 288 | + | h.tab.mu.Unlock() | |
| 289 | + | h.session.snapshotMu.Lock() | |
| 290 | + | require.Equal(t, uint64(1), h.session.snapshotGeneration) | |
| 291 | + | h.session.snapshotMu.Unlock() | |
| 292 | + | require.NotZero(t, h.totalResizes()) | |
| 293 | + | awaitInvalidation(t, invalidations) | |
| 294 | + | requireNoInvalidation(t, invalidations) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) { | |
| 298 | + | genuine := errors.New("genuine rearrange failure") | |
| 299 | + | directions := []struct { | |
| 300 | + | name string | |
| 301 | + | direction layout.Direction | |
| 302 | + | keyAction keys.Action | |
| 303 | + | paletteRun func(paletteExec) error | |
| 304 | + | controlRun func(controlExec) error | |
| 305 | + | }{ | |
| 306 | + | { | |
| 307 | + | name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft, | |
| 308 | + | paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, | |
| 309 | + | controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, | |
| 310 | + | }, | |
| 311 | + | { | |
| 312 | + | name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight, | |
| 313 | + | paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, | |
| 314 | + | controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, | |
| 315 | + | }, | |
| 316 | + | } | |
| 317 | + | outcomes := []struct { | |
| 318 | + | name string | |
| 319 | + | err error | |
| 320 | + | wantErr error | |
| 321 | + | wantNotice bool | |
| 322 | + | }{ | |
| 323 | + | {name: "silent no-change", err: errDaemonActionNoChange}, | |
| 324 | + | {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true}, | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | for _, direction := range directions { | |
| 328 | + | t.Run(direction.name, func(t *testing.T) { | |
| 329 | + | for _, outcome := range outcomes { | |
| 330 | + | t.Run(outcome.name, func(t *testing.T) { | |
| 331 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 332 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 333 | + | ac := &attachedClient{} | |
| 334 | + | ac.setSession(sess) | |
| 335 | + | target := resolveDaemonActionTarget(sess) | |
| 336 | + | paletteSpy := &actionRunnerSpy{err: outcome.err} | |
| 337 | + | controlSpy := &actionRunnerSpy{err: outcome.err} | |
| 338 | + | keySpy := &actionRunnerSpy{err: outcome.err} | |
| 339 | + | ||
| 340 | + | paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy}) | |
| 341 | + | controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy}) | |
| 342 | + | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction) | |
| 343 | + | ||
| 344 | + | if outcome.wantErr == nil { | |
| 345 | + | require.NoError(t, paletteErr) | |
| 346 | + | require.NoError(t, controlErr) | |
| 347 | + | } else { | |
| 348 | + | require.ErrorIs(t, paletteErr, outcome.wantErr) | |
| 349 | + | require.ErrorIs(t, controlErr, outcome.wantErr) | |
| 350 | + | } | |
| 351 | + | if outcome.wantNotice { | |
| 352 | + | require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error") | |
| 353 | + | } else { | |
| 354 | + | require.Empty(t, d.notices.history(), "no-change is silent in the key adapter") | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | for adapter, spy := range map[string]*actionRunnerSpy{ | |
| 358 | + | "palette": paletteSpy, | |
| 359 | + | "control": controlSpy, | |
| 360 | + | "key": keySpy, | |
| 361 | + | } { | |
| 362 | + | require.Len(t, spy.requests, 1, "%s adapter request count", adapter) | |
| 363 | + | request := spy.requests[0] | |
| 364 | + | require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter) | |
| 365 | + | require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter) | |
| 366 | + | require.Same(t, target.session, request.target.session, "%s adapter target session", adapter) | |
| 367 | + | require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter) | |
| 368 | + | require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter) | |
| 369 | + | } | |
| 370 | + | }) | |
| 371 | + | } | |
| 372 | + | }) | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) { | |
| 377 | + | tests := []struct { | |
| 378 | + | name string | |
| 379 | + | invoke func(*paneRearrangeHarness, *attachedClient) error | |
| 380 | + | wantCleanupInvalidation bool | |
| 381 | + | }{ | |
| 382 | + | { | |
| 383 | + | name: "palette cleans up its closed overlay", | |
| 384 | + | invoke: func(h *paneRearrangeHarness, ac *attachedClient) error { | |
| 385 | + | return (paletteExec{d: h.daemon, sess: h.session, ac: ac, redrawClosedPalette: true}).ConsumeOrExpelPaneLeft() | |
| 386 | + | }, | |
| 387 | + | wantCleanupInvalidation: true, | |
| 388 | + | }, | |
| 389 | + | { | |
| 390 | + | name: "control remains silent", | |
| 391 | + | invoke: func(h *paneRearrangeHarness, _ *attachedClient) error { | |
| 392 | + | return (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).ConsumeOrExpelPaneLeft() | |
| 393 | + | }, | |
| 394 | + | }, | |
| 395 | + | { | |
| 396 | + | name: "key remains silent", | |
| 397 | + | invoke: func(h *paneRearrangeHarness, ac *attachedClient) error { | |
| 398 | + | daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft) | |
| 399 | + | return nil | |
| 400 | + | }, | |
| 401 | + | }, | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | for _, tt := range tests { | |
| 405 | + | t.Run(tt.name, func(t *testing.T) { | |
| 406 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 407 | + | ac := &attachedClient{} | |
| 408 | + | ac.setSession(h.session) | |
| 409 | + | h.session.mu.Lock() | |
| 410 | + | h.session.client = ac | |
| 411 | + | h.session.mu.Unlock() | |
| 412 | + | invalidations := make(chan renderInvalidation, 2) | |
| 413 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| 414 | + | rc.attach(ac) | |
| 415 | + | h.session.installRenderCoordinator(rc) | |
| 416 | + | before := h.snapshot() | |
| 417 | + | ||
| 418 | + | require.NoError(t, tt.invoke(h, ac)) | |
| 419 | + | ||
| 420 | + | require.Equal(t, before, h.snapshot(), "adapter no-change must not publish layout state") | |
| 421 | + | require.Empty(t, h.daemon.notices.history()) | |
| 422 | + | if tt.wantCleanupInvalidation { | |
| 423 | + | invalidation := awaitInvalidation(t, invalidations) | |
| 424 | + | require.Equal(t, "palette.go", invalidation.producer) | |
| 425 | + | require.True(t, invalidation.reset) | |
| 426 | + | } | |
| 427 | + | requireNoInvalidation(t, invalidations) | |
| 428 | + | }) | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | ||
| 432 | + | type paneRearrangeHarness struct { | |
| 433 | + | daemon *Daemon | |
| 434 | + | session *session | |
| 435 | + | tab *tab | |
| 436 | + | panes map[layout.PaneID]*pane | |
| 437 | + | ptys map[layout.PaneID]*paneRearrangePTY | |
| 438 | + | factory *portsmocks.MockPTYFactory | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | type paneRearrangeSnapshot struct { | |
| 442 | + | tree *layout.Tree | |
| 443 | + | layoutGeneration uint64 | |
| 444 | + | snapshotGeneration uint64 | |
| 445 | + | snapshotDirty bool | |
| 446 | + | panes map[layout.PaneID]*pane | |
| 447 | + | rects map[layout.PaneID]domain.Rect | |
| 448 | + | screens map[layout.PaneID]domain.Size | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness { | |
| 452 | + | t.Helper() | |
| 453 | + | factory := portsmocks.NewMockPTYFactory(t) | |
| 454 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 455 | + | ids := layout.LeafIDs(tree.Root) | |
| 456 | + | require.NotEmpty(t, ids) | |
| 457 | + | ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids)) | |
| 458 | + | panes := make(map[layout.PaneID]*pane, len(ids)) | |
| 459 | + | for _, id := range ids { | |
| 460 | + | ptys[id] = &paneRearrangePTY{} | |
| 461 | + | } | |
| 462 | + | tb := newTab(ptys[ids[0]], size) | |
| 463 | + | first := tb.panes["pane-1"] | |
| 464 | + | delete(tb.panes, "pane-1") | |
| 465 | + | first.id = ids[0] | |
| 466 | + | panes[ids[0]] = first | |
| 467 | + | tb.panes[ids[0]] = first | |
| 468 | + | for _, id := range ids[1:] { | |
| 469 | + | p := newPane(id, ptys[id], size) | |
| 470 | + | panes[id] = p | |
| 471 | + | tb.panes[id] = p | |
| 472 | + | } | |
| 473 | + | tb.tree = tree.Clone() | |
| 474 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | |
| 475 | + | require.True(t, ok, "test fixture layout must solve") | |
| 476 | + | for _, placement := range placements { | |
| 477 | + | p := tb.panes[placement.ID] | |
| 478 | + | p.rect = placement.Content | |
| 479 | + | p.screen.Resize(placement.Content.Width, placement.Content.Height) | |
| 480 | + | } | |
| 481 | + | tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx) | |
| 482 | + | for _, p := range tb.panes { | |
| 483 | + | p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx) | |
| 484 | + | } | |
| 485 | + | sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}} | |
| 486 | + | sess.snapEligible.Store(true) | |
| 487 | + | d.sessions[sess.id] = sess | |
| 488 | + | return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory} | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) { | |
| 492 | + | t.Helper() | |
| 493 | + | ctx, cancel := context.WithCancel(parent) | |
| 494 | + | t.Cleanup(cancel) | |
| 495 | + | return ctx, cancel | |
| 496 | + | } | |
| 497 | + | ||
| 498 | + | func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget { | |
| 499 | + | return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]} | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | func (h *paneRearrangeHarness) totalResizes() int { | |
| 503 | + | total := 0 | |
| 504 | + | for _, tracked := range h.ptys { | |
| 505 | + | total += tracked.resizeCount() | |
| 506 | + | } | |
| 507 | + | return total | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot { | |
| 511 | + | h.tab.mu.Lock() | |
| 512 | + | snapshot := paneRearrangeSnapshot{ | |
| 513 | + | tree: h.tab.tree.Clone(), | |
| 514 | + | layoutGeneration: h.tab.layoutGeneration, | |
| 515 | + | panes: make(map[layout.PaneID]*pane, len(h.tab.panes)), | |
| 516 | + | rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)), | |
| 517 | + | screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)), | |
| 518 | + | } | |
| 519 | + | for id, p := range h.tab.panes { | |
| 520 | + | snapshot.panes[id] = p | |
| 521 | + | p.mu.Lock() | |
| 522 | + | snapshot.rects[id] = p.rect | |
| 523 | + | snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height} | |
| 524 | + | p.mu.Unlock() | |
| 525 | + | } | |
| 526 | + | h.tab.mu.Unlock() | |
| 527 | + | h.session.snapshotMu.Lock() | |
| 528 | + | snapshot.snapshotGeneration = h.session.snapshotGeneration | |
| 529 | + | snapshot.snapshotDirty = h.session.snapDirty.Load() | |
| 530 | + | h.session.snapshotMu.Unlock() | |
| 531 | + | return snapshot | |
| 532 | + | } | |
| 533 | + | ||
| 534 | + | func (h *paneRearrangeHarness) stateLocksAvailable() bool { | |
| 535 | + | done := make(chan struct{}) | |
| 536 | + | go func() { | |
| 537 | + | h.daemon.mu.Lock() | |
| 538 | + | h.session.mu.Lock() | |
| 539 | + | h.tab.mu.Lock() | |
| 540 | + | for _, p := range h.panes { | |
| 541 | + | p.mu.Lock() | |
| 542 | + | } | |
| 543 | + | for _, p := range h.panes { | |
| 544 | + | p.mu.Unlock() | |
| 545 | + | } | |
| 546 | + | h.tab.mu.Unlock() | |
| 547 | + | h.session.mu.Unlock() | |
| 548 | + | h.daemon.mu.Unlock() | |
| 549 | + | close(done) | |
| 550 | + | }() | |
| 551 | + | select { | |
| 552 | + | case <-done: | |
| 553 | + | return true | |
| 554 | + | case <-time.After(250 * time.Millisecond): | |
| 555 | + | return false | |
| 556 | + | } | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | func threeColumnTree() *layout.Tree { | |
| 560 | + | return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |
| 561 | + | layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"), | |
| 562 | + | }}, Focus: "pane-1"} | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | type paneRearrangePTY struct { | |
| 566 | + | mu sync.Mutex | |
| 567 | + | resizes []domain.Size | |
| 568 | + | closes int | |
| 569 | + | onResize func() | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF } | |
| 573 | + | func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 574 | + | func (p *paneRearrangePTY) Resize(size domain.Size) error { | |
| 575 | + | p.mu.Lock() | |
| 576 | + | p.resizes = append(p.resizes, size) | |
| 577 | + | hook := p.onResize | |
| 578 | + | p.mu.Unlock() | |
| 579 | + | if hook != nil { | |
| 580 | + | hook() | |
| 581 | + | } | |
| 582 | + | return nil | |
| 583 | + | } | |
| 584 | + | func (p *paneRearrangePTY) Close() error { | |
| 585 | + | p.mu.Lock() | |
| 586 | + | p.closes++ | |
| 587 | + | p.mu.Unlock() | |
| 588 | + | return nil | |
| 589 | + | } | |
| 590 | + | func (*paneRearrangePTY) Pid() int { return 0 } | |
| 591 | + | func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil } | |
| 592 | + | ||
| 593 | + | func (p *paneRearrangePTY) resizeCount() int { | |
| 594 | + | p.mu.Lock() | |
| 595 | + | defer p.mu.Unlock() | |
| 596 | + | return len(p.resizes) | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | func (p *paneRearrangePTY) closeCount() int { | |
| 600 | + | p.mu.Lock() | |
| 601 | + | defer p.mu.Unlock() | |
| 602 | + | return p.closes | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | var _ ports.PTY = (*paneRearrangePTY)(nil) | |
| @@ -0,0 +1,605 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 16 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 17 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) { | |||
| 21 | + | tests := []struct { | |||
| 22 | + | name string | |||
| 23 | + | mutate func(*layout.Tree, domain.Rect) (bool, error) | |||
| 24 | + | wantChanged bool | |||
| 25 | + | wantGeneration uint64 | |||
| 26 | + | wantSnapshotGen uint64 | |||
| 27 | + | wantResizes bool | |||
| 28 | + | }{ | |||
| 29 | + | { | |||
| 30 | + | name: "changed candidate publishes once", | |||
| 31 | + | mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) { | |||
| 32 | + | return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area) | |||
| 33 | + | }, | |||
| 34 | + | wantChanged: true, | |||
| 35 | + | wantGeneration: 1, | |||
| 36 | + | wantSnapshotGen: 1, | |||
| 37 | + | wantResizes: true, | |||
| 38 | + | }, | |||
| 39 | + | { | |||
| 40 | + | name: "unchanged candidate is discarded", | |||
| 41 | + | mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) { | |||
| 42 | + | candidate.Focus = "pane-2" | |||
| 43 | + | return false, nil | |||
| 44 | + | }, | |||
| 45 | + | }, | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | for _, tt := range tests { | |||
| 49 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 50 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 51 | + | before := h.tab.tree.Clone() | |||
| 52 | + | ||||
| 53 | + | changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate) | |||
| 54 | + | ||||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | require.Equal(t, tt.wantChanged, changed) | |||
| 57 | + | h.tab.mu.Lock() | |||
| 58 | + | require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration) | |||
| 59 | + | if tt.wantChanged { | |||
| 60 | + | require.NotEqual(t, before, h.tab.tree) | |||
| 61 | + | } else { | |||
| 62 | + | require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate") | |||
| 63 | + | } | |||
| 64 | + | h.tab.mu.Unlock() | |||
| 65 | + | h.session.snapshotMu.Lock() | |||
| 66 | + | require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration) | |||
| 67 | + | h.session.snapshotMu.Unlock() | |||
| 68 | + | if tt.wantResizes { | |||
| 69 | + | require.NotZero(t, h.totalResizes()) | |||
| 70 | + | } else { | |||
| 71 | + | require.Zero(t, h.totalResizes()) | |||
| 72 | + | } | |||
| 73 | + | }) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) { | |||
| 78 | + | tests := []struct { | |||
| 79 | + | name string | |||
| 80 | + | stale func(*paneRearrangeHarness) daemonActionTarget | |||
| 81 | + | }{ | |||
| 82 | + | { | |||
| 83 | + | name: "session", | |||
| 84 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |||
| 85 | + | target := h.target("pane-1") | |||
| 86 | + | h.daemon.sessions[h.session.id] = &session{id: h.session.id} | |||
| 87 | + | return target | |||
| 88 | + | }, | |||
| 89 | + | }, | |||
| 90 | + | { | |||
| 91 | + | name: "tab", | |||
| 92 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |||
| 93 | + | target := h.target("pane-1") | |||
| 94 | + | h.session.tabs = []*tab{newTab(nil, h.tab.size)} | |||
| 95 | + | return target | |||
| 96 | + | }, | |||
| 97 | + | }, | |||
| 98 | + | { | |||
| 99 | + | name: "pane identity", | |||
| 100 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |||
| 101 | + | target := h.target("pane-1") | |||
| 102 | + | h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size) | |||
| 103 | + | return target | |||
| 104 | + | }, | |||
| 105 | + | }, | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | for _, tt := range tests { | |||
| 109 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 110 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 111 | + | before := h.tab.tree.Clone() | |||
| 112 | + | called := false | |||
| 113 | + | ||||
| 114 | + | changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) { | |||
| 115 | + | called = true | |||
| 116 | + | return true, nil | |||
| 117 | + | }) | |||
| 118 | + | ||||
| 119 | + | require.False(t, changed) | |||
| 120 | + | require.ErrorIs(t, err, layout.ErrNotFound) | |||
| 121 | + | require.False(t, called, "stale identity must be rejected before invoking the mutation") | |||
| 122 | + | require.Equal(t, before, h.tab.tree) | |||
| 123 | + | require.Zero(t, h.tab.layoutGeneration) | |||
| 124 | + | require.Zero(t, h.totalResizes()) | |||
| 125 | + | }) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) { | |||
| 130 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 131 | + | locksAvailable := make(chan bool, 8) | |||
| 132 | + | for _, tracked := range h.ptys { | |||
| 133 | + | tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() } | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | done := make(chan error, 1) | |||
| 137 | + | go func() { | |||
| 138 | + | done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 139 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right, | |||
| 140 | + | }) | |||
| 141 | + | }() | |||
| 142 | + | ||||
| 143 | + | select { | |||
| 144 | + | case err := <-done: | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | case <-time.After(2 * time.Second): | |||
| 147 | + | t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks") | |||
| 148 | + | } | |||
| 149 | + | require.NotZero(t, h.totalResizes()) | |||
| 150 | + | close(locksAvailable) | |||
| 151 | + | for available := range locksAvailable { | |||
| 152 | + | require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held") | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) { | |||
| 157 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 158 | + | h.tab.mu.Lock() | |||
| 159 | + | h.tab.tree.Focus = "pane-3" | |||
| 160 | + | before := make(map[layout.PaneID]*pane, len(h.tab.panes)) | |||
| 161 | + | for id, p := range h.tab.panes { | |||
| 162 | + | before[id] = p | |||
| 163 | + | } | |||
| 164 | + | h.tab.mu.Unlock() | |||
| 165 | + | ||||
| 166 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 167 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right, | |||
| 168 | + | }) | |||
| 169 | + | ||||
| 170 | + | require.NoError(t, err) | |||
| 171 | + | h.tab.mu.Lock() | |||
| 172 | + | require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused") | |||
| 173 | + | require.Len(t, h.tab.panes, len(before)) | |||
| 174 | + | for id, p := range before { | |||
| 175 | + | require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id) | |||
| 176 | + | } | |||
| 177 | + | h.tab.mu.Unlock() | |||
| 178 | + | h.factory.AssertNotCalled(t, "Open") | |||
| 179 | + | for _, tracked := range h.ptys { | |||
| 180 | + | require.Zero(t, tracked.closeCount()) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) { | |||
| 185 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 186 | + | ac := &attachedClient{} | |||
| 187 | + | ac.setSession(h.session) | |||
| 188 | + | h.session.mu.Lock() | |||
| 189 | + | h.session.client = ac | |||
| 190 | + | h.session.mu.Unlock() | |||
| 191 | + | invalidations := make(chan renderInvalidation, 2) | |||
| 192 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 193 | + | rc.attach(ac) | |||
| 194 | + | h.session.installRenderCoordinator(rc) | |||
| 195 | + | before := h.snapshot() | |||
| 196 | + | request := daemonActionRequest{ | |||
| 197 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left, | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | err := (daemonActions{d: h.daemon}).Run(request) | |||
| 201 | + | require.ErrorIs(t, err, errDaemonActionNoChange, "the daemon action must retain the package no-change sentinel") | |||
| 202 | + | ||||
| 203 | + | require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size") | |||
| 204 | + | require.Zero(t, h.totalResizes()) | |||
| 205 | + | h.factory.AssertNotCalled(t, "Open") | |||
| 206 | + | for _, tracked := range h.ptys { | |||
| 207 | + | require.Zero(t, tracked.closeCount()) | |||
| 208 | + | } | |||
| 209 | + | require.Empty(t, h.daemon.notices.history()) | |||
| 210 | + | requireNoInvalidation(t, invalidations) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) { | |||
| 214 | + | tests := []struct { | |||
| 215 | + | name string | |||
| 216 | + | size domain.Size | |||
| 217 | + | tree *layout.Tree | |||
| 218 | + | pane layout.PaneID | |||
| 219 | + | dir layout.Direction | |||
| 220 | + | cause error | |||
| 221 | + | wantMsg string | |||
| 222 | + | }{ | |||
| 223 | + | { | |||
| 224 | + | name: "unsupported layout", | |||
| 225 | + | size: domain.Size{Cols: 80, Rows: 22}, | |||
| 226 | + | tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{ | |||
| 227 | + | layout.NewLeaf("pane-1"), | |||
| 228 | + | {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}}, | |||
| 229 | + | }}, Focus: "pane-1"}, | |||
| 230 | + | pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout, | |||
| 231 | + | wantMsg: "pane rearrangement requires a column layout", | |||
| 232 | + | }, | |||
| 233 | + | { | |||
| 234 | + | name: "too small", | |||
| 235 | + | size: domain.Size{Cols: 40, Rows: 5}, | |||
| 236 | + | tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{ | |||
| 237 | + | layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), | |||
| 238 | + | }}, Focus: "pane-1"}, | |||
| 239 | + | pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall, | |||
| 240 | + | wantMsg: "not enough space to rearrange pane", | |||
| 241 | + | }, | |||
| 242 | + | } | |||
| 243 | + | ||||
| 244 | + | for _, tt := range tests { | |||
| 245 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 246 | + | h := newPaneRearrangeHarness(t, tt.size, tt.tree) | |||
| 247 | + | before := h.snapshot() | |||
| 248 | + | ||||
| 249 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 250 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir, | |||
| 251 | + | }) | |||
| 252 | + | ||||
| 253 | + | require.ErrorIs(t, err, tt.cause) | |||
| 254 | + | var userErr *domain.UserError | |||
| 255 | + | require.ErrorAs(t, err, &userErr) | |||
| 256 | + | require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code) | |||
| 257 | + | require.Equal(t, domain.NoticeWarn, userErr.Severity) | |||
| 258 | + | require.Equal(t, tt.wantMsg, userErr.Msg) | |||
| 259 | + | require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic") | |||
| 260 | + | require.Zero(t, h.totalResizes()) | |||
| 261 | + | h.factory.AssertNotCalled(t, "Open") | |||
| 262 | + | for _, tracked := range h.ptys { | |||
| 263 | + | require.Zero(t, tracked.closeCount()) | |||
| 264 | + | } | |||
| 265 | + | }) | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) { | |||
| 270 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 271 | + | ac := &attachedClient{} | |||
| 272 | + | ac.setSession(h.session) | |||
| 273 | + | h.session.mu.Lock() | |||
| 274 | + | h.session.client = ac | |||
| 275 | + | h.session.mu.Unlock() | |||
| 276 | + | invalidations := make(chan renderInvalidation, 2) | |||
| 277 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 278 | + | rc.attach(ac) | |||
| 279 | + | h.session.installRenderCoordinator(rc) | |||
| 280 | + | ||||
| 281 | + | err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{ | |||
| 282 | + | kind: daemonActionConsumeOrExpelPane, direction: layout.Right, | |||
| 283 | + | }) | |||
| 284 | + | ||||
| 285 | + | require.NoError(t, err) | |||
| 286 | + | h.tab.mu.Lock() | |||
| 287 | + | require.Equal(t, uint64(1), h.tab.layoutGeneration) | |||
| 288 | + | h.tab.mu.Unlock() | |||
| 289 | + | h.session.snapshotMu.Lock() | |||
| 290 | + | require.Equal(t, uint64(1), h.session.snapshotGeneration) | |||
| 291 | + | h.session.snapshotMu.Unlock() | |||
| 292 | + | require.NotZero(t, h.totalResizes()) | |||
| 293 | + | awaitInvalidation(t, invalidations) | |||
| 294 | + | requireNoInvalidation(t, invalidations) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) { | |||
| 298 | + | genuine := errors.New("genuine rearrange failure") | |||
| 299 | + | directions := []struct { | |||
| 300 | + | name string | |||
| 301 | + | direction layout.Direction | |||
| 302 | + | keyAction keys.Action | |||
| 303 | + | paletteRun func(paletteExec) error | |||
| 304 | + | controlRun func(controlExec) error | |||
| 305 | + | }{ | |||
| 306 | + | { | |||
| 307 | + | name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft, | |||
| 308 | + | paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, | |||
| 309 | + | controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, | |||
| 310 | + | }, | |||
| 311 | + | { | |||
| 312 | + | name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight, | |||
| 313 | + | paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, | |||
| 314 | + | controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, | |||
| 315 | + | }, | |||
| 316 | + | } | |||
| 317 | + | outcomes := []struct { | |||
| 318 | + | name string | |||
| 319 | + | err error | |||
| 320 | + | wantErr error | |||
| 321 | + | wantNotice bool | |||
| 322 | + | }{ | |||
| 323 | + | {name: "silent no-change", err: errDaemonActionNoChange}, | |||
| 324 | + | {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true}, | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | for _, direction := range directions { | |||
| 328 | + | t.Run(direction.name, func(t *testing.T) { | |||
| 329 | + | for _, outcome := range outcomes { | |||
| 330 | + | t.Run(outcome.name, func(t *testing.T) { | |||
| 331 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 332 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 333 | + | ac := &attachedClient{} | |||
| 334 | + | ac.setSession(sess) | |||
| 335 | + | target := resolveDaemonActionTarget(sess) | |||
| 336 | + | paletteSpy := &actionRunnerSpy{err: outcome.err} | |||
| 337 | + | controlSpy := &actionRunnerSpy{err: outcome.err} | |||
| 338 | + | keySpy := &actionRunnerSpy{err: outcome.err} | |||
| 339 | + | ||||
| 340 | + | paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy}) | |||
| 341 | + | controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy}) | |||
| 342 | + | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction) | |||
| 343 | + | ||||
| 344 | + | if outcome.wantErr == nil { | |||
| 345 | + | require.NoError(t, paletteErr) | |||
| 346 | + | require.NoError(t, controlErr) | |||
| 347 | + | } else { | |||
| 348 | + | require.ErrorIs(t, paletteErr, outcome.wantErr) | |||
| 349 | + | require.ErrorIs(t, controlErr, outcome.wantErr) | |||
| 350 | + | } | |||
| 351 | + | if outcome.wantNotice { | |||
| 352 | + | require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error") | |||
| 353 | + | } else { | |||
| 354 | + | require.Empty(t, d.notices.history(), "no-change is silent in the key adapter") | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | for adapter, spy := range map[string]*actionRunnerSpy{ | |||
| 358 | + | "palette": paletteSpy, | |||
| 359 | + | "control": controlSpy, | |||
| 360 | + | "key": keySpy, | |||
| 361 | + | } { | |||
| 362 | + | require.Len(t, spy.requests, 1, "%s adapter request count", adapter) | |||
| 363 | + | request := spy.requests[0] | |||
| 364 | + | require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter) | |||
| 365 | + | require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter) | |||
| 366 | + | require.Same(t, target.session, request.target.session, "%s adapter target session", adapter) | |||
| 367 | + | require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter) | |||
| 368 | + | require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter) | |||
| 369 | + | } | |||
| 370 | + | }) | |||
| 371 | + | } | |||
| 372 | + | }) | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) { | |||
| 377 | + | tests := []struct { | |||
| 378 | + | name string | |||
| 379 | + | invoke func(*paneRearrangeHarness, *attachedClient) error | |||
| 380 | + | wantCleanupInvalidation bool | |||
| 381 | + | }{ | |||
| 382 | + | { | |||
| 383 | + | name: "palette cleans up its closed overlay", | |||
| 384 | + | invoke: func(h *paneRearrangeHarness, ac *attachedClient) error { | |||
| 385 | + | return (paletteExec{d: h.daemon, sess: h.session, ac: ac, redrawClosedPalette: true}).ConsumeOrExpelPaneLeft() | |||
| 386 | + | }, | |||
| 387 | + | wantCleanupInvalidation: true, | |||
| 388 | + | }, | |||
| 389 | + | { | |||
| 390 | + | name: "control remains silent", | |||
| 391 | + | invoke: func(h *paneRearrangeHarness, _ *attachedClient) error { | |||
| 392 | + | return (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).ConsumeOrExpelPaneLeft() | |||
| 393 | + | }, | |||
| 394 | + | }, | |||
| 395 | + | { | |||
| 396 | + | name: "key remains silent", | |||
| 397 | + | invoke: func(h *paneRearrangeHarness, ac *attachedClient) error { | |||
| 398 | + | daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft) | |||
| 399 | + | return nil | |||
| 400 | + | }, | |||
| 401 | + | }, | |||
| 402 | + | } | |||
| 403 | + | ||||
| 404 | + | for _, tt := range tests { | |||
| 405 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 406 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 407 | + | ac := &attachedClient{} | |||
| 408 | + | ac.setSession(h.session) | |||
| 409 | + | h.session.mu.Lock() | |||
| 410 | + | h.session.client = ac | |||
| 411 | + | h.session.mu.Unlock() | |||
| 412 | + | invalidations := make(chan renderInvalidation, 2) | |||
| 413 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 414 | + | rc.attach(ac) | |||
| 415 | + | h.session.installRenderCoordinator(rc) | |||
| 416 | + | before := h.snapshot() | |||
| 417 | + | ||||
| 418 | + | require.NoError(t, tt.invoke(h, ac)) | |||
| 419 | + | ||||
| 420 | + | require.Equal(t, before, h.snapshot(), "adapter no-change must not publish layout state") | |||
| 421 | + | require.Empty(t, h.daemon.notices.history()) | |||
| 422 | + | if tt.wantCleanupInvalidation { | |||
| 423 | + | invalidation := awaitInvalidation(t, invalidations) | |||
| 424 | + | require.Equal(t, "palette.go", invalidation.producer) | |||
| 425 | + | require.True(t, invalidation.reset) | |||
| 426 | + | } | |||
| 427 | + | requireNoInvalidation(t, invalidations) | |||
| 428 | + | }) | |||
| 429 | + | } | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | type paneRearrangeHarness struct { | |||
| 433 | + | daemon *Daemon | |||
| 434 | + | session *session | |||
| 435 | + | tab *tab | |||
| 436 | + | panes map[layout.PaneID]*pane | |||
| 437 | + | ptys map[layout.PaneID]*paneRearrangePTY | |||
| 438 | + | factory *portsmocks.MockPTYFactory | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | type paneRearrangeSnapshot struct { | |||
| 442 | + | tree *layout.Tree | |||
| 443 | + | layoutGeneration uint64 | |||
| 444 | + | snapshotGeneration uint64 | |||
| 445 | + | snapshotDirty bool | |||
| 446 | + | panes map[layout.PaneID]*pane | |||
| 447 | + | rects map[layout.PaneID]domain.Rect | |||
| 448 | + | screens map[layout.PaneID]domain.Size | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness { | |||
| 452 | + | t.Helper() | |||
| 453 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 454 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 455 | + | ids := layout.LeafIDs(tree.Root) | |||
| 456 | + | require.NotEmpty(t, ids) | |||
| 457 | + | ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids)) | |||
| 458 | + | panes := make(map[layout.PaneID]*pane, len(ids)) | |||
| 459 | + | for _, id := range ids { | |||
| 460 | + | ptys[id] = &paneRearrangePTY{} | |||
| 461 | + | } | |||
| 462 | + | tb := newTab(ptys[ids[0]], size) | |||
| 463 | + | first := tb.panes["pane-1"] | |||
| 464 | + | delete(tb.panes, "pane-1") | |||
| 465 | + | first.id = ids[0] | |||
| 466 | + | panes[ids[0]] = first | |||
| 467 | + | tb.panes[ids[0]] = first | |||
| 468 | + | for _, id := range ids[1:] { | |||
| 469 | + | p := newPane(id, ptys[id], size) | |||
| 470 | + | panes[id] = p | |||
| 471 | + | tb.panes[id] = p | |||
| 472 | + | } | |||
| 473 | + | tb.tree = tree.Clone() | |||
| 474 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | |||
| 475 | + | require.True(t, ok, "test fixture layout must solve") | |||
| 476 | + | for _, placement := range placements { | |||
| 477 | + | p := tb.panes[placement.ID] | |||
| 478 | + | p.rect = placement.Content | |||
| 479 | + | p.screen.Resize(placement.Content.Width, placement.Content.Height) | |||
| 480 | + | } | |||
| 481 | + | tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx) | |||
| 482 | + | for _, p := range tb.panes { | |||
| 483 | + | p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx) | |||
| 484 | + | } | |||
| 485 | + | sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}} | |||
| 486 | + | sess.snapEligible.Store(true) | |||
| 487 | + | d.sessions[sess.id] = sess | |||
| 488 | + | return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory} | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) { | |||
| 492 | + | t.Helper() | |||
| 493 | + | ctx, cancel := context.WithCancel(parent) | |||
| 494 | + | t.Cleanup(cancel) | |||
| 495 | + | return ctx, cancel | |||
| 496 | + | } | |||
| 497 | + | ||||
| 498 | + | func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget { | |||
| 499 | + | return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]} | |||
| 500 | + | } | |||
| 501 | + | ||||
| 502 | + | func (h *paneRearrangeHarness) totalResizes() int { | |||
| 503 | + | total := 0 | |||
| 504 | + | for _, tracked := range h.ptys { | |||
| 505 | + | total += tracked.resizeCount() | |||
| 506 | + | } | |||
| 507 | + | return total | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot { | |||
| 511 | + | h.tab.mu.Lock() | |||
| 512 | + | snapshot := paneRearrangeSnapshot{ | |||
| 513 | + | tree: h.tab.tree.Clone(), | |||
| 514 | + | layoutGeneration: h.tab.layoutGeneration, | |||
| 515 | + | panes: make(map[layout.PaneID]*pane, len(h.tab.panes)), | |||
| 516 | + | rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)), | |||
| 517 | + | screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)), | |||
| 518 | + | } | |||
| 519 | + | for id, p := range h.tab.panes { | |||
| 520 | + | snapshot.panes[id] = p | |||
| 521 | + | p.mu.Lock() | |||
| 522 | + | snapshot.rects[id] = p.rect | |||
| 523 | + | snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height} | |||
| 524 | + | p.mu.Unlock() | |||
| 525 | + | } | |||
| 526 | + | h.tab.mu.Unlock() | |||
| 527 | + | h.session.snapshotMu.Lock() | |||
| 528 | + | snapshot.snapshotGeneration = h.session.snapshotGeneration | |||
| 529 | + | snapshot.snapshotDirty = h.session.snapDirty.Load() | |||
| 530 | + | h.session.snapshotMu.Unlock() | |||
| 531 | + | return snapshot | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | func (h *paneRearrangeHarness) stateLocksAvailable() bool { | |||
| 535 | + | done := make(chan struct{}) | |||
| 536 | + | go func() { | |||
| 537 | + | h.daemon.mu.Lock() | |||
| 538 | + | h.session.mu.Lock() | |||
| 539 | + | h.tab.mu.Lock() | |||
| 540 | + | for _, p := range h.panes { | |||
| 541 | + | p.mu.Lock() | |||
| 542 | + | } | |||
| 543 | + | for _, p := range h.panes { | |||
| 544 | + | p.mu.Unlock() | |||
| 545 | + | } | |||
| 546 | + | h.tab.mu.Unlock() | |||
| 547 | + | h.session.mu.Unlock() | |||
| 548 | + | h.daemon.mu.Unlock() | |||
| 549 | + | close(done) | |||
| 550 | + | }() | |||
| 551 | + | select { | |||
| 552 | + | case <-done: | |||
| 553 | + | return true | |||
| 554 | + | case <-time.After(250 * time.Millisecond): | |||
| 555 | + | return false | |||
| 556 | + | } | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | func threeColumnTree() *layout.Tree { | |||
| 560 | + | return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 561 | + | layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"), | |||
| 562 | + | }}, Focus: "pane-1"} | |||
| 563 | + | } | |||
| 564 | + | ||||
| 565 | + | type paneRearrangePTY struct { | |||
| 566 | + | mu sync.Mutex | |||
| 567 | + | resizes []domain.Size | |||
| 568 | + | closes int | |||
| 569 | + | onResize func() | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF } | |||
| 573 | + | func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 574 | + | func (p *paneRearrangePTY) Resize(size domain.Size) error { | |||
| 575 | + | p.mu.Lock() | |||
| 576 | + | p.resizes = append(p.resizes, size) | |||
| 577 | + | hook := p.onResize | |||
| 578 | + | p.mu.Unlock() | |||
| 579 | + | if hook != nil { | |||
| 580 | + | hook() | |||
| 581 | + | } | |||
| 582 | + | return nil | |||
| 583 | + | } | |||
| 584 | + | func (p *paneRearrangePTY) Close() error { | |||
| 585 | + | p.mu.Lock() | |||
| 586 | + | p.closes++ | |||
| 587 | + | p.mu.Unlock() | |||
| 588 | + | return nil | |||
| 589 | + | } | |||
| 590 | + | func (*paneRearrangePTY) Pid() int { return 0 } | |||
| 591 | + | func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil } | |||
| 592 | + | ||||
| 593 | + | func (p *paneRearrangePTY) resizeCount() int { | |||
| 594 | + | p.mu.Lock() | |||
| 595 | + | defer p.mu.Unlock() | |||
| 596 | + | return len(p.resizes) | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | func (p *paneRearrangePTY) closeCount() int { | |||
| 600 | + | p.mu.Lock() | |||
| 601 | + | defer p.mu.Unlock() | |||
| 602 | + | return p.closes | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | var _ ports.PTY = (*paneRearrangePTY)(nil) | |||
M internal/usecase/daemon/performance_benchmark_test.go +4 -6
| @@ -64,9 +64,9 @@ func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t * | |||
| 64 | 64 | func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) { | |
| 65 | 65 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 66 | 66 | ||
| 67 | - | markSnapshotDirty(fixture.sess) | |
| 68 | - | require.True(t, fixture.d.scheduleSnapshot(fixture.sess)) | |
| 69 | - | awaitSnapshotClean(t, fixture.sess) | |
| 67 | + | // This contract concerns capture and publication bytes, not queue latency. | |
| 68 | + | // Publish synchronously so the 10k-row setup cannot race the test deadline. | |
| 69 | + | publishBenchmarkSnapshot(t, fixture, 1) | |
| 70 | 70 | ||
| 71 | 71 | var copies atomic.Uint64 | |
| 72 | 72 | fixture.sess.snapshotMu.Lock() | |
| @@ -83,9 +83,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||
| 83 | 83 | for i, r := range "changed" { | |
| 84 | 84 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | |
| 85 | 85 | } | |
| 86 | - | markSnapshotDirty(fixture.sess) | |
| 87 | - | require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess)) | |
| 88 | - | awaitSnapshotClean(t, fixture.sess) | |
| 86 | + | publishBenchmarkSnapshot(t, fixture, 2) | |
| 89 | 87 | ||
| 90 | 88 | metrics := fixture.snapshots.metrics() | |
| 91 | 89 | require.Equal(t, uint64(1), metrics.writes) | |
| @@ -64,9 +64,9 @@ func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t * | |||||
| 64 | func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) { | 64 | func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) { | ||
| 65 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | 65 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | ||
| 66 | 66 | ||||
| 67 | - | markSnapshotDirty(fixture.sess) | 67 | + | // This contract concerns capture and publication bytes, not queue latency. |
| 68 | - | require.True(t, fixture.d.scheduleSnapshot(fixture.sess)) | 68 | + | // Publish synchronously so the 10k-row setup cannot race the test deadline. |
| 69 | - | awaitSnapshotClean(t, fixture.sess) | 69 | + | publishBenchmarkSnapshot(t, fixture, 1) |
| 70 | 70 | ||||
| 71 | var copies atomic.Uint64 | 71 | var copies atomic.Uint64 | ||
| 72 | fixture.sess.snapshotMu.Lock() | 72 | fixture.sess.snapshotMu.Lock() | ||
| @@ -83,9 +83,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||||
| 83 | for i, r := range "changed" { | 83 | for i, r := range "changed" { | ||
| 84 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | 84 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | ||
| 85 | } | 85 | } | ||
| 86 | - | markSnapshotDirty(fixture.sess) | 86 | + | publishBenchmarkSnapshot(t, fixture, 2) |
| 87 | - | require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess)) | |||
| 88 | - | awaitSnapshotClean(t, fixture.sess) | |||
| 89 | 87 | ||||
| 90 | metrics := fixture.snapshots.metrics() | 88 | metrics := fixture.snapshots.metrics() | ||
| 91 | require.Equal(t, uint64(1), metrics.writes) | 89 | require.Equal(t, uint64(1), metrics.writes) | ||
M internal/usecase/daemon/resize_mode.go +0 -48
| @@ -32,54 +32,6 @@ func (d *Daemon) equalizePanes(target daemonActionTarget) error { | |||
| 32 | 32 | }) | |
| 33 | 33 | } | |
| 34 | 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 | 35 | func resizeUserError(err error) error { | |
| 84 | 36 | switch { | |
| 85 | 37 | case errors.Is(err, layout.ErrNotInSplit): | |
| @@ -32,54 +32,6 @@ func (d *Daemon) equalizePanes(target daemonActionTarget) error { | |||||
| 32 | }) | 32 | }) | ||
| 33 | } | 33 | } | ||
| 34 | 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 { | 35 | func resizeUserError(err error) error { | ||
| 84 | switch { | 36 | switch { | ||
| 85 | case errors.Is(err, layout.ErrNotInSplit): | 37 | case errors.Is(err, layout.ErrNotInSplit): | ||
M internal/usecase/daemon/snapshot_restore_test.go +154 -10
| @@ -41,11 +41,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap | |||
| 41 | 41 | if r.publishErr != nil { | |
| 42 | 42 | return r.publishErr | |
| 43 | 43 | } | |
| 44 | - | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) | |
| 45 | - | for _, object := range p.Objects { | |
| 46 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 47 | - | } | |
| 48 | - | r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} | |
| 44 | + | r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p)) | |
| 49 | 45 | found := false | |
| 50 | 46 | for _, name := range r.names { | |
| 51 | 47 | found = found || name == p.Name | |
| @@ -183,11 +179,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u | |||
| 183 | 179 | manifest.Generation = generation | |
| 184 | 180 | encoded, err := snapcodec.MarshalManifest(manifest) | |
| 185 | 181 | require.NoError(t, err) | |
| 186 | - | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 187 | - | for _, object := range publication.Objects { | |
| 188 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 189 | - | } | |
| 190 | - | return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects} | |
| 182 | + | publication.Generation = generation | |
| 183 | + | publication.Manifest = encoded | |
| 184 | + | return cloneAcceptanceGeneration(snapshotGeneration(publication)) | |
| 191 | 185 | } | |
| 192 | 186 | ||
| 193 | 187 | func TestValidateRestoreSessionSnapshot(t *testing.T) { | |
| @@ -267,6 +261,156 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 267 | 261 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | |
| 268 | 262 | } | |
| 269 | 263 | ||
| 264 | + | type paneLayoutSnapshotFixture struct { | |
| 265 | + | snapshot snapcodec.Session | |
| 266 | + | tree *layout.Tree | |
| 267 | + | tabStableID string | |
| 268 | + | paneStableIDs map[layout.PaneID]string | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | type restoredPaneLayout struct { | |
| 272 | + | tree *layout.Tree | |
| 273 | + | tabStableID string | |
| 274 | + | paneStableIDs map[layout.PaneID]string | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |
| 278 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |
| 279 | + | require.Len(t, fixture.snapshot.Tabs, 1) | |
| 280 | + | decodedTab := fixture.snapshot.Tabs[0] | |
| 281 | + | ||
| 282 | + | require.Equal(t, fixture.tabStableID, decodedTab.StableID) | |
| 283 | + | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved") | |
| 284 | + | assertConsumedAndExpelledColumns(t, decodedTab.Tree) | |
| 285 | + | require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |
| 286 | + | require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID") | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |
| 290 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |
| 291 | + | restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{}) | |
| 292 | + | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | defer closeRestoredTabs(restoredTabs) | |
| 295 | + | require.Len(t, restoredTabs, 1) | |
| 296 | + | ||
| 297 | + | restored := captureRestoredPaneLayout(restoredTabs[0]) | |
| 298 | + | require.Equal(t, fixture.tabStableID, restored.tabStableID) | |
| 299 | + | assertConsumedAndExpelledColumns(t, restored.tree) | |
| 300 | + | require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree") | |
| 301 | + | require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { | |
| 305 | + | t.Helper() | |
| 306 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) | |
| 307 | + | h.session.createdAt = 77 | |
| 308 | + | h.session.incarnation = domain.IncarnationID{7} | |
| 309 | + | tabStableID := "tab-stable-rearranged" | |
| 310 | + | paneStableIDs := map[layout.PaneID]string{ | |
| 311 | + | "pane-1": "pane-stable-1", | |
| 312 | + | "pane-2": "pane-stable-2", | |
| 313 | + | "pane-3": "pane-stable-3", | |
| 314 | + | "pane-4": "pane-stable-4", | |
| 315 | + | "pane-5": "pane-stable-5", | |
| 316 | + | } | |
| 317 | + | setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs) | |
| 318 | + | applyPaneLayoutSnapshotOperations(t, h) | |
| 319 | + | ||
| 320 | + | h.tab.mu.Lock() | |
| 321 | + | wantTree := h.tab.tree.Clone() | |
| 322 | + | h.tab.mu.Unlock() | |
| 323 | + | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | |
| 324 | + | require.True(t, ok) | |
| 325 | + | publication, err := h.daemon.incrementalPublication(capture) | |
| 326 | + | require.NoError(t, err) | |
| 327 | + | decoded, err := sessionFromGeneration(snapshotGeneration(publication)) | |
| 328 | + | require.NoError(t, err) | |
| 329 | + | ||
| 330 | + | return paneLayoutSnapshotFixture{ | |
| 331 | + | snapshot: decoded, | |
| 332 | + | tree: wantTree, | |
| 333 | + | tabStableID: tabStableID, | |
| 334 | + | paneStableIDs: paneStableIDs, | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func paneLayoutSnapshotTree() *layout.Tree { | |
| 339 | + | return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | |
| 340 | + | {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, | |
| 341 | + | {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ | |
| 342 | + | {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, | |
| 343 | + | {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20}, | |
| 344 | + | }}, | |
| 345 | + | {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{ | |
| 346 | + | {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7}, | |
| 347 | + | {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, | |
| 348 | + | }}, | |
| 349 | + | }}, Focus: "pane-4"} | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) { | |
| 353 | + | tb.mu.Lock() | |
| 354 | + | defer tb.mu.Unlock() | |
| 355 | + | tb.stableID = tabStableID | |
| 356 | + | tb.nextPaneID = 6 | |
| 357 | + | for id, stableID := range paneStableIDs { | |
| 358 | + | tb.panes[id].mu.Lock() | |
| 359 | + | tb.panes[id].stableID = stableID | |
| 360 | + | tb.panes[id].mu.Unlock() | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) { | |
| 365 | + | t.Helper() | |
| 366 | + | for _, operation := range []struct { | |
| 367 | + | name string | |
| 368 | + | pane layout.PaneID | |
| 369 | + | direction layout.Direction | |
| 370 | + | }{ | |
| 371 | + | {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, | |
| 372 | + | {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, | |
| 373 | + | } { | |
| 374 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
| 375 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, | |
| 376 | + | }) | |
| 377 | + | require.NoError(t, err, operation.name) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) { | |
| 382 | + | t.Helper() | |
| 383 | + | require.NotNil(t, tree) | |
| 384 | + | require.NotNil(t, tree.Root) | |
| 385 | + | require.Equal(t, layout.Horizontal, tree.Root.Dir) | |
| 386 | + | require.Len(t, tree.Root.Children, 3) | |
| 387 | + | require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column") | |
| 388 | + | require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column") | |
| 389 | + | require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present") | |
| 390 | + | require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved") | |
| 391 | + | require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved") | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string { | |
| 395 | + | stableIDs := make(map[layout.PaneID]string, len(panes)) | |
| 396 | + | for _, p := range panes { | |
| 397 | + | stableIDs[p.ID] = p.StableID | |
| 398 | + | } | |
| 399 | + | return stableIDs | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func captureRestoredPaneLayout(tb *tab) restoredPaneLayout { | |
| 403 | + | tb.mu.Lock() | |
| 404 | + | defer tb.mu.Unlock() | |
| 405 | + | stableIDs := make(map[layout.PaneID]string, len(tb.panes)) | |
| 406 | + | for id, p := range tb.panes { | |
| 407 | + | p.mu.Lock() | |
| 408 | + | stableIDs[id] = p.stableID | |
| 409 | + | p.mu.Unlock() | |
| 410 | + | } | |
| 411 | + | return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs} | |
| 412 | + | } | |
| 413 | + | ||
| 270 | 414 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |
| 271 | 415 | snapshot := restoreAcceptanceSession(t, "restored") | |
| 272 | 416 | generation := acceptanceGeneration(t, snapshot, 9) | |
| @@ -41,11 +41,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap | |||||
| 41 | if r.publishErr != nil { | 41 | if r.publishErr != nil { | ||
| 42 | return r.publishErr | 42 | return r.publishErr | ||
| 43 | } | 43 | } | ||
| 44 | - | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) | 44 | + | r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p)) |
| 45 | - | for _, object := range p.Objects { | |||
| 46 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |||
| 47 | - | } | |||
| 48 | - | r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} | |||
| 49 | found := false | 45 | found := false | ||
| 50 | for _, name := range r.names { | 46 | for _, name := range r.names { | ||
| 51 | found = found || name == p.Name | 47 | found = found || name == p.Name | ||
| @@ -183,11 +179,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u | |||||
| 183 | manifest.Generation = generation | 179 | manifest.Generation = generation | ||
| 184 | encoded, err := snapcodec.MarshalManifest(manifest) | 180 | encoded, err := snapcodec.MarshalManifest(manifest) | ||
| 185 | require.NoError(t, err) | 181 | require.NoError(t, err) | ||
| 186 | - | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | 182 | + | publication.Generation = generation |
| 187 | - | for _, object := range publication.Objects { | 183 | + | publication.Manifest = encoded |
| 188 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | 184 | + | return cloneAcceptanceGeneration(snapshotGeneration(publication)) |
| 189 | - | } | |||
| 190 | - | return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects} | |||
| 191 | } | 185 | } | ||
| 192 | 186 | ||||
| 193 | func TestValidateRestoreSessionSnapshot(t *testing.T) { | 187 | func TestValidateRestoreSessionSnapshot(t *testing.T) { | ||
| @@ -267,6 +261,156 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||||
| 267 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | 261 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | ||
| 268 | } | 262 | } | ||
| 269 | 263 | ||||
| 264 | + | type paneLayoutSnapshotFixture struct { | |||
| 265 | + | snapshot snapcodec.Session | |||
| 266 | + | tree *layout.Tree | |||
| 267 | + | tabStableID string | |||
| 268 | + | paneStableIDs map[layout.PaneID]string | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | type restoredPaneLayout struct { | |||
| 272 | + | tree *layout.Tree | |||
| 273 | + | tabStableID string | |||
| 274 | + | paneStableIDs map[layout.PaneID]string | |||
| 275 | + | } | |||
| 276 | + | ||||
| 277 | + | func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |||
| 278 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |||
| 279 | + | require.Len(t, fixture.snapshot.Tabs, 1) | |||
| 280 | + | decodedTab := fixture.snapshot.Tabs[0] | |||
| 281 | + | ||||
| 282 | + | require.Equal(t, fixture.tabStableID, decodedTab.StableID) | |||
| 283 | + | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved") | |||
| 284 | + | assertConsumedAndExpelledColumns(t, decodedTab.Tree) | |||
| 285 | + | require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |||
| 286 | + | require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID") | |||
| 287 | + | } | |||
| 288 | + | ||||
| 289 | + | func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |||
| 290 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |||
| 291 | + | restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{}) | |||
| 292 | + | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot) | |||
| 293 | + | require.NoError(t, err) | |||
| 294 | + | defer closeRestoredTabs(restoredTabs) | |||
| 295 | + | require.Len(t, restoredTabs, 1) | |||
| 296 | + | ||||
| 297 | + | restored := captureRestoredPaneLayout(restoredTabs[0]) | |||
| 298 | + | require.Equal(t, fixture.tabStableID, restored.tabStableID) | |||
| 299 | + | assertConsumedAndExpelledColumns(t, restored.tree) | |||
| 300 | + | require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree") | |||
| 301 | + | require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { | |||
| 305 | + | t.Helper() | |||
| 306 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) | |||
| 307 | + | h.session.createdAt = 77 | |||
| 308 | + | h.session.incarnation = domain.IncarnationID{7} | |||
| 309 | + | tabStableID := "tab-stable-rearranged" | |||
| 310 | + | paneStableIDs := map[layout.PaneID]string{ | |||
| 311 | + | "pane-1": "pane-stable-1", | |||
| 312 | + | "pane-2": "pane-stable-2", | |||
| 313 | + | "pane-3": "pane-stable-3", | |||
| 314 | + | "pane-4": "pane-stable-4", | |||
| 315 | + | "pane-5": "pane-stable-5", | |||
| 316 | + | } | |||
| 317 | + | setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs) | |||
| 318 | + | applyPaneLayoutSnapshotOperations(t, h) | |||
| 319 | + | ||||
| 320 | + | h.tab.mu.Lock() | |||
| 321 | + | wantTree := h.tab.tree.Clone() | |||
| 322 | + | h.tab.mu.Unlock() | |||
| 323 | + | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | |||
| 324 | + | require.True(t, ok) | |||
| 325 | + | publication, err := h.daemon.incrementalPublication(capture) | |||
| 326 | + | require.NoError(t, err) | |||
| 327 | + | decoded, err := sessionFromGeneration(snapshotGeneration(publication)) | |||
| 328 | + | require.NoError(t, err) | |||
| 329 | + | ||||
| 330 | + | return paneLayoutSnapshotFixture{ | |||
| 331 | + | snapshot: decoded, | |||
| 332 | + | tree: wantTree, | |||
| 333 | + | tabStableID: tabStableID, | |||
| 334 | + | paneStableIDs: paneStableIDs, | |||
| 335 | + | } | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func paneLayoutSnapshotTree() *layout.Tree { | |||
| 339 | + | return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | |||
| 340 | + | {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, | |||
| 341 | + | {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ | |||
| 342 | + | {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, | |||
| 343 | + | {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20}, | |||
| 344 | + | }}, | |||
| 345 | + | {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{ | |||
| 346 | + | {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7}, | |||
| 347 | + | {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, | |||
| 348 | + | }}, | |||
| 349 | + | }}, Focus: "pane-4"} | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) { | |||
| 353 | + | tb.mu.Lock() | |||
| 354 | + | defer tb.mu.Unlock() | |||
| 355 | + | tb.stableID = tabStableID | |||
| 356 | + | tb.nextPaneID = 6 | |||
| 357 | + | for id, stableID := range paneStableIDs { | |||
| 358 | + | tb.panes[id].mu.Lock() | |||
| 359 | + | tb.panes[id].stableID = stableID | |||
| 360 | + | tb.panes[id].mu.Unlock() | |||
| 361 | + | } | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) { | |||
| 365 | + | t.Helper() | |||
| 366 | + | for _, operation := range []struct { | |||
| 367 | + | name string | |||
| 368 | + | pane layout.PaneID | |||
| 369 | + | direction layout.Direction | |||
| 370 | + | }{ | |||
| 371 | + | {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, | |||
| 372 | + | {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, | |||
| 373 | + | } { | |||
| 374 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 375 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, | |||
| 376 | + | }) | |||
| 377 | + | require.NoError(t, err, operation.name) | |||
| 378 | + | } | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) { | |||
| 382 | + | t.Helper() | |||
| 383 | + | require.NotNil(t, tree) | |||
| 384 | + | require.NotNil(t, tree.Root) | |||
| 385 | + | require.Equal(t, layout.Horizontal, tree.Root.Dir) | |||
| 386 | + | require.Len(t, tree.Root.Children, 3) | |||
| 387 | + | require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column") | |||
| 388 | + | require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column") | |||
| 389 | + | require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present") | |||
| 390 | + | require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved") | |||
| 391 | + | require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved") | |||
| 392 | + | } | |||
| 393 | + | ||||
| 394 | + | func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string { | |||
| 395 | + | stableIDs := make(map[layout.PaneID]string, len(panes)) | |||
| 396 | + | for _, p := range panes { | |||
| 397 | + | stableIDs[p.ID] = p.StableID | |||
| 398 | + | } | |||
| 399 | + | return stableIDs | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | func captureRestoredPaneLayout(tb *tab) restoredPaneLayout { | |||
| 403 | + | tb.mu.Lock() | |||
| 404 | + | defer tb.mu.Unlock() | |||
| 405 | + | stableIDs := make(map[layout.PaneID]string, len(tb.panes)) | |||
| 406 | + | for id, p := range tb.panes { | |||
| 407 | + | p.mu.Lock() | |||
| 408 | + | stableIDs[id] = p.stableID | |||
| 409 | + | p.mu.Unlock() | |||
| 410 | + | } | |||
| 411 | + | return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs} | |||
| 412 | + | } | |||
| 413 | + | ||||
| 270 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | 414 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | ||
| 271 | snapshot := restoreAcceptanceSession(t, "restored") | 415 | snapshot := restoreAcceptanceSession(t, "restored") | ||
| 272 | generation := acceptanceGeneration(t, snapshot, 9) | 416 | generation := acceptanceGeneration(t, snapshot, 9) | ||
A internal/usecase/keys/bindings_test.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package keys | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/require" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestConsumeOrExpelPaneActionsBindWithoutDefaults(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | action Action | |
| 13 | + | key rune | |
| 14 | + | }{ | |
| 15 | + | {name: "consume-or-expel-pane-left", action: ActionConsumeOrExpelPaneLeft, key: 'H'}, | |
| 16 | + | {name: "consume-or-expel-pane-right", action: ActionConsumeOrExpelPaneRight, key: 'L'}, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | for _, tt := range tests { | |
| 20 | + | t.Run(tt.name, func(t *testing.T) { | |
| 21 | + | bindings, warnings := BuildBindings(map[string]string{ | |
| 22 | + | tt.name: "alt+" + string(tt.key), | |
| 23 | + | }) | |
| 24 | + | ||
| 25 | + | require.Empty(t, warnings) | |
| 26 | + | assertAltRuneAction(t, bindings, tt.key, tt.action) | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func TestConsumeOrExpelPaneActionsAreAbsentFromAllDefaultBindings(t *testing.T) { | |
| 32 | + | tests := []struct { | |
| 33 | + | name string | |
| 34 | + | action Action | |
| 35 | + | }{ | |
| 36 | + | {name: "left", action: ActionConsumeOrExpelPaneLeft}, | |
| 37 | + | {name: "right", action: ActionConsumeOrExpelPaneRight}, | |
| 38 | + | } | |
| 39 | + | defaults := DefaultBindings() | |
| 40 | + | ||
| 41 | + | for _, tt := range tests { | |
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |
| 43 | + | for _, bound := range defaults.altRunes { | |
| 44 | + | require.NotEqual(t, tt.action, bound) | |
| 45 | + | } | |
| 46 | + | for _, bound := range defaults.altArrows { | |
| 47 | + | require.NotEqual(t, tt.action, bound) | |
| 48 | + | } | |
| 49 | + | }) | |
| 50 | + | } | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package keys | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestConsumeOrExpelPaneActionsBindWithoutDefaults(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | action Action | |||
| 13 | + | key rune | |||
| 14 | + | }{ | |||
| 15 | + | {name: "consume-or-expel-pane-left", action: ActionConsumeOrExpelPaneLeft, key: 'H'}, | |||
| 16 | + | {name: "consume-or-expel-pane-right", action: ActionConsumeOrExpelPaneRight, key: 'L'}, | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | for _, tt := range tests { | |||
| 20 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 21 | + | bindings, warnings := BuildBindings(map[string]string{ | |||
| 22 | + | tt.name: "alt+" + string(tt.key), | |||
| 23 | + | }) | |||
| 24 | + | ||||
| 25 | + | require.Empty(t, warnings) | |||
| 26 | + | assertAltRuneAction(t, bindings, tt.key, tt.action) | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func TestConsumeOrExpelPaneActionsAreAbsentFromAllDefaultBindings(t *testing.T) { | |||
| 32 | + | tests := []struct { | |||
| 33 | + | name string | |||
| 34 | + | action Action | |||
| 35 | + | }{ | |||
| 36 | + | {name: "left", action: ActionConsumeOrExpelPaneLeft}, | |||
| 37 | + | {name: "right", action: ActionConsumeOrExpelPaneRight}, | |||
| 38 | + | } | |||
| 39 | + | defaults := DefaultBindings() | |||
| 40 | + | ||||
| 41 | + | for _, tt := range tests { | |||
| 42 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 43 | + | for _, bound := range defaults.altRunes { | |||
| 44 | + | require.NotEqual(t, tt.action, bound) | |||
| 45 | + | } | |||
| 46 | + | for _, bound := range defaults.altArrows { | |||
| 47 | + | require.NotEqual(t, tt.action, bound) | |||
| 48 | + | } | |||
| 49 | + | }) | |||
| 50 | + | } | |||
| 51 | + | } | |||
M internal/usecase/keys/keyspec.go +23 -21
| @@ -49,27 +49,29 @@ func ParseKeySpec(spec string) (KeySpec, error) { | |||
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | 51 | var actionNames = map[Action]string{ | |
| 52 | - | ActionOpenPalette: "open-palette", | |
| 53 | - | ActionToggleFloatingPane: "toggle-floating-pane", | |
| 54 | - | ActionJumpAttention: "jump-attention", | |
| 55 | - | ActionFocusPaneLeft: "focus-pane-left", | |
| 56 | - | ActionFocusPaneRight: "focus-pane-right", | |
| 57 | - | ActionFocusPaneUp: "focus-pane-up", | |
| 58 | - | ActionFocusPaneDown: "focus-pane-down", | |
| 59 | - | ActionSwitchTab1: "switch-tab-1", | |
| 60 | - | ActionSwitchTab2: "switch-tab-2", | |
| 61 | - | ActionSwitchTab3: "switch-tab-3", | |
| 62 | - | ActionSwitchTab4: "switch-tab-4", | |
| 63 | - | ActionSwitchTab5: "switch-tab-5", | |
| 64 | - | ActionSwitchTab6: "switch-tab-6", | |
| 65 | - | ActionSwitchTab7: "switch-tab-7", | |
| 66 | - | ActionSwitchTab8: "switch-tab-8", | |
| 67 | - | ActionSwitchTab9: "switch-tab-9", | |
| 68 | - | ActionGrowPaneWidth: "grow-pane-width", | |
| 69 | - | ActionShrinkPaneWidth: "shrink-pane-width", | |
| 70 | - | ActionGrowPaneHeight: "grow-pane-height", | |
| 71 | - | ActionShrinkPaneHeight: "shrink-pane-height", | |
| 72 | - | ActionEqualizePanes: "equalize-panes", | |
| 52 | + | ActionOpenPalette: "open-palette", | |
| 53 | + | ActionToggleFloatingPane: "toggle-floating-pane", | |
| 54 | + | ActionJumpAttention: "jump-attention", | |
| 55 | + | ActionFocusPaneLeft: "focus-pane-left", | |
| 56 | + | ActionFocusPaneRight: "focus-pane-right", | |
| 57 | + | ActionFocusPaneUp: "focus-pane-up", | |
| 58 | + | ActionFocusPaneDown: "focus-pane-down", | |
| 59 | + | ActionSwitchTab1: "switch-tab-1", | |
| 60 | + | ActionSwitchTab2: "switch-tab-2", | |
| 61 | + | ActionSwitchTab3: "switch-tab-3", | |
| 62 | + | ActionSwitchTab4: "switch-tab-4", | |
| 63 | + | ActionSwitchTab5: "switch-tab-5", | |
| 64 | + | ActionSwitchTab6: "switch-tab-6", | |
| 65 | + | ActionSwitchTab7: "switch-tab-7", | |
| 66 | + | ActionSwitchTab8: "switch-tab-8", | |
| 67 | + | ActionSwitchTab9: "switch-tab-9", | |
| 68 | + | ActionGrowPaneWidth: "grow-pane-width", | |
| 69 | + | ActionShrinkPaneWidth: "shrink-pane-width", | |
| 70 | + | ActionGrowPaneHeight: "grow-pane-height", | |
| 71 | + | ActionShrinkPaneHeight: "shrink-pane-height", | |
| 72 | + | ActionEqualizePanes: "equalize-panes", | |
| 73 | + | ActionConsumeOrExpelPaneLeft: "consume-or-expel-pane-left", | |
| 74 | + | ActionConsumeOrExpelPaneRight: "consume-or-expel-pane-right", | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 75 | 77 | var actionsByName = func() map[string]Action { | |
| @@ -49,27 +49,29 @@ func ParseKeySpec(spec string) (KeySpec, error) { | |||||
| 49 | } | 49 | } | ||
| 50 | 50 | ||||
| 51 | var actionNames = map[Action]string{ | 51 | var actionNames = map[Action]string{ | ||
| 52 | - | ActionOpenPalette: "open-palette", | 52 | + | ActionOpenPalette: "open-palette", |
| 53 | - | ActionToggleFloatingPane: "toggle-floating-pane", | 53 | + | ActionToggleFloatingPane: "toggle-floating-pane", |
| 54 | - | ActionJumpAttention: "jump-attention", | 54 | + | ActionJumpAttention: "jump-attention", |
| 55 | - | ActionFocusPaneLeft: "focus-pane-left", | 55 | + | ActionFocusPaneLeft: "focus-pane-left", |
| 56 | - | ActionFocusPaneRight: "focus-pane-right", | 56 | + | ActionFocusPaneRight: "focus-pane-right", |
| 57 | - | ActionFocusPaneUp: "focus-pane-up", | 57 | + | ActionFocusPaneUp: "focus-pane-up", |
| 58 | - | ActionFocusPaneDown: "focus-pane-down", | 58 | + | ActionFocusPaneDown: "focus-pane-down", |
| 59 | - | ActionSwitchTab1: "switch-tab-1", | 59 | + | ActionSwitchTab1: "switch-tab-1", |
| 60 | - | ActionSwitchTab2: "switch-tab-2", | 60 | + | ActionSwitchTab2: "switch-tab-2", |
| 61 | - | ActionSwitchTab3: "switch-tab-3", | 61 | + | ActionSwitchTab3: "switch-tab-3", |
| 62 | - | ActionSwitchTab4: "switch-tab-4", | 62 | + | ActionSwitchTab4: "switch-tab-4", |
| 63 | - | ActionSwitchTab5: "switch-tab-5", | 63 | + | ActionSwitchTab5: "switch-tab-5", |
| 64 | - | ActionSwitchTab6: "switch-tab-6", | 64 | + | ActionSwitchTab6: "switch-tab-6", |
| 65 | - | ActionSwitchTab7: "switch-tab-7", | 65 | + | ActionSwitchTab7: "switch-tab-7", |
| 66 | - | ActionSwitchTab8: "switch-tab-8", | 66 | + | ActionSwitchTab8: "switch-tab-8", |
| 67 | - | ActionSwitchTab9: "switch-tab-9", | 67 | + | ActionSwitchTab9: "switch-tab-9", |
| 68 | - | ActionGrowPaneWidth: "grow-pane-width", | 68 | + | ActionGrowPaneWidth: "grow-pane-width", |
| 69 | - | ActionShrinkPaneWidth: "shrink-pane-width", | 69 | + | ActionShrinkPaneWidth: "shrink-pane-width", |
| 70 | - | ActionGrowPaneHeight: "grow-pane-height", | 70 | + | ActionGrowPaneHeight: "grow-pane-height", |
| 71 | - | ActionShrinkPaneHeight: "shrink-pane-height", | 71 | + | ActionShrinkPaneHeight: "shrink-pane-height", |
| 72 | - | ActionEqualizePanes: "equalize-panes", | 72 | + | ActionEqualizePanes: "equalize-panes", |
| 73 | + | ActionConsumeOrExpelPaneLeft: "consume-or-expel-pane-left", | |||
| 74 | + | ActionConsumeOrExpelPaneRight: "consume-or-expel-pane-right", | |||
| 73 | } | 75 | } | ||
| 74 | 76 | ||||
| 75 | var actionsByName = func() map[string]Action { | 77 | var actionsByName = func() map[string]Action { | ||
M internal/usecase/keys/keyspec_test.go +2 -0
| @@ -84,6 +84,8 @@ func TestActionNamesAreCanonical(t *testing.T) { | |||
| 84 | 84 | {ActionGrowPaneHeight, "grow-pane-height"}, | |
| 85 | 85 | {ActionShrinkPaneHeight, "shrink-pane-height"}, | |
| 86 | 86 | {ActionEqualizePanes, "equalize-panes"}, | |
| 87 | + | {ActionConsumeOrExpelPaneLeft, "consume-or-expel-pane-left"}, | |
| 88 | + | {ActionConsumeOrExpelPaneRight, "consume-or-expel-pane-right"}, | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 89 | 91 | for _, tc := range cases { | |
| @@ -84,6 +84,8 @@ func TestActionNamesAreCanonical(t *testing.T) { | |||||
| 84 | {ActionGrowPaneHeight, "grow-pane-height"}, | 84 | {ActionGrowPaneHeight, "grow-pane-height"}, | ||
| 85 | {ActionShrinkPaneHeight, "shrink-pane-height"}, | 85 | {ActionShrinkPaneHeight, "shrink-pane-height"}, | ||
| 86 | {ActionEqualizePanes, "equalize-panes"}, | 86 | {ActionEqualizePanes, "equalize-panes"}, | ||
| 87 | + | {ActionConsumeOrExpelPaneLeft, "consume-or-expel-pane-left"}, | |||
| 88 | + | {ActionConsumeOrExpelPaneRight, "consume-or-expel-pane-right"}, | |||
| 87 | } | 89 | } | ||
| 88 | 90 | ||||
| 89 | for _, tc := range cases { | 91 | for _, tc := range cases { | ||
M internal/usecase/keys/router.go +2 -0
| @@ -43,6 +43,8 @@ const ( | |||
| 43 | 43 | ActionGrowPaneHeight | |
| 44 | 44 | ActionShrinkPaneHeight | |
| 45 | 45 | ActionEqualizePanes | |
| 46 | + | ActionConsumeOrExpelPaneLeft | |
| 47 | + | ActionConsumeOrExpelPaneRight | |
| 46 | 48 | ) | |
| 47 | 49 | ||
| 48 | 50 | // Handler receives router outputs. Forward is called only for bytes that should | |
| @@ -43,6 +43,8 @@ const ( | |||||
| 43 | ActionGrowPaneHeight | 43 | ActionGrowPaneHeight | ||
| 44 | ActionShrinkPaneHeight | 44 | ActionShrinkPaneHeight | ||
| 45 | ActionEqualizePanes | 45 | ActionEqualizePanes | ||
| 46 | + | ActionConsumeOrExpelPaneLeft | |||
| 47 | + | ActionConsumeOrExpelPaneRight | |||
| 46 | ) | 48 | ) | ||
| 47 | 49 | ||||
| 48 | // Handler receives router outputs. Forward is called only for bytes that should | 50 | // Handler receives router outputs. Forward is called only for bytes that should | ||
A internal/usecase/layout/consume_expel.go +260 -0
| @@ -0,0 +1,260 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | var ( | |
| 10 | + | ErrUnsupportedColumnLayout = errors.New("unsupported column layout") | |
| 11 | + | ErrInvalidDirection = errors.New("invalid direction") | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ConsumeOrExpelPane moves target horizontally between canonical root columns. | |
| 15 | + | // A singleton column is consumed into its immediate neighbor; a member of a | |
| 16 | + | // vertical or stack column is expelled into a new singleton column. | |
| 17 | + | func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) { | |
| 18 | + | if dir != Left && dir != Right { | |
| 19 | + | return false, ErrInvalidDirection | |
| 20 | + | } | |
| 21 | + | if !validCanonicalColumnTree(t) { | |
| 22 | + | return false, ErrUnsupportedColumnLayout | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | columns := canonicalColumns(t.Root) | |
| 26 | + | columnIndex, _, found := findColumnMember(columns, target) | |
| 27 | + | if !found { | |
| 28 | + | return false, ErrNotFound | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | if columns[columnIndex].Kind == Leaf { | |
| 32 | + | destinationIndex := columnIndex - 1 | |
| 33 | + | if dir == Right { | |
| 34 | + | destinationIndex = columnIndex + 1 | |
| 35 | + | } | |
| 36 | + | if destinationIndex < 0 || destinationIndex >= len(columns) { | |
| 37 | + | return false, nil | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | candidate := t.clone() | |
| 42 | + | if _, solved := Solve(candidate.Root, area); !solved { | |
| 43 | + | return false, ErrTooSmall | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | columns = canonicalColumns(candidate.Root) | |
| 47 | + | columnIndex, memberIndex, _ := findColumnMember(columns, target) | |
| 48 | + | if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal { | |
| 49 | + | normalizeChildWeightsFromArea(candidate.Root, area) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | if columns[columnIndex].Kind == Leaf { | |
| 53 | + | consumeSingletonColumn(candidate, columnIndex, dir, area) | |
| 54 | + | } else { | |
| 55 | + | expelColumnMember(candidate, columnIndex, memberIndex, dir) | |
| 56 | + | } | |
| 57 | + | candidate.Focus = target | |
| 58 | + | ||
| 59 | + | if _, solved := Solve(candidate.Root, area); !solved { | |
| 60 | + | return false, ErrTooSmall | |
| 61 | + | } | |
| 62 | + | *t = *candidate | |
| 63 | + | return true, nil | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func validCanonicalColumnTree(t *Tree) bool { | |
| 67 | + | if t == nil || t.Root == nil { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | columns := canonicalColumns(t.Root) | |
| 72 | + | if len(columns) == 0 { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 { | |
| 76 | + | return false | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | ids := make(map[PaneID]struct{}) | |
| 80 | + | for _, column := range columns { | |
| 81 | + | if !validCanonicalColumn(column, ids) { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | if t.Focus == "" { | |
| 86 | + | return false | |
| 87 | + | } | |
| 88 | + | _, focused := ids[t.Focus] | |
| 89 | + | return focused | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func canonicalColumns(root *Node) []*Node { | |
| 93 | + | if root == nil { | |
| 94 | + | return nil | |
| 95 | + | } | |
| 96 | + | if root.Kind == Split && root.Dir == Horizontal { | |
| 97 | + | return root.Children | |
| 98 | + | } | |
| 99 | + | return []*Node{root} | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool { | |
| 103 | + | if column == nil { | |
| 104 | + | return false | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | switch column.Kind { | |
| 108 | + | case Leaf: | |
| 109 | + | if len(column.Children) != 0 { | |
| 110 | + | return false | |
| 111 | + | } | |
| 112 | + | return addUniqueLeafID(ids, column.Leaf) | |
| 113 | + | case Split: | |
| 114 | + | if column.Dir != Vertical || len(column.Children) < 2 { | |
| 115 | + | return false | |
| 116 | + | } | |
| 117 | + | case Stack: | |
| 118 | + | if len(column.Children) < 2 { | |
| 119 | + | return false | |
| 120 | + | } | |
| 121 | + | default: | |
| 122 | + | return false | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | expanded := column.Kind != Stack | |
| 126 | + | for _, member := range column.Children { | |
| 127 | + | if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) { | |
| 128 | + | return false | |
| 129 | + | } | |
| 130 | + | if column.Kind == Stack && member.Leaf == column.Expanded { | |
| 131 | + | expanded = true | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | return expanded | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool { | |
| 138 | + | if id == "" { | |
| 139 | + | return false | |
| 140 | + | } | |
| 141 | + | if _, exists := ids[id]; exists { | |
| 142 | + | return false | |
| 143 | + | } | |
| 144 | + | ids[id] = struct{}{} | |
| 145 | + | return true | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func findColumnMember(columns []*Node, target PaneID) (int, int, bool) { | |
| 149 | + | for columnIndex, column := range columns { | |
| 150 | + | if column.Kind == Leaf { | |
| 151 | + | if column.Leaf == target { | |
| 152 | + | return columnIndex, -1, true | |
| 153 | + | } | |
| 154 | + | continue | |
| 155 | + | } | |
| 156 | + | for memberIndex, member := range column.Children { | |
| 157 | + | if member.Leaf == target { | |
| 158 | + | return columnIndex, memberIndex, true | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | return 0, 0, false | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) { | |
| 166 | + | root := t.Root | |
| 167 | + | destinationIndex := sourceIndex - 1 | |
| 168 | + | if dir == Right { | |
| 169 | + | destinationIndex = sourceIndex + 1 | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | columnAreas, _ := splitChildRects(root, area) | |
| 173 | + | moved := root.Children[sourceIndex] | |
| 174 | + | moved.Weight = 0 | |
| 175 | + | destination := root.Children[destinationIndex] | |
| 176 | + | ||
| 177 | + | switch destination.Kind { | |
| 178 | + | case Leaf: | |
| 179 | + | weight := destination.Weight | |
| 180 | + | destination.Weight = 0 | |
| 181 | + | destination = &Node{ | |
| 182 | + | Kind: Split, | |
| 183 | + | Dir: Vertical, | |
| 184 | + | Children: []*Node{destination, moved}, | |
| 185 | + | Weight: weight, | |
| 186 | + | } | |
| 187 | + | case Split: | |
| 188 | + | normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex]) | |
| 189 | + | destination.Children = append(destination.Children, moved) | |
| 190 | + | case Stack: | |
| 191 | + | destination.Children = append(destination.Children, moved) | |
| 192 | + | destination.Expanded = moved.Leaf | |
| 193 | + | clearChildWeights(destination) | |
| 194 | + | } | |
| 195 | + | root.Children[destinationIndex] = destination | |
| 196 | + | root.Children = removeNodeAt(root.Children, sourceIndex) | |
| 197 | + | ||
| 198 | + | if len(root.Children) == 1 { | |
| 199 | + | promoted := root.Children[0] | |
| 200 | + | promoted.Weight = root.Weight | |
| 201 | + | t.Root = promoted | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) { | |
| 206 | + | root := t.Root | |
| 207 | + | rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal | |
| 208 | + | columns := canonicalColumns(root) | |
| 209 | + | source := columns[columnIndex] | |
| 210 | + | wrapperWeight := source.Weight | |
| 211 | + | if !rootIsHorizontal { | |
| 212 | + | wrapperWeight = root.Weight | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | moved := source.Children[memberIndex] | |
| 216 | + | moved.Weight = 0 | |
| 217 | + | source.Children = removeNodeAt(source.Children, memberIndex) | |
| 218 | + | if source.Kind == Stack { | |
| 219 | + | if source.Expanded == moved.Leaf { | |
| 220 | + | source.Expanded = source.Children[0].Leaf | |
| 221 | + | } | |
| 222 | + | clearChildWeights(source) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | remaining := source | |
| 226 | + | if len(source.Children) == 1 { | |
| 227 | + | remaining = source.Children[0] | |
| 228 | + | remaining.Weight = source.Weight | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | if !rootIsHorizontal { | |
| 232 | + | remaining.Weight = 0 | |
| 233 | + | children := []*Node{remaining, moved} | |
| 234 | + | if dir == Left { | |
| 235 | + | children[0], children[1] = children[1], children[0] | |
| 236 | + | } | |
| 237 | + | t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight} | |
| 238 | + | return | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | root.Children[columnIndex] = remaining | |
| 242 | + | insertionIndex := columnIndex | |
| 243 | + | if dir == Right { | |
| 244 | + | insertionIndex++ | |
| 245 | + | } | |
| 246 | + | root.Children = insertNodeAt(root.Children, insertionIndex, moved) | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func removeNodeAt(nodes []*Node, index int) []*Node { | |
| 250 | + | copy(nodes[index:], nodes[index+1:]) | |
| 251 | + | nodes[len(nodes)-1] = nil | |
| 252 | + | return nodes[:len(nodes)-1] | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | func insertNodeAt(nodes []*Node, index int, node *Node) []*Node { | |
| 256 | + | nodes = append(nodes, nil) | |
| 257 | + | copy(nodes[index+1:], nodes[index:]) | |
| 258 | + | nodes[index] = node | |
| 259 | + | return nodes | |
| 260 | + | } | |
| @@ -0,0 +1,260 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | var ( | |||
| 10 | + | ErrUnsupportedColumnLayout = errors.New("unsupported column layout") | |||
| 11 | + | ErrInvalidDirection = errors.New("invalid direction") | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // ConsumeOrExpelPane moves target horizontally between canonical root columns. | |||
| 15 | + | // A singleton column is consumed into its immediate neighbor; a member of a | |||
| 16 | + | // vertical or stack column is expelled into a new singleton column. | |||
| 17 | + | func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) { | |||
| 18 | + | if dir != Left && dir != Right { | |||
| 19 | + | return false, ErrInvalidDirection | |||
| 20 | + | } | |||
| 21 | + | if !validCanonicalColumnTree(t) { | |||
| 22 | + | return false, ErrUnsupportedColumnLayout | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | columns := canonicalColumns(t.Root) | |||
| 26 | + | columnIndex, _, found := findColumnMember(columns, target) | |||
| 27 | + | if !found { | |||
| 28 | + | return false, ErrNotFound | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | if columns[columnIndex].Kind == Leaf { | |||
| 32 | + | destinationIndex := columnIndex - 1 | |||
| 33 | + | if dir == Right { | |||
| 34 | + | destinationIndex = columnIndex + 1 | |||
| 35 | + | } | |||
| 36 | + | if destinationIndex < 0 || destinationIndex >= len(columns) { | |||
| 37 | + | return false, nil | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | candidate := t.clone() | |||
| 42 | + | if _, solved := Solve(candidate.Root, area); !solved { | |||
| 43 | + | return false, ErrTooSmall | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | columns = canonicalColumns(candidate.Root) | |||
| 47 | + | columnIndex, memberIndex, _ := findColumnMember(columns, target) | |||
| 48 | + | if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal { | |||
| 49 | + | normalizeChildWeightsFromArea(candidate.Root, area) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | if columns[columnIndex].Kind == Leaf { | |||
| 53 | + | consumeSingletonColumn(candidate, columnIndex, dir, area) | |||
| 54 | + | } else { | |||
| 55 | + | expelColumnMember(candidate, columnIndex, memberIndex, dir) | |||
| 56 | + | } | |||
| 57 | + | candidate.Focus = target | |||
| 58 | + | ||||
| 59 | + | if _, solved := Solve(candidate.Root, area); !solved { | |||
| 60 | + | return false, ErrTooSmall | |||
| 61 | + | } | |||
| 62 | + | *t = *candidate | |||
| 63 | + | return true, nil | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func validCanonicalColumnTree(t *Tree) bool { | |||
| 67 | + | if t == nil || t.Root == nil { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | columns := canonicalColumns(t.Root) | |||
| 72 | + | if len(columns) == 0 { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 { | |||
| 76 | + | return false | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | ids := make(map[PaneID]struct{}) | |||
| 80 | + | for _, column := range columns { | |||
| 81 | + | if !validCanonicalColumn(column, ids) { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | if t.Focus == "" { | |||
| 86 | + | return false | |||
| 87 | + | } | |||
| 88 | + | _, focused := ids[t.Focus] | |||
| 89 | + | return focused | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func canonicalColumns(root *Node) []*Node { | |||
| 93 | + | if root == nil { | |||
| 94 | + | return nil | |||
| 95 | + | } | |||
| 96 | + | if root.Kind == Split && root.Dir == Horizontal { | |||
| 97 | + | return root.Children | |||
| 98 | + | } | |||
| 99 | + | return []*Node{root} | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool { | |||
| 103 | + | if column == nil { | |||
| 104 | + | return false | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | switch column.Kind { | |||
| 108 | + | case Leaf: | |||
| 109 | + | if len(column.Children) != 0 { | |||
| 110 | + | return false | |||
| 111 | + | } | |||
| 112 | + | return addUniqueLeafID(ids, column.Leaf) | |||
| 113 | + | case Split: | |||
| 114 | + | if column.Dir != Vertical || len(column.Children) < 2 { | |||
| 115 | + | return false | |||
| 116 | + | } | |||
| 117 | + | case Stack: | |||
| 118 | + | if len(column.Children) < 2 { | |||
| 119 | + | return false | |||
| 120 | + | } | |||
| 121 | + | default: | |||
| 122 | + | return false | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | expanded := column.Kind != Stack | |||
| 126 | + | for _, member := range column.Children { | |||
| 127 | + | if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) { | |||
| 128 | + | return false | |||
| 129 | + | } | |||
| 130 | + | if column.Kind == Stack && member.Leaf == column.Expanded { | |||
| 131 | + | expanded = true | |||
| 132 | + | } | |||
| 133 | + | } | |||
| 134 | + | return expanded | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool { | |||
| 138 | + | if id == "" { | |||
| 139 | + | return false | |||
| 140 | + | } | |||
| 141 | + | if _, exists := ids[id]; exists { | |||
| 142 | + | return false | |||
| 143 | + | } | |||
| 144 | + | ids[id] = struct{}{} | |||
| 145 | + | return true | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func findColumnMember(columns []*Node, target PaneID) (int, int, bool) { | |||
| 149 | + | for columnIndex, column := range columns { | |||
| 150 | + | if column.Kind == Leaf { | |||
| 151 | + | if column.Leaf == target { | |||
| 152 | + | return columnIndex, -1, true | |||
| 153 | + | } | |||
| 154 | + | continue | |||
| 155 | + | } | |||
| 156 | + | for memberIndex, member := range column.Children { | |||
| 157 | + | if member.Leaf == target { | |||
| 158 | + | return columnIndex, memberIndex, true | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | return 0, 0, false | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) { | |||
| 166 | + | root := t.Root | |||
| 167 | + | destinationIndex := sourceIndex - 1 | |||
| 168 | + | if dir == Right { | |||
| 169 | + | destinationIndex = sourceIndex + 1 | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | columnAreas, _ := splitChildRects(root, area) | |||
| 173 | + | moved := root.Children[sourceIndex] | |||
| 174 | + | moved.Weight = 0 | |||
| 175 | + | destination := root.Children[destinationIndex] | |||
| 176 | + | ||||
| 177 | + | switch destination.Kind { | |||
| 178 | + | case Leaf: | |||
| 179 | + | weight := destination.Weight | |||
| 180 | + | destination.Weight = 0 | |||
| 181 | + | destination = &Node{ | |||
| 182 | + | Kind: Split, | |||
| 183 | + | Dir: Vertical, | |||
| 184 | + | Children: []*Node{destination, moved}, | |||
| 185 | + | Weight: weight, | |||
| 186 | + | } | |||
| 187 | + | case Split: | |||
| 188 | + | normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex]) | |||
| 189 | + | destination.Children = append(destination.Children, moved) | |||
| 190 | + | case Stack: | |||
| 191 | + | destination.Children = append(destination.Children, moved) | |||
| 192 | + | destination.Expanded = moved.Leaf | |||
| 193 | + | clearChildWeights(destination) | |||
| 194 | + | } | |||
| 195 | + | root.Children[destinationIndex] = destination | |||
| 196 | + | root.Children = removeNodeAt(root.Children, sourceIndex) | |||
| 197 | + | ||||
| 198 | + | if len(root.Children) == 1 { | |||
| 199 | + | promoted := root.Children[0] | |||
| 200 | + | promoted.Weight = root.Weight | |||
| 201 | + | t.Root = promoted | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) { | |||
| 206 | + | root := t.Root | |||
| 207 | + | rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal | |||
| 208 | + | columns := canonicalColumns(root) | |||
| 209 | + | source := columns[columnIndex] | |||
| 210 | + | wrapperWeight := source.Weight | |||
| 211 | + | if !rootIsHorizontal { | |||
| 212 | + | wrapperWeight = root.Weight | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | moved := source.Children[memberIndex] | |||
| 216 | + | moved.Weight = 0 | |||
| 217 | + | source.Children = removeNodeAt(source.Children, memberIndex) | |||
| 218 | + | if source.Kind == Stack { | |||
| 219 | + | if source.Expanded == moved.Leaf { | |||
| 220 | + | source.Expanded = source.Children[0].Leaf | |||
| 221 | + | } | |||
| 222 | + | clearChildWeights(source) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | remaining := source | |||
| 226 | + | if len(source.Children) == 1 { | |||
| 227 | + | remaining = source.Children[0] | |||
| 228 | + | remaining.Weight = source.Weight | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | if !rootIsHorizontal { | |||
| 232 | + | remaining.Weight = 0 | |||
| 233 | + | children := []*Node{remaining, moved} | |||
| 234 | + | if dir == Left { | |||
| 235 | + | children[0], children[1] = children[1], children[0] | |||
| 236 | + | } | |||
| 237 | + | t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight} | |||
| 238 | + | return | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | root.Children[columnIndex] = remaining | |||
| 242 | + | insertionIndex := columnIndex | |||
| 243 | + | if dir == Right { | |||
| 244 | + | insertionIndex++ | |||
| 245 | + | } | |||
| 246 | + | root.Children = insertNodeAt(root.Children, insertionIndex, moved) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func removeNodeAt(nodes []*Node, index int) []*Node { | |||
| 250 | + | copy(nodes[index:], nodes[index+1:]) | |||
| 251 | + | nodes[len(nodes)-1] = nil | |||
| 252 | + | return nodes[:len(nodes)-1] | |||
| 253 | + | } | |||
| 254 | + | ||||
| 255 | + | func insertNodeAt(nodes []*Node, index int, node *Node) []*Node { | |||
| 256 | + | nodes = append(nodes, nil) | |||
| 257 | + | copy(nodes[index+1:], nodes[index:]) | |||
| 258 | + | nodes[index] = node | |||
| 259 | + | return nodes | |||
| 260 | + | } | |||
A internal/usecase/layout/consume_expel_test.go +304 -0
| @@ -0,0 +1,304 @@ | |||
| 1 | + | package layout | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestConsumeOrExpelPaneTransitions(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | area := domain.Rect{Width: 101, Height: 12} | |
| 14 | + | tests := []struct { | |
| 15 | + | name string | |
| 16 | + | tree *Tree | |
| 17 | + | target PaneID | |
| 18 | + | dir Direction | |
| 19 | + | want *Tree | |
| 20 | + | wantChange bool | |
| 21 | + | }{ | |
| 22 | + | { | |
| 23 | + | name: "consume singleton left into leaf", | |
| 24 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |
| 25 | + | target: "b", | |
| 26 | + | dir: Left, | |
| 27 | + | want: &Tree{Root: vertical("a", "b"), Focus: "b"}, | |
| 28 | + | wantChange: true, | |
| 29 | + | }, | |
| 30 | + | { | |
| 31 | + | name: "consume singleton right into leaf", | |
| 32 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 33 | + | target: "a", | |
| 34 | + | dir: Right, | |
| 35 | + | want: &Tree{Root: vertical("b", "a"), Focus: "a"}, | |
| 36 | + | wantChange: true, | |
| 37 | + | }, | |
| 38 | + | { | |
| 39 | + | name: "consume singleton left into vertical column", | |
| 40 | + | tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"}, | |
| 41 | + | target: "c", | |
| 42 | + | dir: Left, | |
| 43 | + | want: &Tree{Root: verticalNodes( | |
| 44 | + | consumeExpelWeightedLeaf("a", 6), | |
| 45 | + | consumeExpelWeightedLeaf("b", 5), | |
| 46 | + | NewLeaf("c"), | |
| 47 | + | ), Focus: "c"}, | |
| 48 | + | wantChange: true, | |
| 49 | + | }, | |
| 50 | + | { | |
| 51 | + | name: "consume singleton right into vertical column", | |
| 52 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"}, | |
| 53 | + | target: "a", | |
| 54 | + | dir: Right, | |
| 55 | + | want: &Tree{Root: verticalNodes( | |
| 56 | + | consumeExpelWeightedLeaf("b", 6), | |
| 57 | + | consumeExpelWeightedLeaf("c", 5), | |
| 58 | + | NewLeaf("a"), | |
| 59 | + | ), Focus: "a"}, | |
| 60 | + | wantChange: true, | |
| 61 | + | }, | |
| 62 | + | { | |
| 63 | + | name: "consume singleton left into stack appends and expands", | |
| 64 | + | tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"}, | |
| 65 | + | target: "c", | |
| 66 | + | dir: Left, | |
| 67 | + | want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"}, | |
| 68 | + | wantChange: true, | |
| 69 | + | }, | |
| 70 | + | { | |
| 71 | + | name: "consume singleton right into stack appends and expands", | |
| 72 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"}, | |
| 73 | + | target: "a", | |
| 74 | + | dir: Right, | |
| 75 | + | want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"}, | |
| 76 | + | wantChange: true, | |
| 77 | + | }, | |
| 78 | + | { | |
| 79 | + | name: "expel middle vertical member left from sole root column", | |
| 80 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"}, | |
| 81 | + | target: "b", | |
| 82 | + | dir: Left, | |
| 83 | + | want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"}, | |
| 84 | + | wantChange: true, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "expel middle vertical member right from sole root column", | |
| 88 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"}, | |
| 89 | + | target: "b", | |
| 90 | + | dir: Right, | |
| 91 | + | want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"}, | |
| 92 | + | wantChange: true, | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "expel vertical member left beside its source column", | |
| 96 | + | tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"}, | |
| 97 | + | target: "b", | |
| 98 | + | dir: Left, | |
| 99 | + | want: &Tree{Root: horizontal( | |
| 100 | + | NewLeaf("b"), | |
| 101 | + | consumeExpelWeightedNode(vertical("a", "c"), 50), | |
| 102 | + | consumeExpelWeightedLeaf("d", 50), | |
| 103 | + | ), Focus: "b"}, | |
| 104 | + | wantChange: true, | |
| 105 | + | }, | |
| 106 | + | { | |
| 107 | + | name: "expel vertical member right beside its source column", | |
| 108 | + | tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"}, | |
| 109 | + | target: "b", | |
| 110 | + | dir: Right, | |
| 111 | + | want: &Tree{Root: horizontal( | |
| 112 | + | consumeExpelWeightedNode(vertical("a", "c"), 50), | |
| 113 | + | NewLeaf("b"), | |
| 114 | + | consumeExpelWeightedLeaf("d", 50), | |
| 115 | + | ), Focus: "b"}, | |
| 116 | + | wantChange: true, | |
| 117 | + | }, | |
| 118 | + | { | |
| 119 | + | name: "expel stack member left from sole root column", | |
| 120 | + | tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"}, | |
| 121 | + | target: "b", | |
| 122 | + | dir: Left, | |
| 123 | + | want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"}, | |
| 124 | + | wantChange: true, | |
| 125 | + | }, | |
| 126 | + | { | |
| 127 | + | name: "expel stack member right from sole root column", | |
| 128 | + | tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"}, | |
| 129 | + | target: "b", | |
| 130 | + | dir: Right, | |
| 131 | + | want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"}, | |
| 132 | + | wantChange: true, | |
| 133 | + | }, | |
| 134 | + | { | |
| 135 | + | name: "expelling expanded stack member repairs source expansion", | |
| 136 | + | tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"}, | |
| 137 | + | target: "b", | |
| 138 | + | dir: Right, | |
| 139 | + | want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"}, | |
| 140 | + | wantChange: true, | |
| 141 | + | }, | |
| 142 | + | { | |
| 143 | + | name: "expel collapses two-member vertical column", | |
| 144 | + | tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, | |
| 145 | + | target: "a", | |
| 146 | + | dir: Left, | |
| 147 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 148 | + | wantChange: true, | |
| 149 | + | }, | |
| 150 | + | { | |
| 151 | + | name: "expel collapses two-member stack column", | |
| 152 | + | tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"}, | |
| 153 | + | target: "b", | |
| 154 | + | dir: Right, | |
| 155 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |
| 156 | + | wantChange: true, | |
| 157 | + | }, | |
| 158 | + | { | |
| 159 | + | name: "real move focuses explicitly targeted non-focused pane", | |
| 160 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"}, | |
| 161 | + | target: "b", | |
| 162 | + | dir: Right, | |
| 163 | + | want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"}, | |
| 164 | + | wantChange: true, | |
| 165 | + | }, | |
| 166 | + | { | |
| 167 | + | name: "left edge singleton is exact no-op and preserves different focus", | |
| 168 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |
| 169 | + | target: "a", | |
| 170 | + | dir: Left, | |
| 171 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |
| 172 | + | wantChange: false, | |
| 173 | + | }, | |
| 174 | + | { | |
| 175 | + | name: "right edge singleton is exact no-op and preserves different focus", | |
| 176 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 177 | + | target: "b", | |
| 178 | + | dir: Right, | |
| 179 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |
| 180 | + | wantChange: false, | |
| 181 | + | }, | |
| 182 | + | { | |
| 183 | + | name: "sole singleton root is exact no-op", | |
| 184 | + | tree: NewTree("a"), | |
| 185 | + | target: "a", | |
| 186 | + | dir: Left, | |
| 187 | + | want: NewTree("a"), | |
| 188 | + | wantChange: false, | |
| 189 | + | }, | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | for _, tt := range tests { | |
| 193 | + | t.Run(tt.name, func(t *testing.T) { | |
| 194 | + | before := tt.tree.Clone() | |
| 195 | + | beforeIDs := LeafIDs(tt.tree.Root) | |
| 196 | + | requireUniquePaneIDs(t, beforeIDs) | |
| 197 | + | ||
| 198 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | require.Equal(t, tt.wantChange, changed) | |
| 201 | + | require.Equal(t, tt.want, tt.tree) | |
| 202 | + | if !tt.wantChange { | |
| 203 | + | require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree") | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | afterIDs := LeafIDs(tt.tree.Root) | |
| 207 | + | require.ElementsMatch(t, beforeIDs, afterIDs) | |
| 208 | + | require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs") | |
| 209 | + | requireUniquePaneIDs(t, afterIDs) | |
| 210 | + | _, solved := Solve(tt.tree.Root, area) | |
| 211 | + | require.True(t, solved, "every successful result must solve") | |
| 212 | + | }) | |
| 213 | + | } | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) { | |
| 217 | + | t.Parallel() | |
| 218 | + | ||
| 219 | + | area := domain.Rect{Width: 101, Height: 12} | |
| 220 | + | tests := []struct { | |
| 221 | + | name string | |
| 222 | + | tree *Tree | |
| 223 | + | target PaneID | |
| 224 | + | dir Direction | |
| 225 | + | area domain.Rect | |
| 226 | + | want error | |
| 227 | + | }{ | |
| 228 | + | {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 229 | + | {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 230 | + | {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound}, | |
| 231 | + | {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 232 | + | {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout}, | |
| 233 | + | {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 234 | + | {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 235 | + | {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 236 | + | {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 237 | + | {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 238 | + | {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 239 | + | {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 240 | + | {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 241 | + | {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 242 | + | {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 243 | + | {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 244 | + | {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |
| 245 | + | {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection}, | |
| 246 | + | {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection}, | |
| 247 | + | {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection}, | |
| 248 | + | {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall}, | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | for _, tt := range tests { | |
| 252 | + | t.Run(tt.name, func(t *testing.T) { | |
| 253 | + | before := tt.tree.Clone() | |
| 254 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area) | |
| 255 | + | require.False(t, changed) | |
| 256 | + | require.ErrorIs(t, err, tt.want) | |
| 257 | + | require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree") | |
| 258 | + | }) | |
| 259 | + | } | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func horizontal(columns ...*Node) *Node { | |
| 263 | + | return &Node{Kind: Split, Dir: Horizontal, Children: columns} | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func vertical(ids ...PaneID) *Node { | |
| 267 | + | children := make([]*Node, 0, len(ids)) | |
| 268 | + | for _, id := range ids { | |
| 269 | + | children = append(children, NewLeaf(id)) | |
| 270 | + | } | |
| 271 | + | return verticalNodes(children...) | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func verticalNodes(children ...*Node) *Node { | |
| 275 | + | return &Node{Kind: Split, Dir: Vertical, Children: children} | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | func stack(expanded PaneID, ids ...PaneID) *Node { | |
| 279 | + | children := make([]*Node, 0, len(ids)) | |
| 280 | + | for _, id := range ids { | |
| 281 | + | children = append(children, NewLeaf(id)) | |
| 282 | + | } | |
| 283 | + | return &Node{Kind: Stack, Children: children, Expanded: expanded} | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node { | |
| 287 | + | return &Node{Kind: Leaf, Leaf: id, Weight: weight} | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func consumeExpelWeightedNode(node *Node, weight float64) *Node { | |
| 291 | + | node.Weight = weight | |
| 292 | + | return node | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func requireUniquePaneIDs(t *testing.T, ids []PaneID) { | |
| 296 | + | t.Helper() | |
| 297 | + | seen := make(map[PaneID]struct{}, len(ids)) | |
| 298 | + | for _, id := range ids { | |
| 299 | + | require.NotEmpty(t, id) | |
| 300 | + | _, duplicate := seen[id] | |
| 301 | + | require.False(t, duplicate, "pane ID %q occurs more than once", id) | |
| 302 | + | seen[id] = struct{}{} | |
| 303 | + | } | |
| 304 | + | } | |
| @@ -0,0 +1,304 @@ | |||||
| 1 | + | package layout | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestConsumeOrExpelPaneTransitions(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | area := domain.Rect{Width: 101, Height: 12} | |||
| 14 | + | tests := []struct { | |||
| 15 | + | name string | |||
| 16 | + | tree *Tree | |||
| 17 | + | target PaneID | |||
| 18 | + | dir Direction | |||
| 19 | + | want *Tree | |||
| 20 | + | wantChange bool | |||
| 21 | + | }{ | |||
| 22 | + | { | |||
| 23 | + | name: "consume singleton left into leaf", | |||
| 24 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |||
| 25 | + | target: "b", | |||
| 26 | + | dir: Left, | |||
| 27 | + | want: &Tree{Root: vertical("a", "b"), Focus: "b"}, | |||
| 28 | + | wantChange: true, | |||
| 29 | + | }, | |||
| 30 | + | { | |||
| 31 | + | name: "consume singleton right into leaf", | |||
| 32 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 33 | + | target: "a", | |||
| 34 | + | dir: Right, | |||
| 35 | + | want: &Tree{Root: vertical("b", "a"), Focus: "a"}, | |||
| 36 | + | wantChange: true, | |||
| 37 | + | }, | |||
| 38 | + | { | |||
| 39 | + | name: "consume singleton left into vertical column", | |||
| 40 | + | tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"}, | |||
| 41 | + | target: "c", | |||
| 42 | + | dir: Left, | |||
| 43 | + | want: &Tree{Root: verticalNodes( | |||
| 44 | + | consumeExpelWeightedLeaf("a", 6), | |||
| 45 | + | consumeExpelWeightedLeaf("b", 5), | |||
| 46 | + | NewLeaf("c"), | |||
| 47 | + | ), Focus: "c"}, | |||
| 48 | + | wantChange: true, | |||
| 49 | + | }, | |||
| 50 | + | { | |||
| 51 | + | name: "consume singleton right into vertical column", | |||
| 52 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"}, | |||
| 53 | + | target: "a", | |||
| 54 | + | dir: Right, | |||
| 55 | + | want: &Tree{Root: verticalNodes( | |||
| 56 | + | consumeExpelWeightedLeaf("b", 6), | |||
| 57 | + | consumeExpelWeightedLeaf("c", 5), | |||
| 58 | + | NewLeaf("a"), | |||
| 59 | + | ), Focus: "a"}, | |||
| 60 | + | wantChange: true, | |||
| 61 | + | }, | |||
| 62 | + | { | |||
| 63 | + | name: "consume singleton left into stack appends and expands", | |||
| 64 | + | tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"}, | |||
| 65 | + | target: "c", | |||
| 66 | + | dir: Left, | |||
| 67 | + | want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"}, | |||
| 68 | + | wantChange: true, | |||
| 69 | + | }, | |||
| 70 | + | { | |||
| 71 | + | name: "consume singleton right into stack appends and expands", | |||
| 72 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"}, | |||
| 73 | + | target: "a", | |||
| 74 | + | dir: Right, | |||
| 75 | + | want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"}, | |||
| 76 | + | wantChange: true, | |||
| 77 | + | }, | |||
| 78 | + | { | |||
| 79 | + | name: "expel middle vertical member left from sole root column", | |||
| 80 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"}, | |||
| 81 | + | target: "b", | |||
| 82 | + | dir: Left, | |||
| 83 | + | want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"}, | |||
| 84 | + | wantChange: true, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "expel middle vertical member right from sole root column", | |||
| 88 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"}, | |||
| 89 | + | target: "b", | |||
| 90 | + | dir: Right, | |||
| 91 | + | want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"}, | |||
| 92 | + | wantChange: true, | |||
| 93 | + | }, | |||
| 94 | + | { | |||
| 95 | + | name: "expel vertical member left beside its source column", | |||
| 96 | + | tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"}, | |||
| 97 | + | target: "b", | |||
| 98 | + | dir: Left, | |||
| 99 | + | want: &Tree{Root: horizontal( | |||
| 100 | + | NewLeaf("b"), | |||
| 101 | + | consumeExpelWeightedNode(vertical("a", "c"), 50), | |||
| 102 | + | consumeExpelWeightedLeaf("d", 50), | |||
| 103 | + | ), Focus: "b"}, | |||
| 104 | + | wantChange: true, | |||
| 105 | + | }, | |||
| 106 | + | { | |||
| 107 | + | name: "expel vertical member right beside its source column", | |||
| 108 | + | tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"}, | |||
| 109 | + | target: "b", | |||
| 110 | + | dir: Right, | |||
| 111 | + | want: &Tree{Root: horizontal( | |||
| 112 | + | consumeExpelWeightedNode(vertical("a", "c"), 50), | |||
| 113 | + | NewLeaf("b"), | |||
| 114 | + | consumeExpelWeightedLeaf("d", 50), | |||
| 115 | + | ), Focus: "b"}, | |||
| 116 | + | wantChange: true, | |||
| 117 | + | }, | |||
| 118 | + | { | |||
| 119 | + | name: "expel stack member left from sole root column", | |||
| 120 | + | tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"}, | |||
| 121 | + | target: "b", | |||
| 122 | + | dir: Left, | |||
| 123 | + | want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"}, | |||
| 124 | + | wantChange: true, | |||
| 125 | + | }, | |||
| 126 | + | { | |||
| 127 | + | name: "expel stack member right from sole root column", | |||
| 128 | + | tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"}, | |||
| 129 | + | target: "b", | |||
| 130 | + | dir: Right, | |||
| 131 | + | want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"}, | |||
| 132 | + | wantChange: true, | |||
| 133 | + | }, | |||
| 134 | + | { | |||
| 135 | + | name: "expelling expanded stack member repairs source expansion", | |||
| 136 | + | tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"}, | |||
| 137 | + | target: "b", | |||
| 138 | + | dir: Right, | |||
| 139 | + | want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"}, | |||
| 140 | + | wantChange: true, | |||
| 141 | + | }, | |||
| 142 | + | { | |||
| 143 | + | name: "expel collapses two-member vertical column", | |||
| 144 | + | tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, | |||
| 145 | + | target: "a", | |||
| 146 | + | dir: Left, | |||
| 147 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 148 | + | wantChange: true, | |||
| 149 | + | }, | |||
| 150 | + | { | |||
| 151 | + | name: "expel collapses two-member stack column", | |||
| 152 | + | tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"}, | |||
| 153 | + | target: "b", | |||
| 154 | + | dir: Right, | |||
| 155 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |||
| 156 | + | wantChange: true, | |||
| 157 | + | }, | |||
| 158 | + | { | |||
| 159 | + | name: "real move focuses explicitly targeted non-focused pane", | |||
| 160 | + | tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"}, | |||
| 161 | + | target: "b", | |||
| 162 | + | dir: Right, | |||
| 163 | + | want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"}, | |||
| 164 | + | wantChange: true, | |||
| 165 | + | }, | |||
| 166 | + | { | |||
| 167 | + | name: "left edge singleton is exact no-op and preserves different focus", | |||
| 168 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |||
| 169 | + | target: "a", | |||
| 170 | + | dir: Left, | |||
| 171 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"}, | |||
| 172 | + | wantChange: false, | |||
| 173 | + | }, | |||
| 174 | + | { | |||
| 175 | + | name: "right edge singleton is exact no-op and preserves different focus", | |||
| 176 | + | tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 177 | + | target: "b", | |||
| 178 | + | dir: Right, | |||
| 179 | + | want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"}, | |||
| 180 | + | wantChange: false, | |||
| 181 | + | }, | |||
| 182 | + | { | |||
| 183 | + | name: "sole singleton root is exact no-op", | |||
| 184 | + | tree: NewTree("a"), | |||
| 185 | + | target: "a", | |||
| 186 | + | dir: Left, | |||
| 187 | + | want: NewTree("a"), | |||
| 188 | + | wantChange: false, | |||
| 189 | + | }, | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | for _, tt := range tests { | |||
| 193 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 194 | + | before := tt.tree.Clone() | |||
| 195 | + | beforeIDs := LeafIDs(tt.tree.Root) | |||
| 196 | + | requireUniquePaneIDs(t, beforeIDs) | |||
| 197 | + | ||||
| 198 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area) | |||
| 199 | + | require.NoError(t, err) | |||
| 200 | + | require.Equal(t, tt.wantChange, changed) | |||
| 201 | + | require.Equal(t, tt.want, tt.tree) | |||
| 202 | + | if !tt.wantChange { | |||
| 203 | + | require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree") | |||
| 204 | + | } | |||
| 205 | + | ||||
| 206 | + | afterIDs := LeafIDs(tt.tree.Root) | |||
| 207 | + | require.ElementsMatch(t, beforeIDs, afterIDs) | |||
| 208 | + | require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs") | |||
| 209 | + | requireUniquePaneIDs(t, afterIDs) | |||
| 210 | + | _, solved := Solve(tt.tree.Root, area) | |||
| 211 | + | require.True(t, solved, "every successful result must solve") | |||
| 212 | + | }) | |||
| 213 | + | } | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) { | |||
| 217 | + | t.Parallel() | |||
| 218 | + | ||||
| 219 | + | area := domain.Rect{Width: 101, Height: 12} | |||
| 220 | + | tests := []struct { | |||
| 221 | + | name string | |||
| 222 | + | tree *Tree | |||
| 223 | + | target PaneID | |||
| 224 | + | dir Direction | |||
| 225 | + | area domain.Rect | |||
| 226 | + | want error | |||
| 227 | + | }{ | |||
| 228 | + | {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 229 | + | {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 230 | + | {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound}, | |||
| 231 | + | {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 232 | + | {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 233 | + | {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 234 | + | {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 235 | + | {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 236 | + | {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 237 | + | {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 238 | + | {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 239 | + | {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 240 | + | {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 241 | + | {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 242 | + | {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 243 | + | {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 244 | + | {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout}, | |||
| 245 | + | {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection}, | |||
| 246 | + | {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection}, | |||
| 247 | + | {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection}, | |||
| 248 | + | {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall}, | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | for _, tt := range tests { | |||
| 252 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 253 | + | before := tt.tree.Clone() | |||
| 254 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area) | |||
| 255 | + | require.False(t, changed) | |||
| 256 | + | require.ErrorIs(t, err, tt.want) | |||
| 257 | + | require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree") | |||
| 258 | + | }) | |||
| 259 | + | } | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | func horizontal(columns ...*Node) *Node { | |||
| 263 | + | return &Node{Kind: Split, Dir: Horizontal, Children: columns} | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func vertical(ids ...PaneID) *Node { | |||
| 267 | + | children := make([]*Node, 0, len(ids)) | |||
| 268 | + | for _, id := range ids { | |||
| 269 | + | children = append(children, NewLeaf(id)) | |||
| 270 | + | } | |||
| 271 | + | return verticalNodes(children...) | |||
| 272 | + | } | |||
| 273 | + | ||||
| 274 | + | func verticalNodes(children ...*Node) *Node { | |||
| 275 | + | return &Node{Kind: Split, Dir: Vertical, Children: children} | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | func stack(expanded PaneID, ids ...PaneID) *Node { | |||
| 279 | + | children := make([]*Node, 0, len(ids)) | |||
| 280 | + | for _, id := range ids { | |||
| 281 | + | children = append(children, NewLeaf(id)) | |||
| 282 | + | } | |||
| 283 | + | return &Node{Kind: Stack, Children: children, Expanded: expanded} | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node { | |||
| 287 | + | return &Node{Kind: Leaf, Leaf: id, Weight: weight} | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func consumeExpelWeightedNode(node *Node, weight float64) *Node { | |||
| 291 | + | node.Weight = weight | |||
| 292 | + | return node | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func requireUniquePaneIDs(t *testing.T, ids []PaneID) { | |||
| 296 | + | t.Helper() | |||
| 297 | + | seen := make(map[PaneID]struct{}, len(ids)) | |||
| 298 | + | for _, id := range ids { | |||
| 299 | + | require.NotEmpty(t, id) | |||
| 300 | + | _, duplicate := seen[id] | |||
| 301 | + | require.False(t, duplicate, "pane ID %q occurs more than once", id) | |||
| 302 | + | seen[id] = struct{}{} | |||
| 303 | + | } | |||
| 304 | + | } | |||
M internal/usecase/layout/weights_test.go +166 -0
| @@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) { | |||
| 323 | 323 | } | |
| 324 | 324 | } | |
| 325 | 325 | ||
| 326 | + | func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) { | |
| 327 | + | t.Parallel() | |
| 328 | + | ||
| 329 | + | tests := []struct { | |
| 330 | + | name string | |
| 331 | + | tree *Tree | |
| 332 | + | target PaneID | |
| 333 | + | dir Direction | |
| 334 | + | area domain.Rect | |
| 335 | + | check func(*testing.T, *Tree, []Placement, []Placement) | |
| 336 | + | }{ | |
| 337 | + | { | |
| 338 | + | name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight", | |
| 339 | + | tree: &Tree{Root: weightedNode(horizontal( | |
| 340 | + | weightedNode(vertical("a", "b"), 3), | |
| 341 | + | weightedLeaf("c", 1), | |
| 342 | + | ), 11), Focus: "b"}, | |
| 343 | + | target: "b", | |
| 344 | + | dir: Right, | |
| 345 | + | area: domain.Rect{Width: 202, Height: 5}, | |
| 346 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |
| 347 | + | require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before)) | |
| 348 | + | require.Equal(t, 11.0, tree.Root.Weight) | |
| 349 | + | require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root)) | |
| 350 | + | require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root)) | |
| 351 | + | require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after)) | |
| 352 | + | }, | |
| 353 | + | }, | |
| 354 | + | { | |
| 355 | + | name: "vertical consume preserves solved member shares and gives moved pane default weight", | |
| 356 | + | tree: &Tree{Root: weightedNode(horizontal( | |
| 357 | + | weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2), | |
| 358 | + | weightedLeaf("c", 1), | |
| 359 | + | ), 9), Focus: "c"}, | |
| 360 | + | target: "c", | |
| 361 | + | dir: Left, | |
| 362 | + | area: domain.Rect{Width: 101, Height: 20}, | |
| 363 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |
| 364 | + | require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before)) | |
| 365 | + | require.Equal(t, Vertical, tree.Root.Dir) | |
| 366 | + | require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share") | |
| 367 | + | require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root)) | |
| 368 | + | require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after)) | |
| 369 | + | }, | |
| 370 | + | }, | |
| 371 | + | { | |
| 372 | + | name: "stack consume clears member weights and preserves normalized outer column share", | |
| 373 | + | tree: &Tree{Root: horizontal( | |
| 374 | + | weightedLeaf("x", 1), | |
| 375 | + | weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3), | |
| 376 | + | weightedLeaf("c", 1), | |
| 377 | + | ), Focus: "c"}, | |
| 378 | + | target: "c", | |
| 379 | + | dir: Left, | |
| 380 | + | area: domain.Rect{Width: 103, Height: 4}, | |
| 381 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |
| 382 | + | require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before)) | |
| 383 | + | require.Len(t, tree.Root.Children, 2) | |
| 384 | + | stackColumn := tree.Root.Children[1] | |
| 385 | + | require.Equal(t, Stack, stackColumn.Kind) | |
| 386 | + | require.Equal(t, 61.0, stackColumn.Weight) | |
| 387 | + | require.Equal(t, PaneID("c"), stackColumn.Expanded) | |
| 388 | + | require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn)) | |
| 389 | + | require.Equal(t, 77, placementSpanWidth(after, "a")) | |
| 390 | + | require.Equal(t, 77, placementSpanWidth(after, "b")) | |
| 391 | + | require.Equal(t, 77, placementSpanWidth(after, "c")) | |
| 392 | + | }, | |
| 393 | + | }, | |
| 394 | + | { | |
| 395 | + | name: "stack expel clears remaining weights and repairs expanded member", | |
| 396 | + | tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{ | |
| 397 | + | weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3), | |
| 398 | + | }, Expanded: "b"}, 7), Focus: "b"}, | |
| 399 | + | target: "b", | |
| 400 | + | dir: Right, | |
| 401 | + | area: domain.Rect{Width: 101, Height: 4}, | |
| 402 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |
| 403 | + | require.Equal(t, Horizontal, tree.Root.Dir) | |
| 404 | + | require.Equal(t, 7.0, tree.Root.Weight) | |
| 405 | + | require.Equal(t, []float64{0, 0}, childWeights(tree.Root)) | |
| 406 | + | stackColumn := tree.Root.Children[0] | |
| 407 | + | require.Equal(t, PaneID("a"), stackColumn.Expanded) | |
| 408 | + | require.Equal(t, []float64{0, 0}, childWeights(stackColumn)) | |
| 409 | + | require.Equal(t, 50, placementSpanWidth(after, "a")) | |
| 410 | + | require.Equal(t, 50, placementSpanWidth(after, "c")) | |
| 411 | + | require.Equal(t, 50, placementSpanWidth(after, "b")) | |
| 412 | + | }, | |
| 413 | + | }, | |
| 414 | + | { | |
| 415 | + | name: "column collapse copies normalized container weight to promoted leaf", | |
| 416 | + | tree: &Tree{Root: horizontal( | |
| 417 | + | weightedLeaf("x", 1), | |
| 418 | + | weightedNode(vertical("a", "b"), 3), | |
| 419 | + | weightedLeaf("y", 1), | |
| 420 | + | ), Focus: "a"}, | |
| 421 | + | target: "a", | |
| 422 | + | dir: Left, | |
| 423 | + | area: domain.Rect{Width: 103, Height: 5}, | |
| 424 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |
| 425 | + | require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root)) | |
| 426 | + | require.Equal(t, Leaf, tree.Root.Children[2].Kind) | |
| 427 | + | require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf) | |
| 428 | + | require.Equal(t, 61.0, tree.Root.Children[2].Weight) | |
| 429 | + | require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root)) | |
| 430 | + | require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after)) | |
| 431 | + | }, | |
| 432 | + | }, | |
| 433 | + | { | |
| 434 | + | name: "sole-column expel creates equal default horizontal children and retains wrapper weight", | |
| 435 | + | tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"}, | |
| 436 | + | target: "a", | |
| 437 | + | dir: Left, | |
| 438 | + | area: domain.Rect{Width: 101, Height: 5}, | |
| 439 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |
| 440 | + | require.Equal(t, Horizontal, tree.Root.Dir) | |
| 441 | + | require.Equal(t, 9.0, tree.Root.Weight) | |
| 442 | + | require.Equal(t, []float64{0, 0}, childWeights(tree.Root)) | |
| 443 | + | require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after)) | |
| 444 | + | }, | |
| 445 | + | }, | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | for _, tt := range tests { | |
| 449 | + | t.Run(tt.name, func(t *testing.T) { | |
| 450 | + | beforeIDs := LeafIDs(tt.tree.Root) | |
| 451 | + | requireUniquePaneIDs(t, beforeIDs) | |
| 452 | + | before, solved := Solve(tt.tree.Root, tt.area) | |
| 453 | + | require.True(t, solved, "weight fixture must solve before surgery") | |
| 454 | + | ||
| 455 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area) | |
| 456 | + | require.NoError(t, err) | |
| 457 | + | require.True(t, changed) | |
| 458 | + | require.Equal(t, tt.target, tt.tree.Focus) | |
| 459 | + | ||
| 460 | + | afterIDs := LeafIDs(tt.tree.Root) | |
| 461 | + | require.ElementsMatch(t, beforeIDs, afterIDs) | |
| 462 | + | require.Len(t, afterIDs, len(beforeIDs)) | |
| 463 | + | requireUniquePaneIDs(t, afterIDs) | |
| 464 | + | after, solved := Solve(tt.tree.Root, tt.area) | |
| 465 | + | require.True(t, solved, "weighted result must solve") | |
| 466 | + | tt.check(t, tt.tree, before, after) | |
| 467 | + | }) | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | func childWeights(node *Node) []float64 { | |
| 472 | + | weights := make([]float64, len(node.Children)) | |
| 473 | + | for i, child := range node.Children { | |
| 474 | + | weights[i] = child.Weight | |
| 475 | + | } | |
| 476 | + | return weights | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | func placementSpanWidth(placements []Placement, id PaneID) int { | |
| 480 | + | for _, placement := range placements { | |
| 481 | + | if placement.ID != id { | |
| 482 | + | continue | |
| 483 | + | } | |
| 484 | + | if placement.Content.Width > 0 { | |
| 485 | + | return placement.Content.Width | |
| 486 | + | } | |
| 487 | + | return placement.TitleBar.Width | |
| 488 | + | } | |
| 489 | + | return 0 | |
| 490 | + | } | |
| 491 | + | ||
| 326 | 492 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | |
| 327 | 493 | t.Parallel() | |
| 328 | 494 | ||
| @@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) { | |||||
| 323 | } | 323 | } | ||
| 324 | } | 324 | } | ||
| 325 | 325 | ||||
| 326 | + | func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) { | |||
| 327 | + | t.Parallel() | |||
| 328 | + | ||||
| 329 | + | tests := []struct { | |||
| 330 | + | name string | |||
| 331 | + | tree *Tree | |||
| 332 | + | target PaneID | |||
| 333 | + | dir Direction | |||
| 334 | + | area domain.Rect | |||
| 335 | + | check func(*testing.T, *Tree, []Placement, []Placement) | |||
| 336 | + | }{ | |||
| 337 | + | { | |||
| 338 | + | name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight", | |||
| 339 | + | tree: &Tree{Root: weightedNode(horizontal( | |||
| 340 | + | weightedNode(vertical("a", "b"), 3), | |||
| 341 | + | weightedLeaf("c", 1), | |||
| 342 | + | ), 11), Focus: "b"}, | |||
| 343 | + | target: "b", | |||
| 344 | + | dir: Right, | |||
| 345 | + | area: domain.Rect{Width: 202, Height: 5}, | |||
| 346 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |||
| 347 | + | require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before)) | |||
| 348 | + | require.Equal(t, 11.0, tree.Root.Weight) | |||
| 349 | + | require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root)) | |||
| 350 | + | require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root)) | |||
| 351 | + | require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after)) | |||
| 352 | + | }, | |||
| 353 | + | }, | |||
| 354 | + | { | |||
| 355 | + | name: "vertical consume preserves solved member shares and gives moved pane default weight", | |||
| 356 | + | tree: &Tree{Root: weightedNode(horizontal( | |||
| 357 | + | weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2), | |||
| 358 | + | weightedLeaf("c", 1), | |||
| 359 | + | ), 9), Focus: "c"}, | |||
| 360 | + | target: "c", | |||
| 361 | + | dir: Left, | |||
| 362 | + | area: domain.Rect{Width: 101, Height: 20}, | |||
| 363 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |||
| 364 | + | require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before)) | |||
| 365 | + | require.Equal(t, Vertical, tree.Root.Dir) | |||
| 366 | + | require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share") | |||
| 367 | + | require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root)) | |||
| 368 | + | require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after)) | |||
| 369 | + | }, | |||
| 370 | + | }, | |||
| 371 | + | { | |||
| 372 | + | name: "stack consume clears member weights and preserves normalized outer column share", | |||
| 373 | + | tree: &Tree{Root: horizontal( | |||
| 374 | + | weightedLeaf("x", 1), | |||
| 375 | + | weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3), | |||
| 376 | + | weightedLeaf("c", 1), | |||
| 377 | + | ), Focus: "c"}, | |||
| 378 | + | target: "c", | |||
| 379 | + | dir: Left, | |||
| 380 | + | area: domain.Rect{Width: 103, Height: 4}, | |||
| 381 | + | check: func(t *testing.T, tree *Tree, before, after []Placement) { | |||
| 382 | + | require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before)) | |||
| 383 | + | require.Len(t, tree.Root.Children, 2) | |||
| 384 | + | stackColumn := tree.Root.Children[1] | |||
| 385 | + | require.Equal(t, Stack, stackColumn.Kind) | |||
| 386 | + | require.Equal(t, 61.0, stackColumn.Weight) | |||
| 387 | + | require.Equal(t, PaneID("c"), stackColumn.Expanded) | |||
| 388 | + | require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn)) | |||
| 389 | + | require.Equal(t, 77, placementSpanWidth(after, "a")) | |||
| 390 | + | require.Equal(t, 77, placementSpanWidth(after, "b")) | |||
| 391 | + | require.Equal(t, 77, placementSpanWidth(after, "c")) | |||
| 392 | + | }, | |||
| 393 | + | }, | |||
| 394 | + | { | |||
| 395 | + | name: "stack expel clears remaining weights and repairs expanded member", | |||
| 396 | + | tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{ | |||
| 397 | + | weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3), | |||
| 398 | + | }, Expanded: "b"}, 7), Focus: "b"}, | |||
| 399 | + | target: "b", | |||
| 400 | + | dir: Right, | |||
| 401 | + | area: domain.Rect{Width: 101, Height: 4}, | |||
| 402 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |||
| 403 | + | require.Equal(t, Horizontal, tree.Root.Dir) | |||
| 404 | + | require.Equal(t, 7.0, tree.Root.Weight) | |||
| 405 | + | require.Equal(t, []float64{0, 0}, childWeights(tree.Root)) | |||
| 406 | + | stackColumn := tree.Root.Children[0] | |||
| 407 | + | require.Equal(t, PaneID("a"), stackColumn.Expanded) | |||
| 408 | + | require.Equal(t, []float64{0, 0}, childWeights(stackColumn)) | |||
| 409 | + | require.Equal(t, 50, placementSpanWidth(after, "a")) | |||
| 410 | + | require.Equal(t, 50, placementSpanWidth(after, "c")) | |||
| 411 | + | require.Equal(t, 50, placementSpanWidth(after, "b")) | |||
| 412 | + | }, | |||
| 413 | + | }, | |||
| 414 | + | { | |||
| 415 | + | name: "column collapse copies normalized container weight to promoted leaf", | |||
| 416 | + | tree: &Tree{Root: horizontal( | |||
| 417 | + | weightedLeaf("x", 1), | |||
| 418 | + | weightedNode(vertical("a", "b"), 3), | |||
| 419 | + | weightedLeaf("y", 1), | |||
| 420 | + | ), Focus: "a"}, | |||
| 421 | + | target: "a", | |||
| 422 | + | dir: Left, | |||
| 423 | + | area: domain.Rect{Width: 103, Height: 5}, | |||
| 424 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |||
| 425 | + | require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root)) | |||
| 426 | + | require.Equal(t, Leaf, tree.Root.Children[2].Kind) | |||
| 427 | + | require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf) | |||
| 428 | + | require.Equal(t, 61.0, tree.Root.Children[2].Weight) | |||
| 429 | + | require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root)) | |||
| 430 | + | require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after)) | |||
| 431 | + | }, | |||
| 432 | + | }, | |||
| 433 | + | { | |||
| 434 | + | name: "sole-column expel creates equal default horizontal children and retains wrapper weight", | |||
| 435 | + | tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"}, | |||
| 436 | + | target: "a", | |||
| 437 | + | dir: Left, | |||
| 438 | + | area: domain.Rect{Width: 101, Height: 5}, | |||
| 439 | + | check: func(t *testing.T, tree *Tree, _, after []Placement) { | |||
| 440 | + | require.Equal(t, Horizontal, tree.Root.Dir) | |||
| 441 | + | require.Equal(t, 9.0, tree.Root.Weight) | |||
| 442 | + | require.Equal(t, []float64{0, 0}, childWeights(tree.Root)) | |||
| 443 | + | require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after)) | |||
| 444 | + | }, | |||
| 445 | + | }, | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | for _, tt := range tests { | |||
| 449 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 450 | + | beforeIDs := LeafIDs(tt.tree.Root) | |||
| 451 | + | requireUniquePaneIDs(t, beforeIDs) | |||
| 452 | + | before, solved := Solve(tt.tree.Root, tt.area) | |||
| 453 | + | require.True(t, solved, "weight fixture must solve before surgery") | |||
| 454 | + | ||||
| 455 | + | changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area) | |||
| 456 | + | require.NoError(t, err) | |||
| 457 | + | require.True(t, changed) | |||
| 458 | + | require.Equal(t, tt.target, tt.tree.Focus) | |||
| 459 | + | ||||
| 460 | + | afterIDs := LeafIDs(tt.tree.Root) | |||
| 461 | + | require.ElementsMatch(t, beforeIDs, afterIDs) | |||
| 462 | + | require.Len(t, afterIDs, len(beforeIDs)) | |||
| 463 | + | requireUniquePaneIDs(t, afterIDs) | |||
| 464 | + | after, solved := Solve(tt.tree.Root, tt.area) | |||
| 465 | + | require.True(t, solved, "weighted result must solve") | |||
| 466 | + | tt.check(t, tt.tree, before, after) | |||
| 467 | + | }) | |||
| 468 | + | } | |||
| 469 | + | } | |||
| 470 | + | ||||
| 471 | + | func childWeights(node *Node) []float64 { | |||
| 472 | + | weights := make([]float64, len(node.Children)) | |||
| 473 | + | for i, child := range node.Children { | |||
| 474 | + | weights[i] = child.Weight | |||
| 475 | + | } | |||
| 476 | + | return weights | |||
| 477 | + | } | |||
| 478 | + | ||||
| 479 | + | func placementSpanWidth(placements []Placement, id PaneID) int { | |||
| 480 | + | for _, placement := range placements { | |||
| 481 | + | if placement.ID != id { | |||
| 482 | + | continue | |||
| 483 | + | } | |||
| 484 | + | if placement.Content.Width > 0 { | |||
| 485 | + | return placement.Content.Width | |||
| 486 | + | } | |||
| 487 | + | return placement.TitleBar.Width | |||
| 488 | + | } | |||
| 489 | + | return 0 | |||
| 490 | + | } | |||
| 491 | + | ||||
| 326 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | 492 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | ||
| 327 | t.Parallel() | 493 | t.Parallel() | ||
| 328 | 494 | ||||