merge(main): integrate latest main changes
53 files · +4093 -1343
M README.md +1 -1
| @@ -52,7 +52,7 @@ No prefix key; everything is Alt. | |||||
| 52 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | 52 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | ||
| 53 | | Alt+a | jump to a session needing attention | | 53 | | Alt+a | jump to a session needing attention | | ||
| 54 | 54 | ||||
| 55 | - | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. `MPN` (`Move pane to tab`) and `MTB` (`Move tab to session`) open live-destination pickers. Named active and stopped sessions are fuzzy-searchable for navigation, 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. | 55 | + | The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. `MPN` (`Move pane to tab`) and `MTB` (`Move tab to session`) open live-destination pickers; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable for navigation, 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. |
| 56 | 56 | ||||
| 57 | ## Remote attach | 57 | ## Remote attach | ||
| 58 | 58 | ||||
M docs/configuration.md +21 -2
| @@ -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. | ||
| @@ -116,13 +129,19 @@ copy.word-separators = " -_@" | |||||
| 116 | 129 | ||||
| 117 | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | 130 | Set `copy.word-separators = ""` to use only Unicode whitespace as a separator. | ||
| 118 | 131 | ||||
| 132 | + | ## Responsive overlays | |||
| 133 | + | ||||
| 134 | + | On complete frames below 80 columns, interactive overlays use full-width bottom drawers immediately above the bottom bar. This applies to the floating terminal, command palette, session picker, notification history, prompts, and copy search. A drawer keeps its overlay's preferred outer height, capped at the frame height minus four rows, so frame rows 0–2 remain visible. Drawers use only a top border. | |||
| 135 | + | ||||
| 136 | + | Opening an interactive overlay dims the complete underlying frame, including panes, both bars, notices, and any lower-priority overlay. Notice toasts remain compact rather than becoming drawers. Copy mode remains full-screen; only its search prompt uses the responsive drawer. | |||
| 137 | + | ||||
| 119 | ## Palette anchor | 138 | ## Palette anchor | ||
| 120 | 139 | ||||
| 121 | - | The command palette is centered by default. Set `palette.anchor` to another anchor to reposition it, or use `auto` for a bottom shelf on narrow terminals and a bottom-right rail from 96 columns up. Reload moves an open palette without losing your query. | 140 | + | The command palette is centered by default. With `palette.anchor = auto`, it uses a full-width bottom shelf from 80 through 95 columns and a 64-column bottom-right rail from 96 columns up. Set an explicit anchor to position the shelf or rail. Below 80 columns, the palette is always a bottom drawer, so an explicit anchor does not override the responsive layout. Reload moves an open palette without losing your query. |
| 122 | 141 | ||||
| 123 | ## Floating terminal | 142 | ## Floating terminal | ||
| 124 | 143 | ||||
| 125 | - | The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Floating state is not restored across daemon restarts. | 144 | + | The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Below 80 columns, `floating.height` continues to determine the drawer's outer height, capped to preserve the first three frame rows and the bottom bar; `floating.width` is replaced by the full frame width. Floating state is not restored across daemon restarts. |
| 126 | 145 | ||||
| 127 | ## Bar anchors | 146 | ## Bar anchors | ||
| 128 | 147 | ||||
M internal/app/cmd_test.go +8 -3
| @@ -26,6 +26,8 @@ func TestParseCmdArgs(t *testing.T) { | |||||
| 26 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | 26 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | ||
| 27 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | 27 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | ||
| 28 | {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false}, | 28 | {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false}, | ||
| 29 | + | {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false}, | |||
| 30 | + | {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false}, | |||
| 29 | {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, | 31 | {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, | ||
| 30 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | 32 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | ||
| 31 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | 33 | {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false}, | ||
| @@ -94,7 +96,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) { | |||||
| 94 | if called { | 96 | if called { | ||
| 95 | t.Fatal("help dialed daemon") | 97 | t.Fatal("help dialed daemon") | ||
| 96 | } | 98 | } | ||
| 97 | - | 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"} { | 99 | + | 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"} { |
| 98 | if !strings.Contains(out.String(), want) { | 100 | if !strings.Contains(out.String(), want) { | ||
| 99 | t.Fatalf("help %q missing %q", out.String(), want) | 101 | t.Fatalf("help %q missing %q", out.String(), want) | ||
| 100 | } | 102 | } | ||
| @@ -173,8 +175,11 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) { | |||||
| 173 | } | 175 | } | ||
| 174 | } | 176 | } | ||
| 175 | 177 | ||||
| 176 | - | func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) { | 178 | + | func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { |
| 177 | - | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | 179 | + | for _, slug := range []string{ |
| 180 | + | "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", | |||
| 181 | + | "consume-or-expel-pane-left", "consume-or-expel-pane-right", | |||
| 182 | + | } { | |||
| 178 | t.Run(slug, func(t *testing.T) { | 183 | t.Run(slug, func(t *testing.T) { | ||
| 179 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | 184 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | ||
| 180 | require.NoError(t, err) | 185 | require.NoError(t, err) | ||
M internal/usecase/command/command.go +4 -0
| @@ -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 | ||
| @@ -131,6 +133,8 @@ type ControlContext interface { | |||||
| 131 | SplitLeft() error | 133 | SplitLeft() error | ||
| 132 | SplitUp() error | 134 | SplitUp() error | ||
| 133 | SplitDown() error | 135 | SplitDown() error | ||
| 136 | + | ConsumeOrExpelPaneLeft() error | |||
| 137 | + | ConsumeOrExpelPaneRight() error | |||
| 134 | StackPane() error | 138 | StackPane() error | ||
| 135 | ToggleStack() error | 139 | ToggleStack() error | ||
| 136 | GrowPaneWidth() error | 140 | 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 | 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 | 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 | 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", "MPN", "MTB", "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", "MPN", "MTB", "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)) | ||
| @@ -115,6 +115,8 @@ func TestBySlugExactMatch(t *testing.T) { | |||||
| 115 | ok bool | 115 | ok bool | ||
| 116 | }{ | 116 | }{ | ||
| 117 | {slug: "split-right", ok: true}, | 117 | {slug: "split-right", ok: true}, | ||
| 118 | + | {slug: "consume-or-expel-pane-left", ok: true}, | |||
| 119 | + | {slug: "consume-or-expel-pane-right", ok: true}, | |||
| 118 | {slug: "SPLIT-RIGHT", ok: false}, | 120 | {slug: "SPLIT-RIGHT", ok: false}, | ||
| 119 | {slug: "toast", ok: true}, | 121 | {slug: "toast", ok: true}, | ||
| 120 | {slug: "list-panes", ok: true}, | 122 | {slug: "list-panes", ok: true}, | ||
| @@ -132,7 +134,8 @@ func TestBySlugExactMatch(t *testing.T) { | |||||
| 132 | func TestRegistryControlMetadata(t *testing.T) { | 134 | func TestRegistryControlMetadata(t *testing.T) { | ||
| 133 | scriptable := map[string]TargetKind{ | 135 | scriptable := map[string]TargetKind{ | ||
| 134 | "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane, | 136 | "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane, | ||
| 135 | - | "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane, | 137 | + | "split-down": TargetPane, "consume-or-expel-pane-left": TargetPane, |
| 138 | + | "consume-or-expel-pane-right": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane, | |||
| 136 | "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane, | 139 | "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane, | ||
| 137 | "focus-pane-up": TargetPane, "focus-pane-down": TargetPane, | 140 | "focus-pane-up": TargetPane, "focus-pane-down": TargetPane, | ||
| 138 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | 141 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | ||
| @@ -217,6 +220,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||||
| 217 | }{ | 220 | }{ | ||
| 218 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | 221 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | ||
| 219 | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | 222 | {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | ||
| 223 | + | {name: "consume or expel left delegates", slug: "consume-or-expel-pane-left", wantCall: "consume-or-expel-pane-left"}, | |||
| 224 | + | {name: "consume or expel right delegates", slug: "consume-or-expel-pane-right", wantCall: "consume-or-expel-pane-right"}, | |||
| 220 | {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"}, | 225 | {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"}, | ||
| 221 | {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"}, | 226 | {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"}, | ||
| 222 | {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"}, | 227 | {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"}, | ||
| @@ -267,6 +272,8 @@ func (s *controlSpy) SplitRight() error { return s.record("split-ri | |||||
| 267 | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | 272 | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | ||
| 268 | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | 273 | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | ||
| 269 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | 274 | func (s *controlSpy) SplitDown() error { return s.record("split-down") } | ||
| 275 | + | func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") } | |||
| 276 | + | func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") } | |||
| 270 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | 277 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | ||
| 271 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | 278 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | ||
| 272 | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | 279 | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | ||
| @@ -360,6 +367,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||||
| 360 | {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }}, | 367 | {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }}, | ||
| 361 | {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }}, | 368 | {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }}, | ||
| 362 | {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }}, | 369 | {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }}, | ||
| 370 | + | {code: "CEL", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneLeft().Return(nil).Once() }}, | |||
| 371 | + | {code: "CER", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneRight().Return(nil).Once() }}, | |||
| 363 | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | 372 | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | ||
| 364 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | 373 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | ||
| 365 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | 374 | {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }}, | ||
M internal/usecase/daemon/control.go +20 -2
| @@ -226,6 +226,7 @@ const ( | |||||
| 226 | daemonActionRenameTab | 226 | daemonActionRenameTab | ||
| 227 | daemonActionResizePane | 227 | daemonActionResizePane | ||
| 228 | daemonActionEqualizePanes | 228 | daemonActionEqualizePanes | ||
| 229 | + | daemonActionConsumeOrExpelPane | |||
| 229 | ) | 230 | ) | ||
| 230 | 231 | ||||
| 231 | type daemonActionTarget struct { | 232 | type daemonActionTarget struct { | ||
| @@ -309,6 +310,8 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||||
| 309 | return a.d.resizePane(target, request.axis, request.delta) | 310 | return a.d.resizePane(target, request.axis, request.delta) | ||
| 310 | case daemonActionEqualizePanes: | 311 | case daemonActionEqualizePanes: | ||
| 311 | return a.d.equalizePanes(target) | 312 | return a.d.equalizePanes(target) | ||
| 313 | + | case daemonActionConsumeOrExpelPane: | |||
| 314 | + | return a.d.consumeOrExpelPane(target, request.direction) | |||
| 312 | default: | 315 | default: | ||
| 313 | return errors.New("daemon: unknown action") | 316 | return errors.New("daemon: unknown action") | ||
| 314 | } | 317 | } | ||
| @@ -365,13 +368,19 @@ func (e controlExec) runAction(request daemonActionRequest) error { | |||||
| 365 | runner = daemonActions{d: e.d} | 368 | runner = daemonActions{d: e.d} | ||
| 366 | } | 369 | } | ||
| 367 | err := runner.Run(request) | 370 | err := runner.Run(request) | ||
| 368 | - | if err == nil && e.actions == nil { | 371 | + | if errors.Is(err, errDaemonActionNoChange) { |
| 372 | + | return nil | |||
| 373 | + | } | |||
| 374 | + | if err != nil { | |||
| 375 | + | return err | |||
| 376 | + | } | |||
| 377 | + | if e.actions == nil { | |||
| 369 | e.sess.mu.Lock() | 378 | e.sess.mu.Lock() | ||
| 370 | ac := e.sess.client | 379 | ac := e.sess.client | ||
| 371 | e.sess.mu.Unlock() | 380 | e.sess.mu.Unlock() | ||
| 372 | finishDaemonActionForClient(e.d, request, ac, "control.go") | 381 | finishDaemonActionForClient(e.d, request, ac, "control.go") | ||
| 373 | } | 382 | } | ||
| 374 | - | return err | 383 | + | return nil |
| 375 | } | 384 | } | ||
| 376 | 385 | ||||
| 377 | func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) { | 386 | func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) { | ||
| @@ -465,6 +474,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) } | |||||
| 465 | func (e controlExec) SplitLeft() error { return e.split(layout.Left) } | 474 | func (e controlExec) SplitLeft() error { return e.split(layout.Left) } | ||
| 466 | func (e controlExec) SplitUp() error { return e.split(layout.Up) } | 475 | func (e controlExec) SplitUp() error { return e.split(layout.Up) } | ||
| 467 | func (e controlExec) SplitDown() error { return e.split(layout.Down) } | 476 | func (e controlExec) SplitDown() error { return e.split(layout.Down) } | ||
| 477 | + | func (e controlExec) consumeOrExpelPane(direction layout.Direction) error { | |||
| 478 | + | return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction}) | |||
| 479 | + | } | |||
| 480 | + | func (e controlExec) ConsumeOrExpelPaneLeft() error { | |||
| 481 | + | return e.consumeOrExpelPane(layout.Left) | |||
| 482 | + | } | |||
| 483 | + | func (e controlExec) ConsumeOrExpelPaneRight() error { | |||
| 484 | + | return e.consumeOrExpelPane(layout.Right) | |||
| 485 | + | } | |||
| 468 | func (e controlExec) StackPane() error { | 486 | func (e controlExec) StackPane() error { | ||
| 469 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | 487 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | ||
| 470 | } | 488 | } | ||
M internal/usecase/daemon/control_test.go +92 -21
| @@ -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/copymode_test.go +1 -1
| @@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil } | |||||
| 100 | func TestCopySearchModalGeometry(t *testing.T) { | 100 | func TestCopySearchModalGeometry(t *testing.T) { | ||
| 101 | base := domain.Size{Cols: 100, Rows: 40} | 101 | base := domain.Size{Cols: 100, Rows: 40} | ||
| 102 | 102 | ||||
| 103 | - | require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base)) | 103 | + | require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Resolve(base).Bounds) |
| 104 | require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor) | 104 | require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor) | ||
| 105 | require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins) | 105 | require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins) | ||
| 106 | } | 106 | } | ||
M internal/usecase/daemon/floating.go +3 -7
| @@ -144,7 +144,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, | |||||
| 144 | p.mu.Lock() | 144 | p.mu.Lock() | ||
| 145 | geometry := p.committedFloatingGeometryLocked(desired) | 145 | geometry := p.committedFloatingGeometryLocked(desired) | ||
| 146 | p.mu.Unlock() | 146 | p.mu.Unlock() | ||
| 147 | - | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | 147 | + | if !geometry.committable() { |
| 148 | return nil, floatingGeometry{}, false | 148 | return nil, floatingGeometry{}, false | ||
| 149 | } | 149 | } | ||
| 150 | return p, geometry, true | 150 | return p, geometry, true | ||
| @@ -345,11 +345,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 345 | sess.mu.Unlock() | 345 | sess.mu.Unlock() | ||
| 346 | tb.mu.Lock() | 346 | tb.mu.Lock() | ||
| 347 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | 347 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | ||
| 348 | - | size := rectSize(geometry.Inner) | 348 | + | size := rectSize(geometry.ptyRect()) |
| 349 | - | if !size.Valid() { | |||
| 350 | - | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} | |||
| 351 | - | size = rectSize(geometry.Inner) | |||
| 352 | - | } | |||
| 353 | focused := tb.focusedPane() | 349 | focused := tb.focusedPane() | ||
| 354 | tabStableID, tabCtx := tb.stableID, tb.ctx | 350 | tabStableID, tabCtx := tb.stableID, tb.ctx | ||
| 355 | tb.mu.Unlock() | 351 | tb.mu.Unlock() | ||
| @@ -419,7 +415,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 419 | return | 415 | return | ||
| 420 | } | 416 | } | ||
| 421 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | 417 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | ||
| 422 | - | p.rect = spec.geometry.Inner | 418 | + | p.rect = spec.geometry.ptyRect() |
| 423 | p.popupGeometry = spec.geometry | 419 | p.popupGeometry = spec.geometry | ||
| 424 | p.title.displayFallback = spec.fallback | 420 | p.title.displayFallback = spec.fallback | ||
| 425 | if !lifetime.publish(p) { | 421 | if !lifetime.publish(p) { | ||
M internal/usecase/daemon/floating_render.go +191 -27
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | ||
| 14 | // must not translate a composed or cached value again. | 14 | // must not translate a composed or cached value again. | ||
| 15 | type floatingGeometry struct { | 15 | type floatingGeometry struct { | ||
| 16 | + | Mode ui.PresentationMode | |||
| 16 | Bounds domain.Rect | 17 | Bounds domain.Rect | ||
| 17 | Inner domain.Rect | 18 | Inner domain.Rect | ||
| 18 | } | 19 | } | ||
| @@ -21,6 +22,22 @@ func (g floatingGeometry) valid() bool { | |||||
| 21 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | 22 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | ||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 25 | + | // committable accepts a drawer with no terminal content. Its presentation must | |||
| 26 | + | // remain canonical even though the PTY and VT screen use a 1x1 fallback. | |||
| 27 | + | func (g floatingGeometry) committable() bool { | |||
| 28 | + | if g.Mode == ui.PresentationDrawer { | |||
| 29 | + | return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0 | |||
| 30 | + | } | |||
| 31 | + | return g.valid() | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (g floatingGeometry) ptyRect() domain.Rect { | |||
| 35 | + | if g.Inner.Width > 0 && g.Inner.Height > 0 { | |||
| 36 | + | return g.Inner | |||
| 37 | + | } | |||
| 38 | + | return domain.Rect{Width: 1, Height: 1} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 24 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | 41 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | ||
| 25 | g.Bounds.X += dx | 42 | g.Bounds.X += dx | ||
| 26 | g.Bounds.Y += dy | 43 | g.Bounds.Y += dy | ||
| @@ -33,7 +50,7 @@ func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |||||
| 33 | // last committed. Newly constructed test panes fall back to the requested | 50 | // last committed. Newly constructed test panes fall back to the requested | ||
| 34 | // geometry until their first resize. The caller holds p.mu. | 51 | // geometry until their first resize. The caller holds p.mu. | ||
| 35 | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | 52 | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | ||
| 36 | - | if p.popupGeometry.valid() { | 53 | + | if p.popupGeometry.committable() { |
| 37 | return p.popupGeometry | 54 | return p.popupGeometry | ||
| 38 | } | 55 | } | ||
| 39 | return fallback | 56 | return fallback | ||
| @@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry | |||||
| 58 | return axis | 75 | return axis | ||
| 59 | } | 76 | } | ||
| 60 | 77 | ||||
| 61 | - | // calculateContentFloatingGeometry returns origin-zero, tab-content-relative | 78 | + | // calculatePreferredFloatingGeometry retains the canonical percentage and |
| 62 | - | // coordinates for a percentage-sized popup. Launch sizing derives from Inner | 79 | + | // tiny-axis rules used by wide floating terminals. |
| 63 | - | // too, so rendering and PTY geometry share the same per-axis percentage and | 80 | + | func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { |
| 64 | - | // tiny-border rules. | |||
| 65 | - | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |||
| 66 | if !content.Valid() { | 81 | if !content.Valid() { | ||
| 67 | return floatingGeometry{} | 82 | return floatingGeometry{} | ||
| 68 | } | 83 | } | ||
| @@ -75,6 +90,7 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||||
| 75 | Height: y.BoundsSize, | 90 | Height: y.BoundsSize, | ||
| 76 | } | 91 | } | ||
| 77 | return floatingGeometry{ | 92 | return floatingGeometry{ | ||
| 93 | + | Mode: ui.PresentationFloating, | |||
| 78 | Bounds: bounds, | 94 | Bounds: bounds, | ||
| 79 | Inner: domain.Rect{ | 95 | Inner: domain.Rect{ | ||
| 80 | X: bounds.X + x.BorderOffset, | 96 | X: bounds.X + x.BorderOffset, | ||
| @@ -85,12 +101,33 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||||
| 85 | } | 101 | } | ||
| 86 | } | 102 | } | ||
| 87 | 103 | ||||
| 104 | + | // calculateContentFloatingGeometry returns canonical tab-content-relative | |||
| 105 | + | // coordinates. Presentation resolution consumes complete-frame coordinates, | |||
| 106 | + | // so the preferred geometry crosses the top-bar boundary exactly once in each | |||
| 107 | + | // direction. | |||
| 108 | + | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |||
| 109 | + | preferred := calculatePreferredFloatingGeometry(content, cfg) | |||
| 110 | + | if !preferred.valid() { | |||
| 111 | + | return floatingGeometry{} | |||
| 112 | + | } | |||
| 113 | + | framePreferred := preferred.translate(0, 1) | |||
| 114 | + | presentation := ui.ResolvePresentation( | |||
| 115 | + | domain.Size{Cols: content.Cols, Rows: content.Rows + 2}, | |||
| 116 | + | framePreferred.Bounds, | |||
| 117 | + | framePreferred.Inner, | |||
| 118 | + | ) | |||
| 119 | + | return floatingGeometry{ | |||
| 120 | + | Mode: presentation.Mode, | |||
| 121 | + | Bounds: presentation.Bounds, | |||
| 122 | + | Inner: presentation.Inner, | |||
| 123 | + | }.translate(0, -1) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 88 | type floatingComposeInput struct { | 126 | type floatingComposeInput struct { | ||
| 89 | baseFrame renderer.Frame | 127 | baseFrame renderer.Frame | ||
| 90 | baseDamage []renderer.Damage | 128 | baseDamage []renderer.Damage | ||
| 91 | floating capturedFloatingRenderState | 129 | floating capturedFloatingRenderState | ||
| 92 | content domain.Rect | 130 | content domain.Rect | ||
| 93 | - | layout capturedTabLayout | |||
| 94 | theme themeui.Theme | 131 | theme themeui.Theme | ||
| 95 | borderMuted renderer.Style | 132 | borderMuted renderer.Style | ||
| 96 | borderActive renderer.Style | 133 | borderActive renderer.Style | ||
| @@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 103 | baseDamage := input.baseDamage | 140 | baseDamage := input.baseDamage | ||
| 104 | floating := input.floating | 141 | floating := input.floating | ||
| 105 | content := input.content | 142 | content := input.content | ||
| 106 | - | layoutSnap := input.layout | |||
| 107 | theme := input.theme | 143 | theme := input.theme | ||
| 108 | borderMuted := input.borderMuted | 144 | borderMuted := input.borderMuted | ||
| 109 | borderActive := input.borderActive | 145 | borderActive := input.borderActive | ||
| @@ -111,10 +147,9 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 111 | full := input.full | 147 | full := input.full | ||
| 112 | 148 | ||||
| 113 | frame := base.Clone() | 149 | frame := base.Clone() | ||
| 114 | - | layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | 150 | + | applyOverlayBackdrop(frame, theme) |
| 115 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme) | |||
| 116 | geometry := floating.geometry.translate(content.X, content.Y) | 151 | geometry := floating.geometry.translate(content.X, content.Y) | ||
| 117 | - | blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme)) | 152 | + | blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame) |
| 118 | damage := append([]renderer.Damage(nil), baseDamage...) | 153 | damage := append([]renderer.Damage(nil), baseDamage...) | ||
| 119 | for _, d := range floating.pane.damage { | 154 | for _, d := range floating.pane.damage { | ||
| 120 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | 155 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | ||
| @@ -125,39 +160,168 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 125 | if floating.focused { | 160 | if floating.focused { | ||
| 126 | border = borderActive | 161 | border = borderActive | ||
| 127 | } | 162 | } | ||
| 128 | - | drawFloatingBorder(frame, geometry.Bounds, floating.title, border) | 163 | + | drawFloatingBorder(frame, geometry, floating.title, border) |
| 129 | if full || popupChanged { | 164 | if full || popupChanged { | ||
| 130 | return frame, []renderer.Damage{renderer.FullRedraw()} | 165 | return frame, []renderer.Damage{renderer.FullRedraw()} | ||
| 131 | } | 166 | } | ||
| 132 | - | if titleChanged && geometry.Bounds.Height >= 3 { | 167 | + | hasTitleBorder := geometry.Bounds.Height >= 3 |
| 133 | - | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1}) | 168 | + | if geometry.Mode == ui.PresentationDrawer { |
| 169 | + | hasTitleBorder = geometry.Bounds.Height > 0 | |||
| 170 | + | } | |||
| 171 | + | if titleChanged && hasTitleBorder { | |||
| 172 | + | if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok { | |||
| 173 | + | damage = append(damage, titleDamage) | |||
| 174 | + | } | |||
| 134 | } | 175 | } | ||
| 135 | return frame, damage | 176 | return frame, damage | ||
| 136 | } | 177 | } | ||
| 137 | 178 | ||||
| 138 | - | func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | 179 | + | type floatingSpan struct { |
| 180 | + | source, destination, length int | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // clippedFloatingSpan intersects a source span placed at origin with the | |||
| 184 | + | // destination interval [0, limit). The source offset preserves pane cell | |||
| 185 | + | // mapping when the retained committed geometry starts above or left of frame. | |||
| 186 | + | func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) { | |||
| 187 | + | if length <= 0 || limit <= 0 { | |||
| 188 | + | return floatingSpan{}, false | |||
| 189 | + | } | |||
| 190 | + | if origin < 0 { | |||
| 191 | + | if origin <= -length { | |||
| 192 | + | return floatingSpan{}, false | |||
| 193 | + | } | |||
| 194 | + | trim := -origin | |||
| 195 | + | length -= trim | |||
| 196 | + | origin = 0 | |||
| 197 | + | if length <= 0 { | |||
| 198 | + | return floatingSpan{}, false | |||
| 199 | + | } | |||
| 200 | + | return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true | |||
| 201 | + | } | |||
| 202 | + | if origin >= limit { | |||
| 203 | + | return floatingSpan{}, false | |||
| 204 | + | } | |||
| 205 | + | length = min(length, limit-origin) | |||
| 206 | + | return floatingSpan{destination: origin, length: length}, length > 0 | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) { | |||
| 210 | + | x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width) | |||
| 211 | + | if !ok { | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height) | |||
| 215 | + | if !ok { | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 218 | + | for dy := range y.length { | |||
| 219 | + | for dx := range x.length { | |||
| 220 | + | dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy)) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) { | |||
| 226 | + | if bounds.Y < 0 || bounds.Y >= frame.Height { | |||
| 227 | + | return renderer.Damage{}, false | |||
| 228 | + | } | |||
| 229 | + | x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width) | |||
| 230 | + | if !ok { | |||
| 231 | + | return renderer.Damage{}, false | |||
| 232 | + | } | |||
| 233 | + | return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) { | |||
| 237 | + | bounds := geometry.Bounds | |||
| 139 | if bounds.Width <= 0 || bounds.Height <= 0 { | 238 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 140 | return | 239 | return | ||
| 141 | } | 240 | } | ||
| 241 | + | if geometry.Mode == ui.PresentationDrawer { | |||
| 242 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style) | |||
| 243 | + | drawFloatingTitle(frame, bounds, title, style) | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 142 | // Each axis omits its borders independently for tiny popups, matching | 246 | // Each axis omits its borders independently for tiny popups, matching | ||
| 143 | // floatingInnerSize and leaving every cell available to the terminal. | 247 | // floatingInnerSize and leaving every cell available to the terminal. | ||
| 248 | + | right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width) | |||
| 249 | + | bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height) | |||
| 144 | if bounds.Height >= 3 { | 250 | if bounds.Height >= 3 { | ||
| 145 | - | for x := bounds.X; x < bounds.X+bounds.Width; x++ { | 251 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style) |
| 146 | - | frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style}) | 252 | + | if hasBottom { |
| 147 | - | frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style}) | 253 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style) |
| 148 | } | 254 | } | ||
| 149 | } | 255 | } | ||
| 150 | if bounds.Width >= 3 { | 256 | if bounds.Width >= 3 { | ||
| 151 | - | for y := bounds.Y; y < bounds.Y+bounds.Height; y++ { | 257 | + | drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style) |
| 152 | - | frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style}) | 258 | + | if hasRight { |
| 153 | - | frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style}) | 259 | + | drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style) |
| 154 | } | 260 | } | ||
| 155 | } | 261 | } | ||
| 156 | - | if bounds.Width >= 3 && bounds.Height >= 3 { | 262 | + | if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom { |
| 157 | - | frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style}) | 263 | + | setFloatingCell(frame, bounds.X, bounds.Y, '┌', style) |
| 158 | - | frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style}) | 264 | + | setFloatingCell(frame, right, bounds.Y, '┐', style) |
| 159 | - | frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style}) | 265 | + | setFloatingCell(frame, bounds.X, bottom, '└', style) |
| 160 | - | frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style}) | 266 | + | setFloatingCell(frame, right, bottom, '┘', style) |
| 161 | - | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | 267 | + | drawFloatingTitle(frame, bounds, title, style) |
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) { | |||
| 272 | + | if y < 0 || y >= frame.Height { | |||
| 273 | + | return | |||
| 274 | + | } | |||
| 275 | + | span, ok := clippedFloatingSpan(x, width, frame.Width) | |||
| 276 | + | if !ok { | |||
| 277 | + | return | |||
| 278 | + | } | |||
| 279 | + | for dx := range span.length { | |||
| 280 | + | frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style}) | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) { | |||
| 285 | + | if x < 0 || x >= frame.Width { | |||
| 286 | + | return | |||
| 287 | + | } | |||
| 288 | + | span, ok := clippedFloatingSpan(y, height, frame.Height) | |||
| 289 | + | if !ok { | |||
| 290 | + | return | |||
| 291 | + | } | |||
| 292 | + | for dy := range span.length { | |||
| 293 | + | frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style}) | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) { | |||
| 298 | + | if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height { | |||
| 299 | + | return | |||
| 300 | + | } | |||
| 301 | + | frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style}) | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | func floatingLastCoordinate(origin, size int) (int, bool) { | |||
| 305 | + | if size <= 0 { | |||
| 306 | + | return 0, false | |||
| 307 | + | } | |||
| 308 | + | last := origin + size - 1 | |||
| 309 | + | if last < origin { | |||
| 310 | + | return 0, false | |||
| 311 | + | } | |||
| 312 | + | return last, true | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) { | |||
| 316 | + | const inset = 2 | |||
| 317 | + | titleWidth := bounds.Width - 2*inset | |||
| 318 | + | if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset { | |||
| 319 | + | return | |||
| 320 | + | } | |||
| 321 | + | titleX := bounds.X + inset | |||
| 322 | + | span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width) | |||
| 323 | + | if !ok { | |||
| 324 | + | return | |||
| 162 | } | 325 | } | ||
| 326 | + | ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style) | |||
| 163 | } | 327 | } | ||
M internal/usecase/daemon/floating_render_test.go +284 -13
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "github.com/bnema/vev/internal/domain" | 12 | "github.com/bnema/vev/internal/domain" | ||
| 13 | "github.com/bnema/vev/internal/usecase/layout" | 13 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 15 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 15 | "github.com/bnema/vev/pkg/renderer" | 16 | "github.com/bnema/vev/pkg/renderer" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| @@ -39,21 +40,66 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr | |||||
| 39 | 40 | ||||
| 40 | func TestFloatingGeometryTranslate(t *testing.T) { | 41 | func TestFloatingGeometryTranslate(t *testing.T) { | ||
| 41 | geometry := floatingGeometry{ | 42 | geometry := floatingGeometry{ | ||
| 43 | + | Mode: ui.PresentationDrawer, | |||
| 42 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | 44 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | ||
| 43 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | 45 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | ||
| 44 | } | 46 | } | ||
| 45 | 47 | ||||
| 46 | translated := geometry.translate(11, 13) | 48 | translated := geometry.translate(11, 13) | ||
| 47 | require.Equal(t, floatingGeometry{ | 49 | require.Equal(t, floatingGeometry{ | ||
| 50 | + | Mode: ui.PresentationDrawer, | |||
| 48 | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | 51 | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | ||
| 49 | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | 52 | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | ||
| 50 | }, translated) | 53 | }, translated) | ||
| 51 | require.Equal(t, floatingGeometry{ | 54 | require.Equal(t, floatingGeometry{ | ||
| 55 | + | Mode: ui.PresentationDrawer, | |||
| 52 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | 56 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | ||
| 53 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | 57 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | ||
| 54 | }, geometry) | 58 | }, geometry) | ||
| 55 | } | 59 | } | ||
| 56 | 60 | ||||
| 61 | + | func TestResponsiveFloatingGeometry(t *testing.T) { | |||
| 62 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 63 | + | tests := []struct { | |||
| 64 | + | name string | |||
| 65 | + | content domain.Size | |||
| 66 | + | want floatingGeometry | |||
| 67 | + | }{ | |||
| 68 | + | { | |||
| 69 | + | name: "narrow full height becomes content-relative drawer", | |||
| 70 | + | content: domain.Size{Cols: 79, Rows: 22}, | |||
| 71 | + | want: floatingGeometry{ | |||
| 72 | + | Mode: ui.PresentationDrawer, | |||
| 73 | + | Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 20}, | |||
| 74 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79, Height: 19}, | |||
| 75 | + | }, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "breakpoint preserves centered floating geometry", | |||
| 79 | + | content: domain.Size{Cols: 80, Rows: 22}, | |||
| 80 | + | want: floatingGeometry{ | |||
| 81 | + | Mode: ui.PresentationFloating, | |||
| 82 | + | Bounds: domain.Rect{Width: 80, Height: 22}, | |||
| 83 | + | Inner: domain.Rect{X: 1, Y: 1, Width: 78, Height: 20}, | |||
| 84 | + | }, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "tiny narrow frame retains zero-content drawer", | |||
| 88 | + | content: domain.Size{Cols: 79, Rows: 2}, | |||
| 89 | + | want: floatingGeometry{ | |||
| 90 | + | Mode: ui.PresentationDrawer, | |||
| 91 | + | Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0}, | |||
| 92 | + | Inner: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0}, | |||
| 93 | + | }, | |||
| 94 | + | }, | |||
| 95 | + | } | |||
| 96 | + | for _, tt := range tests { | |||
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 98 | + | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, cfg)) | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | ||||
| 57 | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | 103 | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | ||
| 58 | const frameX, frameY = 7, 3 | 104 | const frameX, frameY = 7, 3 | ||
| 59 | tests := []struct { | 105 | tests := []struct { | ||
| @@ -93,26 +139,77 @@ func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |||||
| 93 | } | 139 | } | ||
| 94 | } | 140 | } | ||
| 95 | 141 | ||||
| 96 | - | func TestCalculateFloatingGeometry(t *testing.T) { | 142 | + | func TestCalculatePreferredFloatingGeometry(t *testing.T) { |
| 97 | tests := []struct { | 143 | tests := []struct { | ||
| 98 | name string | 144 | name string | ||
| 99 | content domain.Size | 145 | content domain.Size | ||
| 100 | cfg domain.FloatingConfig | 146 | cfg domain.FloatingConfig | ||
| 101 | want floatingGeometry | 147 | want floatingGeometry | ||
| 102 | }{ | 148 | }{ | ||
| 103 | - | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, | 149 | + | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, |
| 104 | - | {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}}, | 150 | + | {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}}, |
| 105 | - | {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}}, | 151 | + | {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}}, |
| 106 | - | {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}}, | 152 | + | {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}}, |
| 107 | - | {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, | 153 | + | {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, |
| 108 | } | 154 | } | ||
| 109 | for _, tt := range tests { | 155 | for _, tt := range tests { | ||
| 110 | t.Run(tt.name, func(t *testing.T) { | 156 | t.Run(tt.name, func(t *testing.T) { | ||
| 111 | - | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, tt.cfg)) | 157 | + | require.Equal(t, tt.want, calculatePreferredFloatingGeometry(tt.content, tt.cfg)) |
| 112 | }) | 158 | }) | ||
| 113 | } | 159 | } | ||
| 114 | } | 160 | } | ||
| 115 | 161 | ||||
| 162 | + | func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testing.T) { | |||
| 163 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 164 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | |||
| 165 | + | require.False(t, geometry.valid()) | |||
| 166 | + | require.True(t, geometry.committable()) | |||
| 167 | + | require.Equal(t, domain.Rect{Width: 1, Height: 1}, geometry.ptyRect()) | |||
| 168 | + | ||||
| 169 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 170 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 171 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 172 | + | sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 173 | + | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true) | |||
| 174 | + | require.NoError(t, err) | |||
| 175 | + | require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation") | |||
| 176 | + | require.Equal(t, domain.Size{Cols: 1, Rows: 1}, spec.size) | |||
| 177 | + | ||||
| 178 | + | floating := newPane("floating", nil, spec.size) | |||
| 179 | + | floating.popupGeometry = spec.geometry | |||
| 180 | + | installTestFloating(tb, floating, true) | |||
| 181 | + | tb.mu.Lock() | |||
| 182 | + | gotPane, gotGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |||
| 183 | + | require.Same(t, floating, tb.terminalTargetLocked()) | |||
| 184 | + | tb.mu.Unlock() | |||
| 185 | + | require.True(t, visible) | |||
| 186 | + | require.Same(t, floating, gotPane) | |||
| 187 | + | require.Equal(t, geometry, gotGeometry) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) { | |||
| 191 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 192 | + | initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |||
| 193 | + | requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | |||
| 194 | + | pty := &resizePTY{} | |||
| 195 | + | floating := newPane("floating", pty, rectSize(initial.Inner)) | |||
| 196 | + | floating.rect = initial.Inner | |||
| 197 | + | floating.popupGeometry = initial | |||
| 198 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 199 | + | installTestFloating(tb, floating, true) | |||
| 200 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 201 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 202 | + | ||||
| 203 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |||
| 204 | + | require.True(t, ok) | |||
| 205 | + | require.Empty(t, failed) | |||
| 206 | + | require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize") | |||
| 207 | + | require.Equal(t, requested, floating.popupGeometry) | |||
| 208 | + | require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size") | |||
| 209 | + | require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width) | |||
| 210 | + | require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height) | |||
| 211 | + | } | |||
| 212 | + | ||||
| 116 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | 213 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | ||
| 117 | tests := []struct { | 214 | tests := []struct { | ||
| 118 | name string | 215 | name string | ||
| @@ -133,9 +230,9 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |||||
| 133 | } | 230 | } | ||
| 134 | 231 | ||||
| 135 | func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | 232 | func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | ||
| 136 | - | base := renderer.NewFrame(40, 12) | 233 | + | base := renderer.NewFrame(80, 12) |
| 137 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | 234 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | ||
| 138 | - | content := domain.Rect{Y: 1, Width: 40, Height: 10} | 235 | + | content := domain.Rect{Y: 1, Width: 80, Height: 10} |
| 139 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | 236 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | ||
| 140 | paneFrame := renderer.NewFrame(6, 3) | 237 | paneFrame := renderer.NewFrame(6, 3) | ||
| 141 | paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'}) | 238 | paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'}) | ||
| @@ -181,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi | |||||
| 181 | require.Equal(t, 1, damage[0].Height) | 278 | require.Equal(t, 1, damage[0].Height) | ||
| 182 | } | 279 | } | ||
| 183 | 280 | ||||
| 281 | + | func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *testing.T) { | |||
| 282 | + | base := renderer.NewFrame(15, 7) | |||
| 283 | + | for y := range base.Height { | |||
| 284 | + | for x := range base.Width { | |||
| 285 | + | base.Set(x, y, renderer.Cell{Rune: '·'}) | |||
| 286 | + | } | |||
| 287 | + | } | |||
| 288 | + | content := domain.Rect{Y: 1, Width: 15, Height: 5} | |||
| 289 | + | retained := calculateContentFloatingGeometry( | |||
| 290 | + | domain.Size{Cols: 100, Rows: 30}, | |||
| 291 | + | domain.FloatingConfig{Width: 80, Height: 80}, | |||
| 292 | + | ) | |||
| 293 | + | pty := &resizePTY{err: errors.New("resize failed")} | |||
| 294 | + | floatingPane := newPane("floating", pty, rectSize(retained.Inner)) | |||
| 295 | + | floatingPane.rect = retained.Inner | |||
| 296 | + | floatingPane.popupGeometry = retained | |||
| 297 | + | for y := range floatingPane.screen.Frame.Height { | |||
| 298 | + | for x := range floatingPane.screen.Frame.Width { | |||
| 299 | + | floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)}) | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80}) | |||
| 303 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 304 | + | require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested)) | |||
| 305 | + | require.Equal(t, retained, floatingPane.popupGeometry, "failed resize must retain the committed geometry") | |||
| 306 | + | floatingPane.mu.Lock() | |||
| 307 | + | captured := capturePaneRenderStateLocked(floatingPane, retained.Inner) | |||
| 308 | + | floatingPane.mu.Unlock() | |||
| 309 | + | ||||
| 310 | + | var frame renderer.Frame | |||
| 311 | + | require.NotPanics(t, func() { | |||
| 312 | + | frame, _ = composeCapturedFloatingFrame(floatingComposeInput{ | |||
| 313 | + | baseFrame: base, | |||
| 314 | + | floating: capturedFloatingRenderState{ | |||
| 315 | + | visible: true, | |||
| 316 | + | pane: captured, | |||
| 317 | + | geometry: retained, | |||
| 318 | + | generation: 1, | |||
| 319 | + | }, | |||
| 320 | + | content: content, | |||
| 321 | + | full: true, | |||
| 322 | + | }) | |||
| 323 | + | }) | |||
| 324 | + | ||||
| 325 | + | for y := range 4 { | |||
| 326 | + | require.Equal(t, "···············", rowText(frame.Row(y)), "preserved frame row %d must remain outside the retained popup", y) | |||
| 327 | + | } | |||
| 328 | + | require.Equal(t, "··········┌────", rowText(frame.Row(4)), "only the visible top-border intersection is painted") | |||
| 329 | + | require.Equal(t, "··········│abcd", rowText(frame.Row(5)), "visible pane cells retain their source coordinates") | |||
| 330 | + | require.Equal(t, "··········│klmn", rowText(frame.Row(6)), "bottom clipping must not shift the retained source") | |||
| 331 | + | } | |||
| 332 | + | ||||
| 333 | + | func TestComposeCapturedFloatingFrameOffsetsSourceForClippedTopLeft(t *testing.T) { | |||
| 334 | + | base := renderer.NewFrame(4, 4) | |||
| 335 | + | pane := renderer.NewFrame(5, 5) | |||
| 336 | + | for y, text := range []string{"ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"} { | |||
| 337 | + | for x, r := range text { | |||
| 338 | + | pane.Set(x, y, renderer.Cell{Rune: r}) | |||
| 339 | + | } | |||
| 340 | + | } | |||
| 341 | + | geometry := floatingGeometry{ | |||
| 342 | + | Mode: ui.PresentationFloating, | |||
| 343 | + | Bounds: domain.Rect{X: -2, Y: -2, Width: 7, Height: 7}, | |||
| 344 | + | Inner: domain.Rect{X: -1, Y: -1, Width: 5, Height: 5}, | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | frame, _ := composeCapturedFloatingFrame(floatingComposeInput{ | |||
| 348 | + | baseFrame: base, | |||
| 349 | + | floating: capturedFloatingRenderState{ | |||
| 350 | + | visible: true, | |||
| 351 | + | pane: capturedPaneRenderState{frame: pane}, | |||
| 352 | + | geometry: geometry, | |||
| 353 | + | generation: 1, | |||
| 354 | + | }, | |||
| 355 | + | full: true, | |||
| 356 | + | }) | |||
| 357 | + | ||||
| 358 | + | require.Equal(t, []string{"GHIJ", "LMNO", "QRST", "VWXY"}, []string{ | |||
| 359 | + | rowText(frame.Row(0)), rowText(frame.Row(1)), rowText(frame.Row(2)), rowText(frame.Row(3)), | |||
| 360 | + | }) | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func TestDrawFloatingBorderClipsEveryEdge(t *testing.T) { | |||
| 364 | + | tests := []struct { | |||
| 365 | + | name string | |||
| 366 | + | geometry floatingGeometry | |||
| 367 | + | want []string | |||
| 368 | + | }{ | |||
| 369 | + | { | |||
| 370 | + | name: "negative top and left retain bottom and right edges", | |||
| 371 | + | geometry: floatingGeometry{ | |||
| 372 | + | Mode: ui.PresentationFloating, | |||
| 373 | + | Bounds: domain.Rect{X: -2, Y: -1, Width: 6, Height: 5}, | |||
| 374 | + | }, | |||
| 375 | + | want: []string{"···│", "···│", "···│", "───┘"}, | |||
| 376 | + | }, | |||
| 377 | + | { | |||
| 378 | + | name: "overflowing bottom and right retain top and left edges", | |||
| 379 | + | geometry: floatingGeometry{ | |||
| 380 | + | Mode: ui.PresentationFloating, | |||
| 381 | + | Bounds: domain.Rect{X: 2, Y: 2, Width: 5, Height: 4}, | |||
| 382 | + | }, | |||
| 383 | + | want: []string{"····", "····", "··┌─", "··│·"}, | |||
| 384 | + | }, | |||
| 385 | + | { | |||
| 386 | + | name: "drawer clips its separator", | |||
| 387 | + | geometry: floatingGeometry{ | |||
| 388 | + | Mode: ui.PresentationDrawer, | |||
| 389 | + | Bounds: domain.Rect{X: -2, Y: 2, Width: 6, Height: 3}, | |||
| 390 | + | }, | |||
| 391 | + | want: []string{"····", "····", "xy──", "····"}, | |||
| 392 | + | }, | |||
| 393 | + | } | |||
| 394 | + | for _, tt := range tests { | |||
| 395 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 396 | + | frame := renderer.NewFrame(4, 4) | |||
| 397 | + | for y := range frame.Height { | |||
| 398 | + | for x := range frame.Width { | |||
| 399 | + | frame.Set(x, y, renderer.Cell{Rune: '·'}) | |||
| 400 | + | } | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | require.NotPanics(t, func() { | |||
| 404 | + | drawFloatingBorder(frame, tt.geometry, "xy", renderer.Style{}) | |||
| 405 | + | }) | |||
| 406 | + | for y, want := range tt.want { | |||
| 407 | + | require.Equal(t, want, rowText(frame.Row(y)), "row %d", y) | |||
| 408 | + | } | |||
| 409 | + | }) | |||
| 410 | + | } | |||
| 411 | + | } | |||
| 412 | + | ||||
| 184 | func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) { | 413 | func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) { | ||
| 185 | base := renderer.NewFrame(20, 9) | 414 | base := renderer.NewFrame(20, 9) | ||
| 186 | content := domain.Rect{Y: 1, Width: 20, Height: 7} | 415 | content := domain.Rect{Y: 1, Width: 20, Height: 7} | ||
| @@ -401,12 +630,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||||
| 401 | 630 | ||||
| 402 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | 631 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | ||
| 403 | frame := renderer.NewFrame(2, 1) | 632 | frame := renderer.NewFrame(2, 1) | ||
| 404 | - | drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{}) | 633 | + | drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{}) |
| 405 | for _, cell := range frame.Cells { | 634 | for _, cell := range frame.Cells { | ||
| 406 | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | 635 | require.Equal(t, renderer.BlankCell().Rune, cell.Rune) | ||
| 407 | } | 636 | } | ||
| 408 | } | 637 | } | ||
| 409 | 638 | ||||
| 639 | + | func TestResponsiveFloatingDrawerDrawsOnlyTopSeparator(t *testing.T) { | |||
| 640 | + | frame := renderer.NewFrame(12, 6) | |||
| 641 | + | geometry := floatingGeometry{ | |||
| 642 | + | Mode: ui.PresentationDrawer, | |||
| 643 | + | Bounds: domain.Rect{Y: 2, Width: 12, Height: 4}, | |||
| 644 | + | Inner: domain.Rect{Y: 3, Width: 12, Height: 3}, | |||
| 645 | + | } | |||
| 646 | + | drawFloatingBorder(frame, geometry, "float", renderer.Style{}) | |||
| 647 | + | ||||
| 648 | + | require.Equal(t, "──float─────", rowText(frame.Row(2))) | |||
| 649 | + | for y := 3; y < frame.Height; y++ { | |||
| 650 | + | require.Equal(t, " ", rowText(frame.Row(y)), "drawer must not draw side or bottom borders") | |||
| 651 | + | } | |||
| 652 | + | } | |||
| 653 | + | ||||
| 654 | + | func TestResponsiveFloatingPresentationModeInvalidatesCache(t *testing.T) { | |||
| 655 | + | content := domain.Rect{Y: 1, Width: 12, Height: 5} | |||
| 656 | + | bounds := domain.Rect{X: 2, Y: 1, Width: 6, Height: 3} | |||
| 657 | + | inner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 1} | |||
| 658 | + | floating := floatingGeometry{Mode: ui.PresentationDrawer, Bounds: bounds, Inner: inner} | |||
| 659 | + | _, damage := composeCapturedFloatingFrame(floatingComposeInput{ | |||
| 660 | + | baseFrame: renderer.NewFrame(12, 7), | |||
| 661 | + | floating: capturedFloatingRenderState{ | |||
| 662 | + | visible: true, | |||
| 663 | + | pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)}, | |||
| 664 | + | geometry: floating, | |||
| 665 | + | generation: 1, | |||
| 666 | + | }, | |||
| 667 | + | content: content, | |||
| 668 | + | cache: composeCacheInput{ | |||
| 669 | + | valid: true, | |||
| 670 | + | floatingGeneration: 1, | |||
| 671 | + | floatingGeometry: floatingGeometry{ | |||
| 672 | + | Mode: ui.PresentationFloating, | |||
| 673 | + | Bounds: bounds, | |||
| 674 | + | Inner: inner, | |||
| 675 | + | }.translate(content.X, content.Y), | |||
| 676 | + | }, | |||
| 677 | + | }) | |||
| 678 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 679 | + | } | |||
| 680 | + | ||||
| 410 | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | 681 | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | ||
| 411 | base := renderer.NewFrame(80, 24) | 682 | base := renderer.NewFrame(80, 24) | ||
| 412 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | 683 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | ||
| @@ -574,13 +845,13 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||||
| 574 | require.Empty(t, pty.sizes()) | 845 | require.Empty(t, pty.sizes()) | ||
| 575 | 846 | ||||
| 576 | // Showing attempts the current size before paint. A failed resize retains | 847 | // Showing attempts the current size before paint. A failed resize retains | ||
| 577 | - | // the old screen, but commits the new popup rect so capture clips/pads it. | 848 | + | // the complete previously committed render and input geometry. |
| 578 | require.NoError(t, d.toggleFloating(sess, nil)) | 849 | require.NoError(t, d.toggleFloating(sess, nil)) | ||
| 579 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | 850 | require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes()) | ||
| 580 | - | require.Equal(t, current, floating.rect) | 851 | + | require.Equal(t, initial, floating.rect) |
| 581 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | 852 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | ||
| 582 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | 853 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | ||
| 583 | - | require.Equal(t, currentGeometry, floating.popupGeometry) | 854 | + | require.Equal(t, initialGeometry, floating.popupGeometry) |
| 584 | 855 | ||||
| 585 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | 856 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | ||
| 586 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | 857 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | ||
M internal/usecase/daemon/floating_test.go +65 -0
| @@ -208,6 +208,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi | |||||
| 208 | d.sessWg.Wait() | 208 | d.sessWg.Wait() | ||
| 209 | } | 209 | } | ||
| 210 | 210 | ||||
| 211 | + | func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) { | |||
| 212 | + | initial := domain.Size{Cols: 80, Rows: 25} | |||
| 213 | + | latest := domain.Size{Cols: 79, Rows: 25} | |||
| 214 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 215 | + | latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg) | |||
| 216 | + | ||||
| 217 | + | readerRelease := make(chan struct{}) | |||
| 218 | + | readerStarted := make(chan struct{}) | |||
| 219 | + | resized := make(chan struct{}) | |||
| 220 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 221 | + | floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) { | |||
| 222 | + | close(readerStarted) | |||
| 223 | + | <-readerRelease | |||
| 224 | + | return 0, io.EOF | |||
| 225 | + | }).Once() | |||
| 226 | + | floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once() | |||
| 227 | + | floatingPTY.EXPECT().Close().RunAndReturn(func() error { | |||
| 228 | + | select { | |||
| 229 | + | case <-readerRelease: | |||
| 230 | + | default: | |||
| 231 | + | close(readerRelease) | |||
| 232 | + | } | |||
| 233 | + | return nil | |||
| 234 | + | }).Once() | |||
| 235 | + | factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil) | |||
| 236 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 237 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 238 | + | tabCtx, cancelTab := context.WithCancel(t.Context()) | |||
| 239 | + | t.Cleanup(cancelTab) | |||
| 240 | + | tb := newTab(&transactionalResizePTY{}, tabSize(initial)) | |||
| 241 | + | tb.ctx, tb.cancel = tabCtx, cancelTab | |||
| 242 | + | sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()} | |||
| 243 | + | ||||
| 244 | + | d.startFloating(sess, tb, true) | |||
| 245 | + | openCall := <-opened | |||
| 246 | + | require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size) | |||
| 247 | + | require.True(t, d.requestTransactionalResize(sess, nil, latest, true)) | |||
| 248 | + | allowOpen() | |||
| 249 | + | select { | |||
| 250 | + | case <-resized: | |||
| 251 | + | case <-time.After(time.Second): | |||
| 252 | + | t.Fatal("floating install did not reconcile the resize committed while Open was blocked") | |||
| 253 | + | } | |||
| 254 | + | select { | |||
| 255 | + | case <-readerStarted: | |||
| 256 | + | case <-time.After(time.Second): | |||
| 257 | + | t.Fatal("floating reader did not start") | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | require.Eventually(t, func() bool { | |||
| 261 | + | tb.mu.Lock() | |||
| 262 | + | p := tb.floating.pane | |||
| 263 | + | visible := tb.floating.state == floatingVisible | |||
| 264 | + | tb.mu.Unlock() | |||
| 265 | + | if !visible || p == nil { | |||
| 266 | + | return false | |||
| 267 | + | } | |||
| 268 | + | p.mu.Lock() | |||
| 269 | + | defer p.mu.Unlock() | |||
| 270 | + | return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner | |||
| 271 | + | }, time.Second, time.Millisecond, "floating install retained the stale Open geometry") | |||
| 272 | + | d.teardownFloating(tb, nil) | |||
| 273 | + | d.sessWg.Wait() | |||
| 274 | + | } | |||
| 275 | + | ||||
| 211 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | 276 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | ||
| 212 | floatingPTY := portsmocks.NewMockPTY(t) | 277 | floatingPTY := portsmocks.NewMockPTY(t) | ||
| 213 | readerStarted := make(chan struct{}) | 278 | readerStarted := make(chan struct{}) | ||
M internal/usecase/daemon/input.go +20 -9
| @@ -92,7 +92,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) { | |||||
| 92 | } | 92 | } | ||
| 93 | 93 | ||||
| 94 | tb.mu.Lock() | 94 | tb.mu.Lock() | ||
| 95 | - | contentRow := ev.Row - 1 | 95 | + | contentRow := ev.Row - clientTopBarRows |
| 96 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | 96 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | ||
| 97 | if floatingVisible { | 97 | if floatingVisible { | ||
| 98 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | 98 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | ||
| @@ -201,7 +201,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||||
| 201 | if translated { | 201 | if translated { | ||
| 202 | d.writeToPane(sess, p, ev.Raw) | 202 | d.writeToPane(sess, p, ev.Raw) | ||
| 203 | } else { | 203 | } else { | ||
| 204 | - | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1)) | 204 | + | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) |
| 205 | } | 205 | } | ||
| 206 | return | 206 | return | ||
| 207 | } | 207 | } | ||
| @@ -359,14 +359,21 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 359 | if owned { | 359 | if owned { | ||
| 360 | defer effect.End() | 360 | defer effect.End() | ||
| 361 | } | 361 | } | ||
| 362 | - | runResizeAction := func(request daemonActionRequest) { | 362 | + | runAction := func(request daemonActionRequest) { |
| 363 | request.target = resolveDaemonActionTarget(sess) | 363 | request.target = resolveDaemonActionTarget(sess) | ||
| 364 | runner := h.actions | 364 | runner := h.actions | ||
| 365 | if runner == nil { | 365 | if runner == nil { | ||
| 366 | runner = daemonActions{d: h.d} | 366 | runner = daemonActions{d: h.d} | ||
| 367 | } | 367 | } | ||
| 368 | if err := runner.Run(request); err != nil { | 368 | if err := runner.Run(request); err != nil { | ||
| 369 | - | h.d.reportError(sess, resizeUserError(err)) | 369 | + | if errors.Is(err, errDaemonActionNoChange) { |
| 370 | + | return | |||
| 371 | + | } | |||
| 372 | + | var userErr *domain.UserError | |||
| 373 | + | if !errors.As(err, &userErr) { | |||
| 374 | + | err = resizeUserError(err) | |||
| 375 | + | } | |||
| 376 | + | h.d.reportError(sess, err) | |||
| 370 | return | 377 | return | ||
| 371 | } | 378 | } | ||
| 372 | if h.actions == nil { | 379 | if h.actions == nil { | ||
| @@ -405,15 +412,19 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||||
| 405 | h.d.reportError(sess, err) | 412 | h.d.reportError(sess, err) | ||
| 406 | } | 413 | } | ||
| 407 | case keys.ActionGrowPaneWidth: | 414 | case keys.ActionGrowPaneWidth: | ||
| 408 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) | 415 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols}) |
| 409 | case keys.ActionShrinkPaneWidth: | 416 | case keys.ActionShrinkPaneWidth: | ||
| 410 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) | 417 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols}) |
| 411 | case keys.ActionGrowPaneHeight: | 418 | case keys.ActionGrowPaneHeight: | ||
| 412 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) | 419 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows}) |
| 413 | case keys.ActionShrinkPaneHeight: | 420 | case keys.ActionShrinkPaneHeight: | ||
| 414 | - | runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) | 421 | + | runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows}) |
| 415 | case keys.ActionEqualizePanes: | 422 | case keys.ActionEqualizePanes: | ||
| 416 | - | runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes}) | 423 | + | runAction(daemonActionRequest{kind: daemonActionEqualizePanes}) |
| 424 | + | case keys.ActionConsumeOrExpelPaneLeft: | |||
| 425 | + | runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left}) | |||
| 426 | + | case keys.ActionConsumeOrExpelPaneRight: | |||
| 427 | + | runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right}) | |||
| 417 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | 428 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | ||
| 418 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | 429 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | ||
| 419 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | 430 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | ||
M internal/usecase/daemon/input_test.go +177 -2
| @@ -21,11 +21,114 @@ import ( | |||||
| 21 | "github.com/bnema/vev/internal/usecase/layout" | 21 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 22 | "github.com/bnema/vev/internal/usecase/mouse" | 22 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 23 | "github.com/bnema/vev/internal/usecase/picker" | 23 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 24 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 24 | "github.com/bnema/vev/pkg/vt" | 25 | "github.com/bnema/vev/pkg/vt" | ||
| 25 | ) | 26 | ) | ||
| 26 | 27 | ||||
| 27 | // --- test doubles ----------------------------------------------------------- | 28 | // --- test doubles ----------------------------------------------------------- | ||
| 28 | 29 | ||||
| 30 | + | func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) { | |||
| 31 | + | tests := []struct { | |||
| 32 | + | name string | |||
| 33 | + | binding string | |||
| 34 | + | key byte | |||
| 35 | + | focus layout.PaneID | |||
| 36 | + | wantTarget layout.PaneID | |||
| 37 | + | }{ | |||
| 38 | + | {name: "left", binding: "consume-or-expel-pane-left", key: 'H', focus: "pane-3", wantTarget: "pane-3"}, | |||
| 39 | + | {name: "right", binding: "consume-or-expel-pane-right", key: 'L', focus: "pane-1", wantTarget: "pane-1"}, | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | for _, tt := range tests { | |||
| 43 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 44 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 45 | + | h.tab.mu.Lock() | |||
| 46 | + | h.tab.tree.Focus = tt.focus | |||
| 47 | + | beforeGeneration := h.tab.layoutGeneration | |||
| 48 | + | h.tab.mu.Unlock() | |||
| 49 | + | bindings, warnings := keys.BuildBindings(map[string]string{tt.binding: "alt+" + string(tt.key)}) | |||
| 50 | + | require.Empty(t, warnings) | |||
| 51 | + | h.daemon.bindings.Store(bindings) | |||
| 52 | + | ac := &attachedClient{} | |||
| 53 | + | ac.setSession(h.session) | |||
| 54 | + | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |||
| 55 | + | h.session.mu.Lock() | |||
| 56 | + | h.session.client = ac | |||
| 57 | + | h.session.mu.Unlock() | |||
| 58 | + | invalidations := make(chan renderInvalidation, 1) | |||
| 59 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 60 | + | rc.attach(ac) | |||
| 61 | + | h.session.installRenderCoordinator(rc) | |||
| 62 | + | ||||
| 63 | + | h.daemon.handleInput(h.session, ac, []byte{keys.ESC, tt.key}) | |||
| 64 | + | ||||
| 65 | + | h.tab.mu.Lock() | |||
| 66 | + | require.Equal(t, tt.wantTarget, h.tab.tree.Focus) | |||
| 67 | + | require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration) | |||
| 68 | + | require.Len(t, h.tab.tree.Root.Children, 2) | |||
| 69 | + | h.tab.mu.Unlock() | |||
| 70 | + | awaitInvalidation(t, invalidations) | |||
| 71 | + | requireNoInvalidation(t, invalidations) | |||
| 72 | + | }) | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) { | |||
| 77 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 78 | + | bindings, warnings := keys.BuildBindings(map[string]string{"consume-or-expel-pane-left": "alt+H"}) | |||
| 79 | + | require.Empty(t, warnings) | |||
| 80 | + | h.daemon.bindings.Store(bindings) | |||
| 81 | + | ac := &attachedClient{} | |||
| 82 | + | ac.setSession(h.session) | |||
| 83 | + | ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings) | |||
| 84 | + | h.session.mu.Lock() | |||
| 85 | + | h.session.client = ac | |||
| 86 | + | h.session.mu.Unlock() | |||
| 87 | + | invalidations := make(chan renderInvalidation, 1) | |||
| 88 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 89 | + | rc.attach(ac) | |||
| 90 | + | h.session.installRenderCoordinator(rc) | |||
| 91 | + | before := h.snapshot() | |||
| 92 | + | ||||
| 93 | + | h.daemon.handleInput(h.session, ac, []byte{keys.ESC, 'H'}) | |||
| 94 | + | ||||
| 95 | + | require.Equal(t, before, h.snapshot()) | |||
| 96 | + | require.Empty(t, h.daemon.notices.history()) | |||
| 97 | + | requireNoInvalidation(t, invalidations) | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) { | |||
| 101 | + | tests := []struct { | |||
| 102 | + | name string | |||
| 103 | + | action keys.Action | |||
| 104 | + | }{ | |||
| 105 | + | {name: "left", action: keys.ActionConsumeOrExpelPaneLeft}, | |||
| 106 | + | {name: "right", action: keys.ActionConsumeOrExpelPaneRight}, | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | for _, tt := range tests { | |||
| 110 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 111 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 112 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 113 | + | ac := &attachedClient{} | |||
| 114 | + | ac.setSession(sess) | |||
| 115 | + | runner := &actionRunnerSpy{err: domain.UserWarn( | |||
| 116 | + | domain.NoticeLayoutTooSmall, | |||
| 117 | + | "not enough space to rearrange pane", | |||
| 118 | + | layout.ErrTooSmall, | |||
| 119 | + | )} | |||
| 120 | + | ||||
| 121 | + | daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action) | |||
| 122 | + | ||||
| 123 | + | history := d.notices.history() | |||
| 124 | + | require.Len(t, history, 1) | |||
| 125 | + | require.Equal(t, domain.NoticeLayoutTooSmall, history[0].Code) | |||
| 126 | + | require.Equal(t, domain.NoticeWarn, history[0].Severity) | |||
| 127 | + | require.Equal(t, "not enough space to rearrange pane", history[0].Message) | |||
| 128 | + | }) | |||
| 129 | + | } | |||
| 130 | + | } | |||
| 131 | + | ||||
| 29 | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | 132 | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | ||
| 30 | tests := []struct { | 133 | tests := []struct { | ||
| 31 | name string | 134 | name string | ||
| @@ -602,7 +705,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||||
| 602 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | 705 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | ||
| 603 | installTestFloating(tb, floating, true) | 706 | installTestFloating(tb, floating, true) | ||
| 604 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | 707 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | ||
| 605 | - | raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2)) | 708 | + | raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) |
| 606 | 709 | ||||
| 607 | d.handleInput(sess, ac, raw) | 710 | d.handleInput(sess, ac, raw) | ||
| 608 | } | 711 | } | ||
| @@ -620,12 +723,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||||
| 620 | installTestFloating(tb, floating, true) | 723 | installTestFloating(tb, floating, true) | ||
| 621 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | 724 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig()) | ||
| 622 | 725 | ||||
| 623 | - | border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2)) | 726 | + | border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1) |
| 624 | outside := []byte("\x1b[<0;1;2M") | 727 | outside := []byte("\x1b[<0;1;2M") | ||
| 625 | d.handleInput(sess, ac, border) | 728 | d.handleInput(sess, ac, border) | ||
| 626 | d.handleInput(sess, ac, outside) | 729 | d.handleInput(sess, ac, outside) | ||
| 627 | } | 730 | } | ||
| 628 | 731 | ||||
| 732 | + | func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) { | |||
| 733 | + | normal := portsmocks.NewMockPTY(t) | |||
| 734 | + | floatingPTY := portsmocks.NewMockPTY(t) | |||
| 735 | + | floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once() | |||
| 736 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, normal) | |||
| 737 | + | tb := sess.activeTab() | |||
| 738 | + | complete := domain.Size{Cols: 79, Rows: 25} | |||
| 739 | + | content := tabSize(complete) | |||
| 740 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |||
| 741 | + | require.Equal(t, ui.PresentationDrawer, geometry.Mode) | |||
| 742 | + | tb.mu.Lock() | |||
| 743 | + | tb.size = content | |||
| 744 | + | tb.mu.Unlock() | |||
| 745 | + | floating := newPane("floating", floatingPTY, rectSize(geometry.Inner)) | |||
| 746 | + | floating.popupGeometry = geometry | |||
| 747 | + | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |||
| 748 | + | installTestFloating(tb, floating, true) | |||
| 749 | + | ||||
| 750 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |||
| 751 | + | d.handleInput(sess, ac, firstInner) | |||
| 752 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |||
| 753 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |||
| 754 | + | } | |||
| 755 | + | } | |||
| 756 | + | ||||
| 757 | + | func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) { | |||
| 758 | + | p, release := newBlockingPTY(t) | |||
| 759 | + | defer release() | |||
| 760 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 761 | + | tb := sess.activeTab() | |||
| 762 | + | complete := domain.Size{Cols: 79, Rows: 25} | |||
| 763 | + | content := tabSize(complete) | |||
| 764 | + | geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig()) | |||
| 765 | + | tb.mu.Lock() | |||
| 766 | + | tb.size = content | |||
| 767 | + | tb.mu.Unlock() | |||
| 768 | + | floating := newPane("floating", nil, rectSize(geometry.Inner)) | |||
| 769 | + | floating.popupGeometry = geometry | |||
| 770 | + | floating.screen.Write([]byte("drawer row zero\r\nsecond row")) | |||
| 771 | + | installTestFloating(tb, floating, true) | |||
| 772 | + | ||||
| 773 | + | for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} { | |||
| 774 | + | d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1)) | |||
| 775 | + | ac.overlays.copyMu.Lock() | |||
| 776 | + | require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow) | |||
| 777 | + | ac.overlays.copyMu.Unlock() | |||
| 778 | + | } | |||
| 779 | + | ||||
| 780 | + | firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1) | |||
| 781 | + | d.handleInput(sess, ac, firstInner) | |||
| 782 | + | ac.overlays.copyMu.Lock() | |||
| 783 | + | pointer := ac.overlays.copyPointer | |||
| 784 | + | ac.overlays.copyMu.Unlock() | |||
| 785 | + | require.True(t, pointer.valid) | |||
| 786 | + | require.Same(t, floating, pointer.pane) | |||
| 787 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press) | |||
| 788 | + | require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content) | |||
| 789 | + | ||||
| 790 | + | // Motion onto the protected top row clamps against the press-owned drawer | |||
| 791 | + | // geometry instead of retargeting the underlying tiled terminal. | |||
| 792 | + | d.handleInput(sess, ac, []byte("\x1b[<32;79;1M")) | |||
| 793 | + | ac.overlays.copyMu.Lock() | |||
| 794 | + | require.NotNil(t, ac.overlays.copyMode) | |||
| 795 | + | require.Same(t, floating, ac.overlays.copyPane) | |||
| 796 | + | require.Same(t, floating, ac.overlays.copyPointer.pane) | |||
| 797 | + | require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry) | |||
| 798 | + | selection := ac.overlays.copyMode.Selection() | |||
| 799 | + | ac.overlays.copyMu.Unlock() | |||
| 800 | + | require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor) | |||
| 801 | + | require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row") | |||
| 802 | + | } | |||
| 803 | + | ||||
| 629 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | 804 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | ||
| 630 | writes := make(chan []byte, 1) | 805 | writes := make(chan []byte, 1) | ||
| 631 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | 806 | p, releasePTY := newBlockingPTYWithWrites(t, writes) | ||
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 | + | } | |||
M internal/usecase/daemon/modal_compose.go +4 -7
| @@ -8,12 +8,9 @@ import ( | |||||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) { | 10 | func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) { | ||
| 11 | - | inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase) | 11 | + | presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height}) |
| 12 | - | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}) | 12 | + | modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase) |
| 13 | - | for y := range min(inner.Height, modalFrame.Height) { | 13 | + | modalFrame := renderInner(rectSize(presentation.Inner)) |
| 14 | - | for x := range min(inner.Width, modalFrame.Width) { | 14 | + | copyModalInner(base, presentation.Inner, modalFrame) |
| 15 | - | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |||
| 16 | - | } | |||
| 17 | - | } | |||
| 18 | return base, []renderer.Damage{renderer.FullRedraw()} | 15 | return base, []renderer.Damage{renderer.FullRedraw()} | ||
| 19 | } | 16 | } | ||
M internal/usecase/daemon/mouse_panes.go +1 -1
| @@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool { | |||||
| 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | ||
| 188 | ev.Col -= colOffset | 188 | ev.Col -= colOffset | ||
| 189 | ev.Row -= contentYOffset | 189 | ev.Row -= contentYOffset | ||
| 190 | - | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1) | 190 | + | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows) |
| 191 | return ev | 191 | return ev | ||
| 192 | } | 192 | } | ||
| 193 | 193 | ||||
M internal/usecase/daemon/overlay_backdrop.go +5 -27
| @@ -6,33 +6,11 @@ import ( | |||||
| 6 | "github.com/bnema/vev/pkg/renderer" | 6 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | // overlayBackdrop controls optional dimming of pane content behind an overlay. | 9 | + | // applyOverlayBackdrop dims every cell already composed into the complete |
| 10 | - | // Chrome (bars, dividers, and pane titles) is deliberately not part of the | 10 | + | // renderer frame. Modal composition calls it immediately before painting each |
| 11 | - | // backdrop. | 11 | + | // active layer so bars, panes, toasts, and lower overlays share one backdrop. |
| 12 | - | type overlayBackdrop struct { | 12 | + | func applyOverlayBackdrop(frame renderer.Frame, theme themeui.Theme) { |
| 13 | - | DimPaneContents bool | 13 | + | dimFrameRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, theme) |
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | // apply dims the content rectangles from a tab-content-relative layout | |||
| 17 | - | // snapshot. A failed solve follows the tab renderer's focused-pane fallback by | |||
| 18 | - | // dimming the complete content area. | |||
| 19 | - | func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) { | |||
| 20 | - | if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 { | |||
| 21 | - | return | |||
| 22 | - | } | |||
| 23 | - | if !layoutSnap.ok { | |||
| 24 | - | dimFrameRect(frame, contentArea, theme) | |||
| 25 | - | return | |||
| 26 | - | } | |||
| 27 | - | for _, placement := range layoutSnap.placements { | |||
| 28 | - | if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 { | |||
| 29 | - | continue | |||
| 30 | - | } | |||
| 31 | - | r := placement.Content | |||
| 32 | - | r.X += contentArea.X | |||
| 33 | - | r.Y += contentArea.Y | |||
| 34 | - | dimFrameRect(frame, r, theme) | |||
| 35 | - | } | |||
| 36 | } | 14 | } | ||
| 37 | 15 | ||||
| 38 | func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) { | 16 | func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) { | ||
M internal/usecase/daemon/overlay_backdrop_test.go +27 -71
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "github.com/stretchr/testify/require" | 6 | "github.com/stretchr/testify/require" | ||
| 7 | 7 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 8 | "github.com/bnema/vev/internal/domain" | ||
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | themeui "github.com/bnema/vev/internal/usecase/theme" | 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 11 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 12 | ) | 11 | ) | ||
| @@ -15,82 +14,39 @@ func backdropTheme() themeui.Theme { | |||||
| 15 | return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | 14 | return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | ||
| 16 | } | 15 | } | ||
| 17 | 16 | ||||
| 18 | - | func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) { | 17 | + | func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { |
| 19 | - | frame := renderer.NewFrame(8, 6) | 18 | + | frame := renderer.NewFrame(4, 3) |
| 20 | original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()} | 19 | original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()} | ||
| 21 | - | frame.Set(2, 2, original) | 20 | + | for y := range frame.Height { |
| 22 | - | frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | 21 | + | for x := range frame.Width { |
| 23 | - | frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title | 22 | + | frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*frame.Width + x), Style: renderer.DefaultStyle()}) |
| 24 | - | frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar | 23 | + | } |
| 25 | - | frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar | |||
| 26 | - | frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed | |||
| 27 | - | ||||
| 28 | - | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{ | |||
| 29 | - | {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}}, | |||
| 30 | - | {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}}, | |||
| 31 | - | }} | |||
| 32 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme()) | |||
| 33 | - | ||||
| 34 | - | require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin") | |||
| 35 | - | require.True(t, frame.At(2, 2).Style.HasForegroundRGB) | |||
| 36 | - | require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming") | |||
| 37 | - | require.Zero(t, frame.At(3, 2).Rune) | |||
| 38 | - | require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp") | |||
| 39 | - | require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp") | |||
| 40 | - | require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp") | |||
| 41 | - | require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed") | |||
| 42 | - | } | |||
| 43 | - | ||||
| 44 | - | func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) { | |||
| 45 | - | tests := []struct { | |||
| 46 | - | name string | |||
| 47 | - | content domain.Rect | |||
| 48 | - | affected [][2]int | |||
| 49 | - | unaffected [][2]int | |||
| 50 | - | }{ | |||
| 51 | - | { | |||
| 52 | - | name: "negative origin", | |||
| 53 | - | content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, | |||
| 54 | - | affected: [][2]int{{0, 0}, {1, 1}}, | |||
| 55 | - | unaffected: [][2]int{{2, 2}, {3, 3}}, | |||
| 56 | - | }, | |||
| 57 | - | { | |||
| 58 | - | name: "right and bottom overflow", | |||
| 59 | - | content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, | |||
| 60 | - | affected: [][2]int{{2, 2}, {3, 3}}, | |||
| 61 | - | unaffected: [][2]int{{0, 0}, {1, 1}}, | |||
| 62 | - | }, | |||
| 63 | } | 24 | } | ||
| 64 | - | 25 | + | frame.Set(1, 1, original) | |
| 65 | - | for _, tt := range tests { | 26 | + | frame.Set(2, 1, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) |
| 66 | - | t.Run(tt.name, func(t *testing.T) { | 27 | + | |
| 67 | - | frame := renderer.NewFrame(4, 4) | 28 | + | applyOverlayBackdrop(frame, backdropTheme()) |
| 68 | - | original := renderer.DefaultStyle() | 29 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) |
| 69 | - | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}} | 30 | + | for y := range frame.Height { |
| 70 | - | 31 | + | for x := range frame.Width { | |
| 71 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme()) | 32 | + | require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y) |
| 72 | - | 33 | + | } | |
| 73 | - | for _, point := range tt.affected { | |||
| 74 | - | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), frame.At(point[0], point[1]).Style) | |||
| 75 | - | } | |||
| 76 | - | for _, point := range tt.unaffected { | |||
| 77 | - | require.Equal(t, original, frame.At(point[0], point[1]).Style) | |||
| 78 | - | } | |||
| 79 | - | }) | |||
| 80 | } | 34 | } | ||
| 35 | + | require.Equal(t, '界', frame.At(1, 1).Rune) | |||
| 36 | + | require.True(t, frame.At(2, 1).Continuation) | |||
| 37 | + | require.Zero(t, frame.At(2, 1).Rune) | |||
| 81 | } | 38 | } | ||
| 82 | 39 | ||||
| 83 | - | func TestOverlayBackdropDisabledAndFallback(t *testing.T) { | 40 | + | func TestDimFrameRectClipsToFrame(t *testing.T) { |
| 84 | frame := renderer.NewFrame(4, 4) | 41 | frame := renderer.NewFrame(4, 4) | ||
| 85 | - | frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 86 | before := frame.Clone() | 42 | before := frame.Clone() | ||
| 87 | - | area := domain.Rect{Y: 1, Width: 4, Height: 2} | 43 | + | dimFrameRect(frame, domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, backdropTheme()) |
| 88 | - | (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | 44 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) |
| 89 | - | require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default") | |||
| 90 | 45 | ||||
| 91 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | 46 | + | for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} { |
| 92 | - | require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area") | 47 | + | require.Equal(t, dimmed, frame.At(point[0], point[1]).Style) |
| 93 | - | require.True(t, frame.At(3, 2).Style.HasForegroundRGB) | 48 | + | } |
| 94 | - | require.False(t, frame.At(0, 0).Style.HasForegroundRGB) | 49 | + | for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} { |
| 95 | - | require.False(t, frame.At(0, 3).Style.HasForegroundRGB) | 50 | + | require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style) |
| 51 | + | } | |||
| 96 | } | 52 | } | ||
M internal/usecase/daemon/palette.go +28 -9
| @@ -322,7 +322,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 322 | return | 322 | return | ||
| 323 | } | 323 | } | ||
| 324 | sess.dispatchMu.Lock() | 324 | sess.dispatchMu.Lock() | ||
| 325 | - | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect}, args) | 325 | + | err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args) |
| 326 | sess.dispatchMu.Unlock() | 326 | sess.dispatchMu.Unlock() | ||
| 327 | if roleHandoff { | 327 | if roleHandoff { | ||
| 328 | if current := ac.currentSession(); current != nil { | 328 | if current := ac.currentSession(); current != nil { | ||
| @@ -393,12 +393,13 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin | |||||
| 393 | } | 393 | } | ||
| 394 | 394 | ||||
| 395 | type paletteExec struct { | 395 | type paletteExec struct { | ||
| 396 | - | d *Daemon | 396 | + | d *Daemon |
| 397 | - | sess *session | 397 | + | sess *session |
| 398 | - | ac *attachedClient | 398 | + | ac *attachedClient |
| 399 | - | recent []recentSession | 399 | + | recent []recentSession |
| 400 | - | actions daemonActionRunner | 400 | + | actions daemonActionRunner |
| 401 | - | effect *roleEffectTicket | 401 | + | effect *roleEffectTicket |
| 402 | + | redrawClosedPalette bool | |||
| 402 | } | 403 | } | ||
| 403 | 404 | ||||
| 404 | func (e paletteExec) runAction(request daemonActionRequest) error { | 405 | func (e paletteExec) runAction(request daemonActionRequest) error { | ||
| @@ -410,10 +411,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error { | |||||
| 410 | runner = daemonActions{d: e.d} | 411 | runner = daemonActions{d: e.d} | ||
| 411 | } | 412 | } | ||
| 412 | err := runner.Run(request) | 413 | err := runner.Run(request) | ||
| 413 | - | if err == nil && e.actions == nil { | 414 | + | if errors.Is(err, errDaemonActionNoChange) { |
| 415 | + | if e.redrawClosedPalette { | |||
| 416 | + | e.d.invalidateRender(e.sess, e.ac, true, "palette.go") | |||
| 417 | + | } | |||
| 418 | + | return nil | |||
| 419 | + | } | |||
| 420 | + | if err != nil { | |||
| 421 | + | return err | |||
| 422 | + | } | |||
| 423 | + | if e.actions == nil { | |||
| 414 | finishDaemonActionForClient(e.d, request, e.ac, "palette.go") | 424 | finishDaemonActionForClient(e.d, request, e.ac, "palette.go") | ||
| 415 | } | 425 | } | ||
| 416 | - | return err | 426 | + | return nil |
| 417 | } | 427 | } | ||
| 418 | 428 | ||||
| 419 | func (e paletteExec) CreateTab() error { | 429 | func (e paletteExec) CreateTab() error { | ||
| @@ -442,6 +452,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) } | |||||
| 442 | func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } | 452 | func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } | ||
| 443 | func (e paletteExec) SplitUp() error { return e.split(layout.Up) } | 453 | func (e paletteExec) SplitUp() error { return e.split(layout.Up) } | ||
| 444 | func (e paletteExec) SplitDown() error { return e.split(layout.Down) } | 454 | func (e paletteExec) SplitDown() error { return e.split(layout.Down) } | ||
| 455 | + | func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error { | |||
| 456 | + | return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction}) | |||
| 457 | + | } | |||
| 458 | + | func (e paletteExec) ConsumeOrExpelPaneLeft() error { | |||
| 459 | + | return e.consumeOrExpelPane(layout.Left) | |||
| 460 | + | } | |||
| 461 | + | func (e paletteExec) ConsumeOrExpelPaneRight() error { | |||
| 462 | + | return e.consumeOrExpelPane(layout.Right) | |||
| 463 | + | } | |||
| 445 | 464 | ||||
| 446 | func (e paletteExec) StackPane() error { | 465 | func (e paletteExec) StackPane() error { | ||
| 447 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | 466 | return e.runAction(daemonActionRequest{kind: daemonActionStackPane}) | ||
M internal/usecase/daemon/palette_test.go +45 -7
| @@ -520,14 +520,22 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) { | |||||
| 520 | } | 520 | } | ||
| 521 | d.handleInput(sess, ac, []byte("\x1b ")) | 521 | d.handleInput(sess, ac, []byte("\x1b ")) | ||
| 522 | awaitFrame(t, sends, ports.MsgOutput) | 522 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 523 | - | go d.handleInput(sess, ac, []byte("JRS 1\r")) | 523 | + | inputHandled := make(chan struct{}) |
| 524 | - | <-validated | 524 | + | go func() { |
| 525 | + | d.handleInput(sess, ac, []byte("JRS 1\r")) | |||
| 526 | + | close(inputHandled) | |||
| 527 | + | }() | |||
| 528 | + | awaitTestCompletion(t, validated, "JRS did not validate its captured target") | |||
| 525 | d.mu.Lock() | 529 | d.mu.Lock() | ||
| 526 | delete(d.sessions, target.id) | 530 | delete(d.sessions, target.id) | ||
| 527 | d.mu.Unlock() | 531 | d.mu.Unlock() | ||
| 528 | close(releaseHandoff) | 532 | close(releaseHandoff) | ||
| 529 | 533 | ||||
| 530 | - | awaitFrame(t, sends, ports.MsgOutput) | 534 | + | // switchToTarget repaints its failed hand-off before handlePaletteInput |
| 535 | + | // records the generation-safe feedback and schedules the feedback repaint. | |||
| 536 | + | // Wait for the input transaction, rather than mistaking that intermediate | |||
| 537 | + | // repaint for publication of the final palette state. | |||
| 538 | + | awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete") | |||
| 531 | require.True(t, ac.overlays.paletteActive()) | 539 | require.True(t, ac.overlays.paletteActive()) | ||
| 532 | ac.overlays.paletteMu.Lock() | 540 | ac.overlays.paletteMu.Lock() | ||
| 533 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | 541 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | ||
| @@ -734,7 +742,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||||
| 734 | for i, cmd := range commands { | 742 | for i, cmd := range commands { | ||
| 735 | codes[i] = cmd.Code | 743 | codes[i] = cmd.Code | ||
| 736 | } | 744 | } | ||
| 737 | - | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) | 745 | + | require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes) |
| 738 | } | 746 | } | ||
| 739 | 747 | ||||
| 740 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | 748 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | ||
| @@ -852,10 +860,12 @@ func TestPaletteModalGeometry(t *testing.T) { | |||||
| 852 | 860 | ||||
| 853 | auto := domain.PaletteConfig{} | 861 | auto := domain.PaletteConfig{} | ||
| 854 | tests := []testCase{ | 862 | tests := []testCase{ | ||
| 863 | + | {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}}, | |||
| 864 | + | {name: "auto 80 column shelf", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 80, Height: 11}}, | |||
| 855 | {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}}, | 865 | {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}}, | ||
| 856 | {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}}, | 866 | {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}}, | ||
| 857 | {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}}, | 867 | {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}}, | ||
| 858 | - | {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}}, | 868 | + | {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}}, |
| 859 | } | 869 | } | ||
| 860 | for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} { | 870 | for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} { | ||
| 861 | wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor] | 871 | wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor] | ||
| @@ -869,7 +879,15 @@ func TestPaletteModalGeometry(t *testing.T) { | |||||
| 869 | for _, tt := range tests { | 879 | for _, tt := range tests { | ||
| 870 | t.Run(tt.name, func(t *testing.T) { | 880 | t.Run(tt.name, func(t *testing.T) { | ||
| 871 | modal := paletteModalFor(tt.size, tt.cfg) | 881 | modal := paletteModalFor(tt.size, tt.cfg) | ||
| 872 | - | require.Equal(t, tt.want, modal.Bounds(tt.size)) | 882 | + | presentation := modal.Resolve(tt.size) |
| 883 | + | require.Equal(t, tt.want, presentation.Bounds) | |||
| 884 | + | if tt.size.Cols < ui.ResponsiveDrawerBreakpoint { | |||
| 885 | + | require.Equal(t, ui.PresentationDrawer, presentation.Mode) | |||
| 886 | + | require.Equal(t, ui.BorderTop, presentation.Borders) | |||
| 887 | + | } else { | |||
| 888 | + | require.Equal(t, ui.PresentationFloating, presentation.Mode) | |||
| 889 | + | require.Equal(t, ui.BorderAll, presentation.Borders) | |||
| 890 | + | } | |||
| 873 | require.Equal(t, " Commands ", modal.Title) | 891 | require.Equal(t, " Commands ", modal.Title) | ||
| 874 | require.Equal(t, 11, modal.FixedHeight) | 892 | require.Equal(t, 11, modal.FixedHeight) | ||
| 875 | require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins) | 893 | require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins) | ||
| @@ -877,6 +895,26 @@ func TestPaletteModalGeometry(t *testing.T) { | |||||
| 877 | } | 895 | } | ||
| 878 | } | 896 | } | ||
| 879 | 897 | ||||
| 898 | + | func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) { | |||
| 899 | + | model := palette.New(nil) | |||
| 900 | + | for _, tt := range []struct { | |||
| 901 | + | name string | |||
| 902 | + | width int | |||
| 903 | + | topLeft rune | |||
| 904 | + | innerLeft rune | |||
| 905 | + | }{ | |||
| 906 | + | {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'}, | |||
| 907 | + | {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'}, | |||
| 908 | + | } { | |||
| 909 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 910 | + | frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "") | |||
| 911 | + | ||||
| 912 | + | require.Equal(t, tt.topLeft, frame.At(0, 28).Rune) | |||
| 913 | + | require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune) | |||
| 914 | + | }) | |||
| 915 | + | } | |||
| 916 | + | } | |||
| 917 | + | ||||
| 880 | func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | 918 | func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | ||
| 881 | model := palette.New(nil) | 919 | model := palette.New(nil) | ||
| 882 | base := renderer.NewFrame(120, 40) | 920 | base := renderer.NewFrame(120, 40) | ||
| @@ -902,7 +940,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) { | |||||
| 902 | styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | 940 | styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | ||
| 903 | 941 | ||||
| 904 | frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles) | 942 | frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles) | ||
| 905 | - | inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size) | 943 | + | inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner |
| 906 | 944 | ||||
| 907 | require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted)) | 945 | require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted)) | ||
| 908 | } | 946 | } | ||
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 | + | } | |||
A internal/usecase/daemon/pane_rearrange_test.go +604 -0
| @@ -0,0 +1,604 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "io" | |||
| 7 | + | "maps" | |||
| 8 | + | "sync" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 17 | + | "github.com/bnema/vev/internal/usecase/keys" | |||
| 18 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) { | |||
| 22 | + | tests := []struct { | |||
| 23 | + | name string | |||
| 24 | + | mutate func(*layout.Tree, domain.Rect) (bool, error) | |||
| 25 | + | wantChanged bool | |||
| 26 | + | wantGeneration uint64 | |||
| 27 | + | wantSnapshotGen uint64 | |||
| 28 | + | wantResizes bool | |||
| 29 | + | }{ | |||
| 30 | + | { | |||
| 31 | + | name: "changed candidate publishes once", | |||
| 32 | + | mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) { | |||
| 33 | + | return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area) | |||
| 34 | + | }, | |||
| 35 | + | wantChanged: true, | |||
| 36 | + | wantGeneration: 1, | |||
| 37 | + | wantSnapshotGen: 1, | |||
| 38 | + | wantResizes: true, | |||
| 39 | + | }, | |||
| 40 | + | { | |||
| 41 | + | name: "unchanged candidate is discarded", | |||
| 42 | + | mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) { | |||
| 43 | + | candidate.Focus = "pane-2" | |||
| 44 | + | return false, nil | |||
| 45 | + | }, | |||
| 46 | + | }, | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | for _, tt := range tests { | |||
| 50 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 51 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 52 | + | before := h.tab.tree.Clone() | |||
| 53 | + | ||||
| 54 | + | changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate) | |||
| 55 | + | ||||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | require.Equal(t, tt.wantChanged, changed) | |||
| 58 | + | h.tab.mu.Lock() | |||
| 59 | + | require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration) | |||
| 60 | + | if tt.wantChanged { | |||
| 61 | + | require.NotEqual(t, before, h.tab.tree) | |||
| 62 | + | } else { | |||
| 63 | + | require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate") | |||
| 64 | + | } | |||
| 65 | + | h.tab.mu.Unlock() | |||
| 66 | + | h.session.snapshotMu.Lock() | |||
| 67 | + | require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration) | |||
| 68 | + | h.session.snapshotMu.Unlock() | |||
| 69 | + | if tt.wantResizes { | |||
| 70 | + | require.NotZero(t, h.totalResizes()) | |||
| 71 | + | } else { | |||
| 72 | + | require.Zero(t, h.totalResizes()) | |||
| 73 | + | } | |||
| 74 | + | }) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) { | |||
| 79 | + | tests := []struct { | |||
| 80 | + | name string | |||
| 81 | + | stale func(*paneRearrangeHarness) daemonActionTarget | |||
| 82 | + | }{ | |||
| 83 | + | { | |||
| 84 | + | name: "session", | |||
| 85 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |||
| 86 | + | target := h.target("pane-1") | |||
| 87 | + | h.daemon.sessions[h.session.id] = &session{id: h.session.id} | |||
| 88 | + | return target | |||
| 89 | + | }, | |||
| 90 | + | }, | |||
| 91 | + | { | |||
| 92 | + | name: "tab", | |||
| 93 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |||
| 94 | + | target := h.target("pane-1") | |||
| 95 | + | h.session.tabs = []*tab{newTab(nil, h.tab.size)} | |||
| 96 | + | return target | |||
| 97 | + | }, | |||
| 98 | + | }, | |||
| 99 | + | { | |||
| 100 | + | name: "pane identity", | |||
| 101 | + | stale: func(h *paneRearrangeHarness) daemonActionTarget { | |||
| 102 | + | target := h.target("pane-1") | |||
| 103 | + | h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size) | |||
| 104 | + | return target | |||
| 105 | + | }, | |||
| 106 | + | }, | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | for _, tt := range tests { | |||
| 110 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 111 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 112 | + | before := h.tab.tree.Clone() | |||
| 113 | + | called := false | |||
| 114 | + | ||||
| 115 | + | changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) { | |||
| 116 | + | called = true | |||
| 117 | + | return true, nil | |||
| 118 | + | }) | |||
| 119 | + | ||||
| 120 | + | require.False(t, changed) | |||
| 121 | + | require.ErrorIs(t, err, layout.ErrNotFound) | |||
| 122 | + | require.False(t, called, "stale identity must be rejected before invoking the mutation") | |||
| 123 | + | require.Equal(t, before, h.tab.tree) | |||
| 124 | + | require.Zero(t, h.tab.layoutGeneration) | |||
| 125 | + | require.Zero(t, h.totalResizes()) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) { | |||
| 131 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 132 | + | locksAvailable := make(chan bool, 8) | |||
| 133 | + | for _, tracked := range h.ptys { | |||
| 134 | + | tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | done := make(chan error, 1) | |||
| 138 | + | go func() { | |||
| 139 | + | done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 140 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right, | |||
| 141 | + | }) | |||
| 142 | + | }() | |||
| 143 | + | ||||
| 144 | + | select { | |||
| 145 | + | case err := <-done: | |||
| 146 | + | require.NoError(t, err) | |||
| 147 | + | case <-time.After(2 * time.Second): | |||
| 148 | + | t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks") | |||
| 149 | + | } | |||
| 150 | + | require.NotZero(t, h.totalResizes()) | |||
| 151 | + | close(locksAvailable) | |||
| 152 | + | for available := range locksAvailable { | |||
| 153 | + | require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held") | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) { | |||
| 158 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 159 | + | h.tab.mu.Lock() | |||
| 160 | + | h.tab.tree.Focus = "pane-3" | |||
| 161 | + | before := make(map[layout.PaneID]*pane, len(h.tab.panes)) | |||
| 162 | + | maps.Copy(before, h.tab.panes) | |||
| 163 | + | h.tab.mu.Unlock() | |||
| 164 | + | ||||
| 165 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 166 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right, | |||
| 167 | + | }) | |||
| 168 | + | ||||
| 169 | + | require.NoError(t, err) | |||
| 170 | + | h.tab.mu.Lock() | |||
| 171 | + | require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused") | |||
| 172 | + | require.Len(t, h.tab.panes, len(before)) | |||
| 173 | + | for id, p := range before { | |||
| 174 | + | require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id) | |||
| 175 | + | } | |||
| 176 | + | h.tab.mu.Unlock() | |||
| 177 | + | h.factory.AssertNotCalled(t, "Open") | |||
| 178 | + | for _, tracked := range h.ptys { | |||
| 179 | + | require.Zero(t, tracked.closeCount()) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) { | |||
| 184 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 185 | + | ac := &attachedClient{} | |||
| 186 | + | ac.setSession(h.session) | |||
| 187 | + | h.session.mu.Lock() | |||
| 188 | + | h.session.client = ac | |||
| 189 | + | h.session.mu.Unlock() | |||
| 190 | + | invalidations := make(chan renderInvalidation, 2) | |||
| 191 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 192 | + | rc.attach(ac) | |||
| 193 | + | h.session.installRenderCoordinator(rc) | |||
| 194 | + | before := h.snapshot() | |||
| 195 | + | request := daemonActionRequest{ | |||
| 196 | + | kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left, | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | err := (daemonActions{d: h.daemon}).Run(request) | |||
| 200 | + | require.ErrorIs(t, err, errDaemonActionNoChange, "the daemon action must retain the package no-change sentinel") | |||
| 201 | + | ||||
| 202 | + | require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size") | |||
| 203 | + | require.Zero(t, h.totalResizes()) | |||
| 204 | + | h.factory.AssertNotCalled(t, "Open") | |||
| 205 | + | for _, tracked := range h.ptys { | |||
| 206 | + | require.Zero(t, tracked.closeCount()) | |||
| 207 | + | } | |||
| 208 | + | require.Empty(t, h.daemon.notices.history()) | |||
| 209 | + | requireNoInvalidation(t, invalidations) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) { | |||
| 213 | + | tests := []struct { | |||
| 214 | + | name string | |||
| 215 | + | size domain.Size | |||
| 216 | + | tree *layout.Tree | |||
| 217 | + | pane layout.PaneID | |||
| 218 | + | dir layout.Direction | |||
| 219 | + | cause error | |||
| 220 | + | wantMsg string | |||
| 221 | + | }{ | |||
| 222 | + | { | |||
| 223 | + | name: "unsupported layout", | |||
| 224 | + | size: domain.Size{Cols: 80, Rows: 22}, | |||
| 225 | + | tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{ | |||
| 226 | + | layout.NewLeaf("pane-1"), | |||
| 227 | + | {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}}, | |||
| 228 | + | }}, Focus: "pane-1"}, | |||
| 229 | + | pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout, | |||
| 230 | + | wantMsg: "pane rearrangement requires a column layout", | |||
| 231 | + | }, | |||
| 232 | + | { | |||
| 233 | + | name: "too small", | |||
| 234 | + | size: domain.Size{Cols: 40, Rows: 5}, | |||
| 235 | + | tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{ | |||
| 236 | + | layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), | |||
| 237 | + | }}, Focus: "pane-1"}, | |||
| 238 | + | pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall, | |||
| 239 | + | wantMsg: "not enough space to rearrange pane", | |||
| 240 | + | }, | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | for _, tt := range tests { | |||
| 244 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 245 | + | h := newPaneRearrangeHarness(t, tt.size, tt.tree) | |||
| 246 | + | before := h.snapshot() | |||
| 247 | + | ||||
| 248 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |||
| 249 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir, | |||
| 250 | + | }) | |||
| 251 | + | ||||
| 252 | + | require.ErrorIs(t, err, tt.cause) | |||
| 253 | + | var userErr *domain.UserError | |||
| 254 | + | require.ErrorAs(t, err, &userErr) | |||
| 255 | + | require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code) | |||
| 256 | + | require.Equal(t, domain.NoticeWarn, userErr.Severity) | |||
| 257 | + | require.Equal(t, tt.wantMsg, userErr.Msg) | |||
| 258 | + | require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic") | |||
| 259 | + | require.Zero(t, h.totalResizes()) | |||
| 260 | + | h.factory.AssertNotCalled(t, "Open") | |||
| 261 | + | for _, tracked := range h.ptys { | |||
| 262 | + | require.Zero(t, tracked.closeCount()) | |||
| 263 | + | } | |||
| 264 | + | }) | |||
| 265 | + | } | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) { | |||
| 269 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 270 | + | ac := &attachedClient{} | |||
| 271 | + | ac.setSession(h.session) | |||
| 272 | + | h.session.mu.Lock() | |||
| 273 | + | h.session.client = ac | |||
| 274 | + | h.session.mu.Unlock() | |||
| 275 | + | invalidations := make(chan renderInvalidation, 2) | |||
| 276 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 277 | + | rc.attach(ac) | |||
| 278 | + | h.session.installRenderCoordinator(rc) | |||
| 279 | + | ||||
| 280 | + | err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{ | |||
| 281 | + | kind: daemonActionConsumeOrExpelPane, direction: layout.Right, | |||
| 282 | + | }) | |||
| 283 | + | ||||
| 284 | + | require.NoError(t, err) | |||
| 285 | + | h.tab.mu.Lock() | |||
| 286 | + | require.Equal(t, uint64(1), h.tab.layoutGeneration) | |||
| 287 | + | h.tab.mu.Unlock() | |||
| 288 | + | h.session.snapshotMu.Lock() | |||
| 289 | + | require.Equal(t, uint64(1), h.session.snapshotGeneration) | |||
| 290 | + | h.session.snapshotMu.Unlock() | |||
| 291 | + | require.NotZero(t, h.totalResizes()) | |||
| 292 | + | awaitInvalidation(t, invalidations) | |||
| 293 | + | requireNoInvalidation(t, invalidations) | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) { | |||
| 297 | + | genuine := errors.New("genuine rearrange failure") | |||
| 298 | + | directions := []struct { | |||
| 299 | + | name string | |||
| 300 | + | direction layout.Direction | |||
| 301 | + | keyAction keys.Action | |||
| 302 | + | paletteRun func(paletteExec) error | |||
| 303 | + | controlRun func(controlExec) error | |||
| 304 | + | }{ | |||
| 305 | + | { | |||
| 306 | + | name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft, | |||
| 307 | + | paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, | |||
| 308 | + | controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, | |||
| 309 | + | }, | |||
| 310 | + | { | |||
| 311 | + | name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight, | |||
| 312 | + | paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, | |||
| 313 | + | controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, | |||
| 314 | + | }, | |||
| 315 | + | } | |||
| 316 | + | outcomes := []struct { | |||
| 317 | + | name string | |||
| 318 | + | err error | |||
| 319 | + | wantErr error | |||
| 320 | + | wantNotice bool | |||
| 321 | + | }{ | |||
| 322 | + | {name: "silent no-change", err: errDaemonActionNoChange}, | |||
| 323 | + | {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true}, | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | for _, direction := range directions { | |||
| 327 | + | t.Run(direction.name, func(t *testing.T) { | |||
| 328 | + | for _, outcome := range outcomes { | |||
| 329 | + | t.Run(outcome.name, func(t *testing.T) { | |||
| 330 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 331 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |||
| 332 | + | ac := &attachedClient{} | |||
| 333 | + | ac.setSession(sess) | |||
| 334 | + | target := resolveDaemonActionTarget(sess) | |||
| 335 | + | paletteSpy := &actionRunnerSpy{err: outcome.err} | |||
| 336 | + | controlSpy := &actionRunnerSpy{err: outcome.err} | |||
| 337 | + | keySpy := &actionRunnerSpy{err: outcome.err} | |||
| 338 | + | ||||
| 339 | + | paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy}) | |||
| 340 | + | controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy}) | |||
| 341 | + | daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction) | |||
| 342 | + | ||||
| 343 | + | if outcome.wantErr == nil { | |||
| 344 | + | require.NoError(t, paletteErr) | |||
| 345 | + | require.NoError(t, controlErr) | |||
| 346 | + | } else { | |||
| 347 | + | require.ErrorIs(t, paletteErr, outcome.wantErr) | |||
| 348 | + | require.ErrorIs(t, controlErr, outcome.wantErr) | |||
| 349 | + | } | |||
| 350 | + | if outcome.wantNotice { | |||
| 351 | + | require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error") | |||
| 352 | + | } else { | |||
| 353 | + | require.Empty(t, d.notices.history(), "no-change is silent in the key adapter") | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | for adapter, spy := range map[string]*actionRunnerSpy{ | |||
| 357 | + | "palette": paletteSpy, | |||
| 358 | + | "control": controlSpy, | |||
| 359 | + | "key": keySpy, | |||
| 360 | + | } { | |||
| 361 | + | require.Len(t, spy.requests, 1, "%s adapter request count", adapter) | |||
| 362 | + | request := spy.requests[0] | |||
| 363 | + | require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter) | |||
| 364 | + | require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter) | |||
| 365 | + | require.Same(t, target.session, request.target.session, "%s adapter target session", adapter) | |||
| 366 | + | require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter) | |||
| 367 | + | require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter) | |||
| 368 | + | } | |||
| 369 | + | }) | |||
| 370 | + | } | |||
| 371 | + | }) | |||
| 372 | + | } | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) { | |||
| 376 | + | tests := []struct { | |||
| 377 | + | name string | |||
| 378 | + | invoke func(*paneRearrangeHarness, *attachedClient) error | |||
| 379 | + | wantCleanupInvalidation bool | |||
| 380 | + | }{ | |||
| 381 | + | { | |||
| 382 | + | name: "palette cleans up its closed overlay", | |||
| 383 | + | invoke: func(h *paneRearrangeHarness, ac *attachedClient) error { | |||
| 384 | + | return (paletteExec{d: h.daemon, sess: h.session, ac: ac, redrawClosedPalette: true}).ConsumeOrExpelPaneLeft() | |||
| 385 | + | }, | |||
| 386 | + | wantCleanupInvalidation: true, | |||
| 387 | + | }, | |||
| 388 | + | { | |||
| 389 | + | name: "control remains silent", | |||
| 390 | + | invoke: func(h *paneRearrangeHarness, _ *attachedClient) error { | |||
| 391 | + | return (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).ConsumeOrExpelPaneLeft() | |||
| 392 | + | }, | |||
| 393 | + | }, | |||
| 394 | + | { | |||
| 395 | + | name: "key remains silent", | |||
| 396 | + | invoke: func(h *paneRearrangeHarness, ac *attachedClient) error { | |||
| 397 | + | daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft) | |||
| 398 | + | return nil | |||
| 399 | + | }, | |||
| 400 | + | }, | |||
| 401 | + | } | |||
| 402 | + | ||||
| 403 | + | for _, tt := range tests { | |||
| 404 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 405 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |||
| 406 | + | ac := &attachedClient{} | |||
| 407 | + | ac.setSession(h.session) | |||
| 408 | + | h.session.mu.Lock() | |||
| 409 | + | h.session.client = ac | |||
| 410 | + | h.session.mu.Unlock() | |||
| 411 | + | invalidations := make(chan renderInvalidation, 2) | |||
| 412 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |||
| 413 | + | rc.attach(ac) | |||
| 414 | + | h.session.installRenderCoordinator(rc) | |||
| 415 | + | before := h.snapshot() | |||
| 416 | + | ||||
| 417 | + | require.NoError(t, tt.invoke(h, ac)) | |||
| 418 | + | ||||
| 419 | + | require.Equal(t, before, h.snapshot(), "adapter no-change must not publish layout state") | |||
| 420 | + | require.Empty(t, h.daemon.notices.history()) | |||
| 421 | + | if tt.wantCleanupInvalidation { | |||
| 422 | + | invalidation := awaitInvalidation(t, invalidations) | |||
| 423 | + | require.Equal(t, "palette.go", invalidation.producer) | |||
| 424 | + | require.True(t, invalidation.reset) | |||
| 425 | + | } | |||
| 426 | + | requireNoInvalidation(t, invalidations) | |||
| 427 | + | }) | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | type paneRearrangeHarness struct { | |||
| 432 | + | daemon *Daemon | |||
| 433 | + | session *session | |||
| 434 | + | tab *tab | |||
| 435 | + | panes map[layout.PaneID]*pane | |||
| 436 | + | ptys map[layout.PaneID]*paneRearrangePTY | |||
| 437 | + | factory *portsmocks.MockPTYFactory | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | type paneRearrangeSnapshot struct { | |||
| 441 | + | tree *layout.Tree | |||
| 442 | + | layoutGeneration uint64 | |||
| 443 | + | snapshotGeneration uint64 | |||
| 444 | + | snapshotDirty bool | |||
| 445 | + | panes map[layout.PaneID]*pane | |||
| 446 | + | rects map[layout.PaneID]domain.Rect | |||
| 447 | + | screens map[layout.PaneID]domain.Size | |||
| 448 | + | } | |||
| 449 | + | ||||
| 450 | + | func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness { | |||
| 451 | + | t.Helper() | |||
| 452 | + | factory := portsmocks.NewMockPTYFactory(t) | |||
| 453 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 454 | + | ids := layout.LeafIDs(tree.Root) | |||
| 455 | + | require.NotEmpty(t, ids) | |||
| 456 | + | ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids)) | |||
| 457 | + | panes := make(map[layout.PaneID]*pane, len(ids)) | |||
| 458 | + | for _, id := range ids { | |||
| 459 | + | ptys[id] = &paneRearrangePTY{} | |||
| 460 | + | } | |||
| 461 | + | tb := newTab(ptys[ids[0]], size) | |||
| 462 | + | first := tb.panes["pane-1"] | |||
| 463 | + | delete(tb.panes, "pane-1") | |||
| 464 | + | first.id = ids[0] | |||
| 465 | + | panes[ids[0]] = first | |||
| 466 | + | tb.panes[ids[0]] = first | |||
| 467 | + | for _, id := range ids[1:] { | |||
| 468 | + | p := newPane(id, ptys[id], size) | |||
| 469 | + | panes[id] = p | |||
| 470 | + | tb.panes[id] = p | |||
| 471 | + | } | |||
| 472 | + | tb.tree = tree.Clone() | |||
| 473 | + | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows}) | |||
| 474 | + | require.True(t, ok, "test fixture layout must solve") | |||
| 475 | + | for _, placement := range placements { | |||
| 476 | + | p := tb.panes[placement.ID] | |||
| 477 | + | p.rect = placement.Content | |||
| 478 | + | p.screen.Resize(placement.Content.Width, placement.Content.Height) | |||
| 479 | + | } | |||
| 480 | + | tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx) | |||
| 481 | + | for _, p := range tb.panes { | |||
| 482 | + | p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx) | |||
| 483 | + | } | |||
| 484 | + | sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}} | |||
| 485 | + | sess.snapEligible.Store(true) | |||
| 486 | + | d.sessions[sess.id] = sess | |||
| 487 | + | return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory} | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) { | |||
| 491 | + | t.Helper() | |||
| 492 | + | ctx, cancel := context.WithCancel(parent) | |||
| 493 | + | t.Cleanup(cancel) | |||
| 494 | + | return ctx, cancel | |||
| 495 | + | } | |||
| 496 | + | ||||
| 497 | + | func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget { | |||
| 498 | + | return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]} | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | func (h *paneRearrangeHarness) totalResizes() int { | |||
| 502 | + | total := 0 | |||
| 503 | + | for _, tracked := range h.ptys { | |||
| 504 | + | total += tracked.resizeCount() | |||
| 505 | + | } | |||
| 506 | + | return total | |||
| 507 | + | } | |||
| 508 | + | ||||
| 509 | + | func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot { | |||
| 510 | + | h.tab.mu.Lock() | |||
| 511 | + | snapshot := paneRearrangeSnapshot{ | |||
| 512 | + | tree: h.tab.tree.Clone(), | |||
| 513 | + | layoutGeneration: h.tab.layoutGeneration, | |||
| 514 | + | panes: make(map[layout.PaneID]*pane, len(h.tab.panes)), | |||
| 515 | + | rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)), | |||
| 516 | + | screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)), | |||
| 517 | + | } | |||
| 518 | + | for id, p := range h.tab.panes { | |||
| 519 | + | snapshot.panes[id] = p | |||
| 520 | + | p.mu.Lock() | |||
| 521 | + | snapshot.rects[id] = p.rect | |||
| 522 | + | snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height} | |||
| 523 | + | p.mu.Unlock() | |||
| 524 | + | } | |||
| 525 | + | h.tab.mu.Unlock() | |||
| 526 | + | h.session.snapshotMu.Lock() | |||
| 527 | + | snapshot.snapshotGeneration = h.session.snapshotGeneration | |||
| 528 | + | snapshot.snapshotDirty = h.session.snapDirty.Load() | |||
| 529 | + | h.session.snapshotMu.Unlock() | |||
| 530 | + | return snapshot | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | func (h *paneRearrangeHarness) stateLocksAvailable() bool { | |||
| 534 | + | done := make(chan struct{}) | |||
| 535 | + | go func() { | |||
| 536 | + | h.daemon.mu.Lock() | |||
| 537 | + | h.session.mu.Lock() | |||
| 538 | + | h.tab.mu.Lock() | |||
| 539 | + | for _, p := range h.panes { | |||
| 540 | + | p.mu.Lock() | |||
| 541 | + | } | |||
| 542 | + | for _, p := range h.panes { | |||
| 543 | + | p.mu.Unlock() | |||
| 544 | + | } | |||
| 545 | + | h.tab.mu.Unlock() | |||
| 546 | + | h.session.mu.Unlock() | |||
| 547 | + | h.daemon.mu.Unlock() | |||
| 548 | + | close(done) | |||
| 549 | + | }() | |||
| 550 | + | select { | |||
| 551 | + | case <-done: | |||
| 552 | + | return true | |||
| 553 | + | case <-time.After(250 * time.Millisecond): | |||
| 554 | + | return false | |||
| 555 | + | } | |||
| 556 | + | } | |||
| 557 | + | ||||
| 558 | + | func threeColumnTree() *layout.Tree { | |||
| 559 | + | return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{ | |||
| 560 | + | layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"), | |||
| 561 | + | }}, Focus: "pane-1"} | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | type paneRearrangePTY struct { | |||
| 565 | + | mu sync.Mutex | |||
| 566 | + | resizes []domain.Size | |||
| 567 | + | closes int | |||
| 568 | + | onResize func() | |||
| 569 | + | } | |||
| 570 | + | ||||
| 571 | + | func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF } | |||
| 572 | + | func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil } | |||
| 573 | + | func (p *paneRearrangePTY) Resize(size domain.Size) error { | |||
| 574 | + | p.mu.Lock() | |||
| 575 | + | p.resizes = append(p.resizes, size) | |||
| 576 | + | hook := p.onResize | |||
| 577 | + | p.mu.Unlock() | |||
| 578 | + | if hook != nil { | |||
| 579 | + | hook() | |||
| 580 | + | } | |||
| 581 | + | return nil | |||
| 582 | + | } | |||
| 583 | + | func (p *paneRearrangePTY) Close() error { | |||
| 584 | + | p.mu.Lock() | |||
| 585 | + | p.closes++ | |||
| 586 | + | p.mu.Unlock() | |||
| 587 | + | return nil | |||
| 588 | + | } | |||
| 589 | + | func (*paneRearrangePTY) Pid() int { return 0 } | |||
| 590 | + | func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil } | |||
| 591 | + | ||||
| 592 | + | func (p *paneRearrangePTY) resizeCount() int { | |||
| 593 | + | p.mu.Lock() | |||
| 594 | + | defer p.mu.Unlock() | |||
| 595 | + | return len(p.resizes) | |||
| 596 | + | } | |||
| 597 | + | ||||
| 598 | + | func (p *paneRearrangePTY) closeCount() int { | |||
| 599 | + | p.mu.Lock() | |||
| 600 | + | defer p.mu.Unlock() | |||
| 601 | + | return p.closes | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | var _ ports.PTY = (*paneRearrangePTY)(nil) | |||
M internal/usecase/daemon/performance_benchmark_test.go +4 -6
| @@ -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/picker_test.go +1 -1
| @@ -1344,7 +1344,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||||
| 1344 | func TestPickerModalGeometry(t *testing.T) { | 1344 | func TestPickerModalGeometry(t *testing.T) { | ||
| 1345 | base := domain.Size{Cols: 100, Rows: 40} | 1345 | base := domain.Size{Cols: 100, Rows: 40} | ||
| 1346 | 1346 | ||||
| 1347 | - | require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base)) | 1347 | + | require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds) |
| 1348 | require.Equal(t, domain.AnchorCenter, pickerModal.Anchor) | 1348 | require.Equal(t, domain.AnchorCenter, pickerModal.Anchor) | ||
| 1349 | } | 1349 | } | ||
| 1350 | 1350 | ||||
M internal/usecase/daemon/prompt_test.go +1 -1
| @@ -20,7 +20,7 @@ func (f failingPTYFactory) Open(context.Context, string, []string, []string, str | |||||
| 20 | func TestPromptModalGeometry(t *testing.T) { | 20 | func TestPromptModalGeometry(t *testing.T) { | ||
| 21 | base := domain.Size{Cols: 100, Rows: 40} | 21 | base := domain.Size{Cols: 100, Rows: 40} | ||
| 22 | 22 | ||||
| 23 | - | require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base)) | 23 | + | require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds) |
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | func TestEnterPromptRendersTitleAndPrefill(t *testing.T) { | 26 | func TestEnterPromptRendersTitleAndPrefill(t *testing.T) { | ||
M internal/usecase/daemon/render_capture.go +5 -3
| @@ -102,9 +102,11 @@ type capturedOverlayRenderState struct { | |||||
| 102 | } | 102 | } | ||
| 103 | 103 | ||||
| 104 | type capturedModal struct { | 104 | type capturedModal struct { | ||
| 105 | - | modal ui.Modal | 105 | + | active bool |
| 106 | - | inner renderer.Frame | 106 | + | title string |
| 107 | - | focused bool | 107 | + | presentation ui.Presentation |
| 108 | + | inner renderer.Frame | |||
| 109 | + | focused bool | |||
| 108 | } | 110 | } | ||
| 109 | 111 | ||||
| 110 | func (o capturedOverlayRenderState) active() bool { | 112 | func (o capturedOverlayRenderState) active() bool { | ||
M internal/usecase/daemon/render_pipeline.go +91 -69
| @@ -126,30 +126,43 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 126 | damage = append(damage, d) | 126 | damage = append(damage, d) | ||
| 127 | } | 127 | } | ||
| 128 | } | 128 | } | ||
| 129 | - | // Keep the committed cache unadorned: floating composition clones this | 129 | + | // Keep the committed cache unadorned. Toasts are composed first so floating |
| 130 | - | // base, so closing or moving a popup cannot retain dimmed/bordered cells. | 130 | + | // terminals and scoped modals dim them with the rest of the complete frame. |
| 131 | baseFrame := frame | 131 | baseFrame := frame | ||
| 132 | + | overlaysActive := state.overlays.active() | |||
| 133 | + | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |||
| 134 | + | var toastFootprints []domain.Rect | |||
| 135 | + | if toastsVisible { | |||
| 136 | + | frame = baseFrame.Clone() | |||
| 137 | + | toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles) | |||
| 138 | + | } | |||
| 132 | if state.floating.visible { | 139 | if state.floating.visible { | ||
| 133 | var floatingDamage []renderer.Damage | 140 | var floatingDamage []renderer.Damage | ||
| 134 | frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ | 141 | frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ | ||
| 135 | - | baseFrame: baseFrame, | 142 | + | baseFrame: frame, |
| 136 | baseDamage: damage, | 143 | baseDamage: damage, | ||
| 137 | floating: state.floating, | 144 | floating: state.floating, | ||
| 138 | content: content, | 145 | content: content, | ||
| 139 | - | layout: state.layout, | |||
| 140 | theme: state.theme, | 146 | theme: state.theme, | ||
| 141 | borderMuted: styles.BorderMuted, | 147 | borderMuted: styles.BorderMuted, | ||
| 142 | borderActive: styles.BorderActive, | 148 | borderActive: styles.BorderActive, | ||
| 143 | cache: in, | 149 | cache: in, | ||
| 144 | - | full: full || state.overlays.active(), | 150 | + | full: full || overlaysActive, |
| 145 | }) | 151 | }) | ||
| 146 | damage = floatingDamage | 152 | damage = floatingDamage | ||
| 147 | } | 153 | } | ||
| 154 | + | if overlaysActive { | |||
| 155 | + | if !toastsVisible && !state.floating.visible { | |||
| 156 | + | frame = baseFrame.Clone() | |||
| 157 | + | } | |||
| 158 | + | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |||
| 159 | + | frame, damage = composeCapturedOverlays(state, frame, damage) | |||
| 160 | + | } | |||
| 148 | if !full { | 161 | if !full { | ||
| 149 | - | if !sameCells(in.bars.top, frame.Row(0)) { | 162 | + | if !sameCells(in.bars.top, baseFrame.Row(0)) { |
| 150 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | 163 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | ||
| 151 | } | 164 | } | ||
| 152 | - | if !sameCells(in.bars.bottom, frame.Row(rows+1)) { | 165 | + | if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) { |
| 153 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | 166 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | ||
| 154 | } | 167 | } | ||
| 155 | } | 168 | } | ||
| @@ -157,17 +170,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 157 | // toasts, so every render damages both the last and current toast coverage. | 170 | // toasts, so every render damages both the last and current toast coverage. | ||
| 158 | // This restores cells exposed by dismissal and redraws a stable toast over | 171 | // This restores cells exposed by dismissal and redraws a stable toast over | ||
| 159 | // any underlying pane update without promoting either case to a full frame. | 172 | // any underlying pane update without promoting either case to a full frame. | ||
| 160 | - | overlaysActive := state.overlays.active() | |||
| 161 | - | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |||
| 162 | - | if overlaysActive || toastsVisible { | |||
| 163 | - | // Without a floating frame, overlay composition would otherwise mutate | |||
| 164 | - | // the base cache in place. Floating composition already owns a clone. | |||
| 165 | - | if !state.floating.visible { | |||
| 166 | - | baseFrame = frame.Clone() | |||
| 167 | - | } | |||
| 168 | - | frame, damage = composeCapturedOverlays(state, frame, damage, content) | |||
| 169 | - | } | |||
| 170 | - | toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles) | |||
| 171 | if full || overlaysActive { | 173 | if full || overlaysActive { | ||
| 172 | damage = []renderer.Damage{renderer.FullRedraw()} | 174 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 173 | } else { | 175 | } else { | ||
| @@ -222,91 +224,111 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 222 | } | 224 | } | ||
| 223 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | 225 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | ||
| 224 | if snap.copySearchModel != nil { | 226 | if snap.copySearchModel != nil { | ||
| 225 | - | o.copySearch.modal = copySearchModal | 227 | + | presentation := copySearchModal.Resolve(size) |
| 226 | - | o.copySearch.focused = true | 228 | + | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} |
| 227 | - | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | 229 | + | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) |
| 228 | } | 230 | } | ||
| 229 | if snap.pickerActive && snap.pickerModel != nil { | 231 | if snap.pickerActive && snap.pickerModel != nil { | ||
| 230 | - | o.picker.modal = pickerModal | 232 | + | presentation := pickerModal.Resolve(size) |
| 231 | - | o.picker.focused = true | 233 | + | o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true} |
| 232 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator} | 234 | renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator} | ||
| 233 | - | o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles) | 235 | + | o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles) |
| 234 | } | 236 | } | ||
| 235 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | 237 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | ||
| 236 | - | o.noticesOverlay.modal = noticesModal | 238 | + | presentation := noticesModal.Resolve(size) |
| 237 | - | o.noticesOverlay.focused = true | 239 | + | o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true} |
| 238 | renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} | 240 | renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted} | ||
| 239 | - | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles) | 241 | + | o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles) |
| 240 | } | 242 | } | ||
| 241 | if snap.paletteActive && snap.paletteModel != nil { | 243 | if snap.paletteActive && snap.paletteModel != nil { | ||
| 242 | - | o.palette.modal = paletteModalFor(size, paletteCfg) | 244 | + | modal := paletteModalFor(size, paletteCfg) |
| 243 | - | o.palette.focused = true | 245 | + | presentation := modal.Resolve(size) |
| 246 | + | o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} | |||
| 244 | guidance := "" | 247 | guidance := "" | ||
| 245 | if snap.paletteHints != nil { | 248 | if snap.paletteHints != nil { | ||
| 246 | guidance = snap.paletteHints.Feedback | 249 | guidance = snap.paletteHints.Feedback | ||
| 247 | } | 250 | } | ||
| 248 | o.paletteGuidance = snap.paletteFeedback | 251 | o.paletteGuidance = snap.paletteFeedback | ||
| 249 | - | o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback}) | 252 | + | o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback}) |
| 250 | } | 253 | } | ||
| 251 | if snap.promptActive && snap.promptModel != nil { | 254 | if snap.promptActive && snap.promptModel != nil { | ||
| 252 | - | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | 255 | + | modal := promptModalFor(snap.promptModel.Title()) |
| 253 | - | o.prompt.focused = true | 256 | + | presentation := modal.Resolve(size) |
| 254 | - | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) | 257 | + | o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} |
| 258 | + | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) | |||
| 255 | } | 259 | } | ||
| 256 | state.cursor.hiddenByOverlay = o.active() | 260 | state.cursor.hiddenByOverlay = o.active() | ||
| 257 | } | 261 | } | ||
| 258 | 262 | ||||
| 259 | - | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { | 263 | + | func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) { |
| 260 | o := state.overlays | 264 | o := state.overlays | ||
| 261 | - | if o.copyActive { | 265 | + | if !o.copyActive { |
| 262 | - | target := domain.Rect{} | 266 | + | return frame, damage |
| 263 | - | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | 267 | + | } |
| 264 | - | target = state.floating.geometry.translate(content.X, content.Y).Inner | 268 | + | target := domain.Rect{} |
| 269 | + | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | |||
| 270 | + | target = state.floating.geometry.translate(content.X, content.Y).Inner | |||
| 271 | + | } | |||
| 272 | + | if target.Width == 0 { | |||
| 273 | + | id := o.copyPaneID | |||
| 274 | + | if id == "" { | |||
| 275 | + | id = state.layout.focus | |||
| 265 | } | 276 | } | ||
| 266 | - | if target.Width == 0 { | 277 | + | for _, p := range state.panes { |
| 267 | - | id := o.copyPaneID | 278 | + | if p.id == id && !p.placement.Collapsed { |
| 268 | - | if id == "" { | 279 | + | target = p.placement.Content |
| 269 | - | id = state.layout.focus | 280 | + | target.Y += content.Y |
| 270 | - | } | 281 | + | break |
| 271 | - | for _, p := range state.panes { | |||
| 272 | - | if p.id == id && !p.placement.Collapsed { | |||
| 273 | - | target = p.placement.Content | |||
| 274 | - | target.Y += content.Y | |||
| 275 | - | break | |||
| 276 | - | } | |||
| 277 | } | 282 | } | ||
| 278 | } | 283 | } | ||
| 279 | - | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles) | 284 | + | } |
| 280 | - | } | 285 | + | return composeCopyClientFrame(o.copyMode, target, frame, state.styles) |
| 281 | - | layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | 286 | + | } |
| 282 | - | if o.paletteActive && !state.floating.visible { | 287 | + | |
| 283 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | 288 | + | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { |
| 284 | - | } | 289 | + | o := state.overlays |
| 285 | - | // This paint order intentionally differs from HandleInput's keyboard | 290 | + | // Paint in reverse keyboard priority so the same layer that owns input is |
| 286 | - | // priority (prompt > palette > picker > notices > copy, see | 291 | + | // visually topmost: prompt > palette > picker > notices > copy search. |
| 287 | - | // overlay_runtime.go), which paints the picker under notices instead of | 292 | + | for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} { |
| 288 | - | // over it. Currently unreachable: notices only opens via the palette, and | 293 | + | if !modal.active { |
| 289 | - | // HandleInput short-circuits to the first active overlay, so picker and | |||
| 290 | - | // notices are never simultaneously active. If that ever changes, this | |||
| 291 | - | // mismatch would let the picker own the keyboard while notices visually | |||
| 292 | - | // covers it. | |||
| 293 | - | for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} { | |||
| 294 | - | if modal.inner.Width == 0 && modal.inner.Height == 0 { | |||
| 295 | continue | 294 | continue | ||
| 296 | } | 295 | } | ||
| 296 | + | applyOverlayBackdrop(frame, state.theme) | |||
| 297 | border := state.styles.BorderMuted | 297 | border := state.styles.BorderMuted | ||
| 298 | if modal.focused { | 298 | if modal.focused { | ||
| 299 | border = state.styles.BorderActive | 299 | border = state.styles.BorderActive | ||
| 300 | } | 300 | } | ||
| 301 | - | inner := modal.modal.Composite(frame, border, state.styles.PickerBase) | 301 | + | (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase) |
| 302 | - | for y := range min(inner.Height, modal.inner.Height) { | 302 | + | copyModalInner(frame, modal.presentation.Inner, modal.inner) |
| 303 | - | copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)]) | |||
| 304 | - | } | |||
| 305 | damage = []renderer.Damage{renderer.FullRedraw()} | 303 | damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 306 | } | 304 | } | ||
| 307 | return frame, damage | 305 | return frame, damage | ||
| 308 | } | 306 | } | ||
| 309 | 307 | ||||
| 308 | + | // copyModalInner copies a captured model frame into its resolved destination. | |||
| 309 | + | // Both source and destination are clipped so degenerate presentations remain | |||
| 310 | + | // safe immutable composition inputs. | |||
| 311 | + | func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) { | |||
| 312 | + | left := max(target.X, 0) | |||
| 313 | + | top := max(target.Y, 0) | |||
| 314 | + | right := min(target.X+target.Width, dst.Width) | |||
| 315 | + | bottom := min(target.Y+target.Height, dst.Height) | |||
| 316 | + | if left >= right || top >= bottom { | |||
| 317 | + | return | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | sourceX := left - target.X | |||
| 321 | + | sourceY := top - target.Y | |||
| 322 | + | width := min(right-left, src.Width-sourceX) | |||
| 323 | + | height := min(bottom-top, src.Height-sourceY) | |||
| 324 | + | if width <= 0 || height <= 0 { | |||
| 325 | + | return | |||
| 326 | + | } | |||
| 327 | + | for y := range height { | |||
| 328 | + | copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width]) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 310 | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | 332 | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | ||
| 311 | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | 333 | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | ||
| 312 | return nil | 334 | return nil | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -1
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 11 | "github.com/bnema/vev/internal/usecase/layout" | 11 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 13 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 13 | "github.com/bnema/vev/pkg/renderer" | 14 | "github.com/bnema/vev/pkg/renderer" | ||
| 14 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 15 | ) | 16 | ) | ||
| @@ -326,7 +327,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||||
| 326 | reset: false, | 327 | reset: false, | ||
| 327 | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | 328 | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | ||
| 328 | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | 329 | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | ||
| 329 | - | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | 330 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, |
| 330 | bars: barState{topRight: title, bottomRight: title}, | 331 | bars: barState{topRight: title, bottomRight: title}, | ||
| 331 | styles: resolveStyles(nil), | 332 | styles: resolveStyles(nil), | ||
| 332 | } | 333 | } | ||
M internal/usecase/daemon/render_pipeline_test.go +253 -7
| @@ -9,13 +9,20 @@ import ( | |||||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | "testing" | 11 | "testing" | ||
| 12 | + | "time" | |||
| 12 | 13 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 14 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 15 | "github.com/bnema/vev/internal/ports" | ||
| 15 | "github.com/bnema/vev/internal/testutil/replaytest" | 16 | "github.com/bnema/vev/internal/testutil/replaytest" | ||
| 17 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |||
| 16 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 19 | + | "github.com/bnema/vev/internal/usecase/notices" | |||
| 20 | + | "github.com/bnema/vev/internal/usecase/palette" | |||
| 21 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 22 | + | promptui "github.com/bnema/vev/internal/usecase/prompt" | |||
| 17 | themeui "github.com/bnema/vev/internal/usecase/theme" | 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 18 | "github.com/bnema/vev/internal/usecase/ui" | 24 | "github.com/bnema/vev/internal/usecase/ui" | ||
| 25 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |||
| 19 | "github.com/bnema/vev/pkg/renderer" | 26 | "github.com/bnema/vev/pkg/renderer" | ||
| 20 | "github.com/bnema/vev/pkg/vt" | 27 | "github.com/bnema/vev/pkg/vt" | ||
| 21 | "github.com/stretchr/testify/require" | 28 | "github.com/stretchr/testify/require" | ||
| @@ -220,21 +227,21 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||||
| 220 | reset: true, | 227 | reset: true, | ||
| 221 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}}, | 228 | layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}}, | ||
| 222 | panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | 229 | panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | ||
| 223 | - | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1}, | 230 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1}, |
| 224 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | 231 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | ||
| 225 | - | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}}, | 232 | + | overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}}, |
| 226 | cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, | 233 | cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, | ||
| 227 | styles: resolveStyles(nil), | 234 | styles: resolveStyles(nil), | ||
| 228 | } | 235 | } | ||
| 229 | composed := composeFrame(state, composeCacheInput{}) | 236 | composed := composeFrame(state, composeCacheInput{}) | ||
| 230 | - | require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame)) | 237 | + | require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT ", " ", " sess B"}, frameRows(composed.frame)) |
| 231 | require.True(t, composed.cursor.hidden, "overlay owns cursor visibility") | 238 | require.True(t, composed.cursor.hidden, "overlay owns cursor visibility") | ||
| 232 | 239 | ||||
| 233 | stream := newOutputStateStream() | 240 | stream := newOutputStateStream() | ||
| 234 | prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset) | 241 | prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset) | ||
| 235 | require.NoError(t, err) | 242 | require.NoError(t, err) | ||
| 236 | terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...) | 243 | terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...) | ||
| 237 | - | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) | 244 | + | require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes)) |
| 238 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | 245 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | ||
| 239 | client.Write(terminalBytes) | 246 | client.Write(terminalBytes) | ||
| 240 | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | 247 | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | ||
| @@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||||
| 298 | active := renderer.Style{Foreground: 3, Background: 4} | 305 | active := renderer.Style{Foreground: 3, Background: 4} | ||
| 299 | interior := renderer.Style{Foreground: 5, Background: 6} | 306 | interior := renderer.Style{Foreground: 5, Background: 6} | ||
| 300 | modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"} | 307 | modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"} | ||
| 308 | + | presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8}) | |||
| 301 | 309 | ||||
| 302 | for _, tt := range []struct { | 310 | for _, tt := range []struct { | ||
| 303 | name string | 311 | name string | ||
| @@ -310,16 +318,254 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||||
| 310 | t.Run(tt.name, func(t *testing.T) { | 318 | t.Run(tt.name, func(t *testing.T) { | ||
| 311 | state := capturedRenderState{ | 319 | state := capturedRenderState{ | ||
| 312 | styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active}, | 320 | styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active}, | ||
| 313 | - | overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}}, | 321 | + | overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}}, |
| 314 | } | 322 | } | ||
| 315 | - | frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{}) | 323 | + | frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil) |
| 316 | - | bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8}) | 324 | + | bounds := presentation.Bounds |
| 317 | require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role") | 325 | require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role") | ||
| 318 | require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role") | 326 | require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role") | ||
| 319 | }) | 327 | }) | ||
| 320 | } | 328 | } | ||
| 321 | } | 329 | } | ||
| 322 | 330 | ||||
| 331 | + | func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | |||
| 332 | + | const ( | |||
| 333 | + | width = 100 | |||
| 334 | + | contentRows = 6 | |||
| 335 | + | ) | |||
| 336 | + | styles := resolveStyles(nil) | |||
| 337 | + | theme := backdropTheme() | |||
| 338 | + | leftPlacement := layout.Placement{ | |||
| 339 | + | ID: "left", | |||
| 340 | + | TitleBar: domain.Rect{Width: 49, Height: 1}, | |||
| 341 | + | Content: domain.Rect{Y: 1, Width: 49, Height: 5}, | |||
| 342 | + | } | |||
| 343 | + | rightPlacement := layout.Placement{ | |||
| 344 | + | ID: "right", | |||
| 345 | + | TitleBar: domain.Rect{X: 50, Width: 50, Height: 1}, | |||
| 346 | + | Content: domain.Rect{X: 50, Y: 1, Width: 50, Height: 5}, | |||
| 347 | + | } | |||
| 348 | + | state := capturedRenderState{ | |||
| 349 | + | reset: true, | |||
| 350 | + | layout: capturedTabLayout{ | |||
| 351 | + | area: domain.Rect{Width: width, Height: contentRows}, | |||
| 352 | + | focus: "left", | |||
| 353 | + | placements: []layout.Placement{leftPlacement, rightPlacement}, | |||
| 354 | + | dividers: []layout.Divider{{Rect: domain.Rect{X: 49, Width: 1, Height: contentRows}, Dir: layout.Horizontal}}, | |||
| 355 | + | fingerprint: "modal-backdrop", | |||
| 356 | + | valid: true, | |||
| 357 | + | }, | |||
| 358 | + | panes: []capturedPaneRenderState{ | |||
| 359 | + | {id: "left", title: "left", frame: cachePaneFrame(49, 5, 'L'), placement: leftPlacement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}, | |||
| 360 | + | {id: "right", title: "right", frame: cachePaneFrame(50, 5, 'R'), placement: rightPlacement, damage: []renderer.Damage{renderer.FullRedraw()}}, | |||
| 361 | + | }, | |||
| 362 | + | overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}}, | |||
| 363 | + | styles: styles, | |||
| 364 | + | theme: theme, | |||
| 365 | + | } | |||
| 366 | + | base := composeFrame(state, composeCacheInput{}) | |||
| 367 | + | require.NotEmpty(t, base.cache.toastFootprints) | |||
| 368 | + | ||||
| 369 | + | state.reset = false | |||
| 370 | + | state.panes[0].damage = nil | |||
| 371 | + | state.panes[1].damage = nil | |||
| 372 | + | state.overlays.promptActive = true | |||
| 373 | + | state.overlays.prompt = capturedModal{ | |||
| 374 | + | active: true, | |||
| 375 | + | title: "Prompt", | |||
| 376 | + | presentation: ui.Presentation{ | |||
| 377 | + | Bounds: domain.Rect{X: 80, Y: 5, Width: 10, Height: 2}, | |||
| 378 | + | Inner: domain.Rect{X: 81, Y: 6, Width: 8, Height: 1}, | |||
| 379 | + | Borders: ui.BorderAll, | |||
| 380 | + | }, | |||
| 381 | + | inner: renderer.NewFrame(8, 1), | |||
| 382 | + | focused: true, | |||
| 383 | + | } | |||
| 384 | + | composed := composeFrame(state, base.cache) | |||
| 385 | + | dimmer := themeui.NewDimmer(theme) | |||
| 386 | + | toast := base.cache.toastFootprints[0] | |||
| 387 | + | ||||
| 388 | + | for name, point := range map[string][2]int{ | |||
| 389 | + | "top bar": {0, 0}, | |||
| 390 | + | "pane chrome": {0, 1}, | |||
| 391 | + | "pane content": {0, 2}, | |||
| 392 | + | "divider": {49, 2}, | |||
| 393 | + | "bottom bar": {0, contentRows + 1}, | |||
| 394 | + | "toast": {toast.X, toast.Y}, | |||
| 395 | + | } { | |||
| 396 | + | t.Run(name, func(t *testing.T) { | |||
| 397 | + | x, y := point[0], point[1] | |||
| 398 | + | require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style) | |||
| 399 | + | }) | |||
| 400 | + | } | |||
| 401 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage) | |||
| 402 | + | require.False(t, composed.cache.valid) | |||
| 403 | + | require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned") | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) { | |||
| 407 | + | const ( | |||
| 408 | + | width = 100 | |||
| 409 | + | contentRows = 6 | |||
| 410 | + | ) | |||
| 411 | + | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: width, Height: contentRows}} | |||
| 412 | + | theme := backdropTheme() | |||
| 413 | + | state := capturedRenderState{ | |||
| 414 | + | reset: true, | |||
| 415 | + | layout: capturedTabLayout{area: domain.Rect{Width: width, Height: contentRows}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "floating-toast-backdrop", valid: true}, | |||
| 416 | + | panes: []capturedPaneRenderState{{ | |||
| 417 | + | id: "pane", frame: cachePaneFrame(width, contentRows, 'P'), placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 418 | + | }}, | |||
| 419 | + | overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}}, | |||
| 420 | + | styles: resolveStyles(nil), | |||
| 421 | + | theme: theme, | |||
| 422 | + | } | |||
| 423 | + | toastOnly := composeFrame(state, composeCacheInput{}) | |||
| 424 | + | require.NotEmpty(t, toastOnly.cache.toastFootprints) | |||
| 425 | + | toast := toastOnly.cache.toastFootprints[0] | |||
| 426 | + | ||||
| 427 | + | state.floating = capturedFloatingRenderState{ | |||
| 428 | + | visible: true, | |||
| 429 | + | focused: true, | |||
| 430 | + | pane: capturedPaneRenderState{id: "floating", frame: renderer.NewFrame(8, 1)}, | |||
| 431 | + | geometry: floatingGeometry{ | |||
| 432 | + | Mode: ui.PresentationFloating, | |||
| 433 | + | Bounds: domain.Rect{X: 40, Y: 2, Width: 10, Height: 3}, | |||
| 434 | + | Inner: domain.Rect{X: 41, Y: 3, Width: 8, Height: 1}, | |||
| 435 | + | }, | |||
| 436 | + | generation: 1, | |||
| 437 | + | } | |||
| 438 | + | composed := composeFrame(state, composeCacheInput{}) | |||
| 439 | + | dimmer := themeui.NewDimmer(theme) | |||
| 440 | + | ||||
| 441 | + | for name, point := range map[string][2]int{ | |||
| 442 | + | "top bar": {0, 0}, | |||
| 443 | + | "bottom bar": {0, contentRows + 1}, | |||
| 444 | + | "toast": {toast.X, toast.Y}, | |||
| 445 | + | } { | |||
| 446 | + | t.Run(name, func(t *testing.T) { | |||
| 447 | + | x, y := point[0], point[1] | |||
| 448 | + | require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style) | |||
| 449 | + | }) | |||
| 450 | + | } | |||
| 451 | + | require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache") | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |||
| 455 | + | theme := backdropTheme() | |||
| 456 | + | lowerStyle := renderer.Style{Foreground: 1, Background: 2} | |||
| 457 | + | higherStyle := renderer.Style{Foreground: 3, Background: 4} | |||
| 458 | + | modalAt := func(x int, r rune, style renderer.Style) capturedModal { | |||
| 459 | + | inner := renderer.NewFrame(1, 1) | |||
| 460 | + | inner.Set(0, 0, renderer.Cell{Rune: r, Style: style}) | |||
| 461 | + | bounds := domain.Rect{X: x, Y: 1, Width: 1, Height: 1} | |||
| 462 | + | return capturedModal{active: true, presentation: ui.Presentation{Bounds: bounds, Inner: bounds}, inner: inner} | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | for _, tt := range []struct { | |||
| 466 | + | name string | |||
| 467 | + | lower string | |||
| 468 | + | higher string | |||
| 469 | + | }{ | |||
| 470 | + | {name: "notices above copy search", lower: "copy search", higher: "notices"}, | |||
| 471 | + | {name: "picker above notices", lower: "notices", higher: "picker"}, | |||
| 472 | + | {name: "palette above picker", lower: "picker", higher: "palette"}, | |||
| 473 | + | {name: "prompt above palette", lower: "palette", higher: "prompt"}, | |||
| 474 | + | } { | |||
| 475 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 476 | + | state := capturedRenderState{theme: theme, styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}} | |||
| 477 | + | setModal := func(name string, modal capturedModal) { | |||
| 478 | + | switch name { | |||
| 479 | + | case "copy search": | |||
| 480 | + | state.overlays.copySearch = modal | |||
| 481 | + | case "notices": | |||
| 482 | + | state.overlays.noticesOverlay = modal | |||
| 483 | + | case "picker": | |||
| 484 | + | state.overlays.picker = modal | |||
| 485 | + | case "palette": | |||
| 486 | + | state.overlays.palette = modal | |||
| 487 | + | case "prompt": | |||
| 488 | + | state.overlays.prompt = modal | |||
| 489 | + | } | |||
| 490 | + | } | |||
| 491 | + | setModal(tt.lower, modalAt(1, 'L', lowerStyle)) | |||
| 492 | + | setModal(tt.higher, modalAt(3, 'H', higherStyle)) | |||
| 493 | + | ||||
| 494 | + | frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil) | |||
| 495 | + | ||||
| 496 | + | require.Equal(t, 'L', frame.At(1, 1).Rune) | |||
| 497 | + | require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop") | |||
| 498 | + | require.Equal(t, 'H', frame.At(3, 1).Rune) | |||
| 499 | + | require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost") | |||
| 500 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 501 | + | }) | |||
| 502 | + | } | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) { | |||
| 506 | + | state := capturedRenderState{ | |||
| 507 | + | layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}}, | |||
| 508 | + | styles: fallbackChromeStyles, | |||
| 509 | + | } | |||
| 510 | + | snapshot := scopy.NewSnapshotFromRows(nil, 79, 38) | |||
| 511 | + | snap := &overlayRenderSnapshot{ | |||
| 512 | + | copySearchModel: visualsearch.New(snapshot), | |||
| 513 | + | pickerActive: true, | |||
| 514 | + | pickerModel: picker.New(nil, picker.SelectionConfig{}), | |||
| 515 | + | noticesOverlayActive: true, | |||
| 516 | + | noticesOverlayModel: notices.New(nil, time.Time{}), | |||
| 517 | + | paletteActive: true, | |||
| 518 | + | paletteModel: palette.New(nil), | |||
| 519 | + | promptActive: true, | |||
| 520 | + | promptModel: promptui.New(" Prompt ", ""), | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | captureOverlayLayers(&state, snap, domain.PaletteConfig{}) | |||
| 524 | + | ||||
| 525 | + | for name, modal := range map[string]capturedModal{ | |||
| 526 | + | "copy search": state.overlays.copySearch, | |||
| 527 | + | "picker": state.overlays.picker, | |||
| 528 | + | "notices": state.overlays.noticesOverlay, | |||
| 529 | + | "palette": state.overlays.palette, | |||
| 530 | + | "prompt": state.overlays.prompt, | |||
| 531 | + | } { | |||
| 532 | + | t.Run(name, func(t *testing.T) { | |||
| 533 | + | require.True(t, modal.active) | |||
| 534 | + | require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode) | |||
| 535 | + | require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height}) | |||
| 536 | + | }) | |||
| 537 | + | } | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) { | |||
| 541 | + | base := renderer.NewFrame(4, 3) | |||
| 542 | + | inner := renderer.NewFrame(4, 2) | |||
| 543 | + | for y, text := range []string{"ABCD", "EFGH"} { | |||
| 544 | + | for x, r := range text { | |||
| 545 | + | inner.Set(x, y, renderer.Cell{Rune: r}) | |||
| 546 | + | } | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner) | |||
| 550 | + | ||||
| 551 | + | require.Equal(t, "BCD ", rowText(base.Row(2))) | |||
| 552 | + | } | |||
| 553 | + | ||||
| 554 | + | func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) { | |||
| 555 | + | presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4}) | |||
| 556 | + | require.Zero(t, presentation.Bounds.Height) | |||
| 557 | + | state := capturedRenderState{ | |||
| 558 | + | styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}, | |||
| 559 | + | overlays: capturedOverlayRenderState{ | |||
| 560 | + | copySearch: capturedModal{active: true, title: "Search", presentation: presentation}, | |||
| 561 | + | }, | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | _, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil) | |||
| 565 | + | ||||
| 566 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage) | |||
| 567 | + | } | |||
| 568 | + | ||||
| 323 | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | 569 | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | ||
| 324 | terminal := renderer.DefaultStyle() | 570 | terminal := renderer.DefaultStyle() | ||
| 325 | muted := renderer.Style{Foreground: 1} | 571 | muted := renderer.Style{Foreground: 1} | ||
M internal/usecase/daemon/render_test.go +14 -5
| @@ -48,7 +48,10 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) { | |||||
| 48 | dimmed := client.Frame.At(0, 1) | 48 | dimmed := client.Frame.At(0, 1) | ||
| 49 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | 49 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | ||
| 50 | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame") | 50 | require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame") | ||
| 51 | - | require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp") | 51 | + | for x, cell := range copyBar { |
| 52 | + | cell.Style = themeui.NewDimmer(theme).Dim(cell.Style) | |||
| 53 | + | require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop") | |||
| 54 | + | } | |||
| 52 | paletteVisible := false | 55 | paletteVisible := false | ||
| 53 | for y := range client.Frame.Height { | 56 | for y := range client.Frame.Height { | ||
| 54 | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | 57 | paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands") | ||
| @@ -93,7 +96,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||||
| 93 | } | 96 | } | ||
| 94 | } | 97 | } | ||
| 95 | 98 | ||||
| 96 | - | func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | 99 | + | func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) { |
| 97 | p, release := newBlockingPTY(t) | 100 | p, release := newBlockingPTY(t) | ||
| 98 | defer release() | 101 | defer release() | ||
| 99 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 102 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| @@ -110,7 +113,9 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |||||
| 110 | 113 | ||||
| 111 | d.enterPalette(sess, ac) | 114 | d.enterPalette(sess, ac) | ||
| 112 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 115 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 113 | - | require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp") | 116 | + | dimmedPickerTitle := pickerTitle |
| 117 | + | dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style) | |||
| 118 | + | require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop") | |||
| 114 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | 119 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style") | ||
| 115 | } | 120 | } | ||
| 116 | 121 | ||||
| @@ -133,8 +138,12 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||||
| 133 | dimmed := client.Frame.At(0, 1) | 138 | dimmed := client.Frame.At(0, 1) | ||
| 134 | require.Equal(t, 'X', dimmed.Rune) | 139 | require.Equal(t, 'X', dimmed.Rune) | ||
| 135 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style") | 140 | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style") | ||
| 136 | - | require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp") | 141 | + | dimmedTopBar := topBar |
| 137 | - | require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp") | 142 | + | dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style) |
| 143 | + | dimmedBottomBar := bottomBar | |||
| 144 | + | dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style) | |||
| 145 | + | require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop") | |||
| 146 | + | require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop") | |||
| 138 | 147 | ||||
| 139 | d.handleInput(sess, ac, []byte("\x1b")) | 148 | d.handleInput(sess, ac, []byte("\x1b")) | ||
| 140 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 149 | mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
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 | 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/resize_publication.go +0 -200
| @@ -106,203 +106,3 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac * | |||||
| 106 | } | 106 | } | ||
| 107 | return publication.emit() | 107 | return publication.emit() | ||
| 108 | } | 108 | } | ||
| 109 | - | ||||
| 110 | - | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | |||
| 111 | - | ticket, admitted := beginActiveLeaseEffect(sess, ac, lease) | |||
| 112 | - | if !admitted { | |||
| 113 | - | return false | |||
| 114 | - | } | |||
| 115 | - | defer ticket.End() | |||
| 116 | - | rc := sess.renderCoordinator() | |||
| 117 | - | if rc == nil { | |||
| 118 | - | return false | |||
| 119 | - | } | |||
| 120 | - | snap := rc.resizeSnapshot() | |||
| 121 | - | current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) } | |||
| 122 | - | if !current() { | |||
| 123 | - | return false | |||
| 124 | - | } | |||
| 125 | - | d.exitCopyMode(ac) | |||
| 126 | - | result, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | |||
| 127 | - | return rc.resizeCurrentForLease(epoch, ac, lease, true) | |||
| 128 | - | }) | |||
| 129 | - | if !ok { | |||
| 130 | - | return false | |||
| 131 | - | } | |||
| 132 | - | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |||
| 133 | - | markSnapshotDirty(sess) | |||
| 134 | - | }) { | |||
| 135 | - | return false | |||
| 136 | - | } | |||
| 137 | - | if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) { | |||
| 138 | - | return false | |||
| 139 | - | } | |||
| 140 | - | if len(result.failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | |||
| 141 | - | var retry *resizeRetryReservation | |||
| 142 | - | if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() { | |||
| 143 | - | retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() { | |||
| 144 | - | d.retryResizeMembers(sess, ac, lease, epoch, result.failed) | |||
| 145 | - | }) | |||
| 146 | - | }) || retry == nil { | |||
| 147 | - | return false | |||
| 148 | - | } | |||
| 149 | - | retry.finish() | |||
| 150 | - | } | |||
| 151 | - | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 152 | - | if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch, | |||
| 153 | - | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |||
| 154 | - | return false | |||
| 155 | - | } | |||
| 156 | - | // The resize debounce has already elapsed. Consume this sticky reset now; | |||
| 157 | - | // fire preserves ACK and synchronized-output gates rather than scheduling a | |||
| 158 | - | // second urgent deadline. | |||
| 159 | - | rc.fireCurrent(false) | |||
| 160 | - | return true | |||
| 161 | - | } | |||
| 162 | - | ||||
| 163 | - | // retryResizeMembers retries failed committed members through a freshly | |||
| 164 | - | // prepared tab transaction. The captured members identify retry candidates | |||
| 165 | - | // only: their rectangles must never cross this delayed boundary, because any | |||
| 166 | - | // later layout mutation may have changed their geometry or removed them. | |||
| 167 | - | func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) { | |||
| 168 | - | ticket, admitted := beginActiveLeaseEffect(sess, ac, lease) | |||
| 169 | - | if !admitted { | |||
| 170 | - | return | |||
| 171 | - | } | |||
| 172 | - | defer ticket.End() | |||
| 173 | - | rc := sess.renderCoordinator() | |||
| 174 | - | if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 175 | - | return | |||
| 176 | - | } | |||
| 177 | - | ||||
| 178 | - | // Retain tab order for deterministic external PTY ordering while collapsing | |||
| 179 | - | // several failed members from the same tab into one canonical transaction. | |||
| 180 | - | tabs := make([]*tab, 0, len(members)) | |||
| 181 | - | seen := make(map[*tab]struct{}, len(members)) | |||
| 182 | - | for _, member := range members { | |||
| 183 | - | if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) { | |||
| 184 | - | continue | |||
| 185 | - | } | |||
| 186 | - | if _, ok := seen[member.tab]; ok { | |||
| 187 | - | continue | |||
| 188 | - | } | |||
| 189 | - | seen[member.tab] = struct{}{} | |||
| 190 | - | tabs = append(tabs, member.tab) | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | failed := make([]resizeMember, 0, len(members)) | |||
| 194 | - | succeeded := false | |||
| 195 | - | for _, tb := range tabs { | |||
| 196 | - | if !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 197 | - | return | |||
| 198 | - | } | |||
| 199 | - | // A later accepted layout transaction clears resizeRetry. In that case | |||
| 200 | - | // this delayed callback has no remaining work and, importantly, must not | |||
| 201 | - | // replay the obsolete rectangle it captured before the mutation. | |||
| 202 | - | retryPending := false | |||
| 203 | - | tb.mu.Lock() | |||
| 204 | - | for _, member := range members { | |||
| 205 | - | if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane { | |||
| 206 | - | continue | |||
| 207 | - | } | |||
| 208 | - | member.pane.mu.Lock() | |||
| 209 | - | retryPending = retryPending || member.pane.resizeRetry | |||
| 210 | - | member.pane.mu.Unlock() | |||
| 211 | - | } | |||
| 212 | - | tb.mu.Unlock() | |||
| 213 | - | if !retryPending { | |||
| 214 | - | continue | |||
| 215 | - | } | |||
| 216 | - | ||||
| 217 | - | // applyTabLayoutTransaction captures the current generation, pane | |||
| 218 | - | // pointers, and solved rectangles, and validates them again before any | |||
| 219 | - | // VT/rectangle publication. It also preserves the resize gate and | |||
| 220 | - | // degradation notice behavior for another failed external attempt. | |||
| 221 | - | result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool { | |||
| 222 | - | return rc.retryCurrentForLease(epoch, ac, lease) | |||
| 223 | - | }) | |||
| 224 | - | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 225 | - | return | |||
| 226 | - | } | |||
| 227 | - | failed = append(failed, result.failed...) | |||
| 228 | - | // A retry success is a formerly failed, still-current target whose | |||
| 229 | - | // canonical apply cleared its retry bit. A collapsed or removed target | |||
| 230 | - | // is neither a retry completion nor a reason to publish a reset. | |||
| 231 | - | tb.mu.Lock() | |||
| 232 | - | for _, member := range members { | |||
| 233 | - | if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane { | |||
| 234 | - | continue | |||
| 235 | - | } | |||
| 236 | - | member.pane.mu.Lock() | |||
| 237 | - | succeeded = succeeded || !member.pane.resizeRetry | |||
| 238 | - | member.pane.mu.Unlock() | |||
| 239 | - | } | |||
| 240 | - | tb.mu.Unlock() | |||
| 241 | - | } | |||
| 242 | - | for _, member := range members { | |||
| 243 | - | if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrent(&member) { | |||
| 244 | - | continue | |||
| 245 | - | } | |||
| 246 | - | if !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 247 | - | return | |||
| 248 | - | } | |||
| 249 | - | // Floating panes are outside tb.panes. Validate the exact accepted slot | |||
| 250 | - | // before preparing a fresh geometry; applyVisibleFloatingLayout repeats | |||
| 251 | - | // the same pointer/generation check around external PTY.Resize. | |||
| 252 | - | member.tab.mu.Lock() | |||
| 253 | - | currentSlot := member.tab.floating.state == floatingVisible && | |||
| 254 | - | member.tab.floating.generation == member.floatingGeneration && | |||
| 255 | - | member.tab.floating.pane == member.pane && | |||
| 256 | - | resizeMemberOwnerCurrent(&member) | |||
| 257 | - | retryPending := false | |||
| 258 | - | if currentSlot { | |||
| 259 | - | member.pane.mu.Lock() | |||
| 260 | - | retryPending = member.pane.resizeRetry | |||
| 261 | - | member.pane.mu.Unlock() | |||
| 262 | - | } | |||
| 263 | - | member.tab.mu.Unlock() | |||
| 264 | - | if !currentSlot || !retryPending { | |||
| 265 | - | continue | |||
| 266 | - | } | |||
| 267 | - | result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool { | |||
| 268 | - | return rc.retryCurrentForLease(epoch, ac, lease) | |||
| 269 | - | }, &member) | |||
| 270 | - | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 271 | - | return | |||
| 272 | - | } | |||
| 273 | - | failed = append(failed, result.failed...) | |||
| 274 | - | member.tab.mu.Lock() | |||
| 275 | - | stillCurrent := member.tab.floating.state == floatingVisible && | |||
| 276 | - | member.tab.floating.generation == member.floatingGeneration && | |||
| 277 | - | member.tab.floating.pane == member.pane && | |||
| 278 | - | resizeMemberOwnerCurrent(&member) | |||
| 279 | - | member.tab.mu.Unlock() | |||
| 280 | - | if stillCurrent && len(result.failed) == 0 { | |||
| 281 | - | member.pane.mu.Lock() | |||
| 282 | - | succeeded = succeeded || !member.pane.resizeRetry | |||
| 283 | - | member.pane.mu.Unlock() | |||
| 284 | - | } | |||
| 285 | - | } | |||
| 286 | - | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | |||
| 287 | - | var retry *resizeRetryReservation | |||
| 288 | - | if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() { | |||
| 289 | - | retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() { | |||
| 290 | - | d.retryResizeMembers(sess, ac, lease, epoch, failed) | |||
| 291 | - | }) | |||
| 292 | - | }) || retry == nil { | |||
| 293 | - | return | |||
| 294 | - | } | |||
| 295 | - | retry.finish() | |||
| 296 | - | } | |||
| 297 | - | if succeeded { | |||
| 298 | - | // Retry completion changes VT state after the original layout commit. | |||
| 299 | - | // Keep a named session's eventual snapshot generation aligned with it. | |||
| 300 | - | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() { | |||
| 301 | - | markSnapshotDirty(sess) | |||
| 302 | - | }) { | |||
| 303 | - | return | |||
| 304 | - | } | |||
| 305 | - | d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0, | |||
| 306 | - | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |||
| 307 | - | } | |||
| 308 | - | } | |||
D internal/usecase/daemon/resize_retry.go +0 -136
| @@ -1,136 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import "context" | |||
| 4 | - | ||||
| 5 | - | const maxAcceptedTabLayoutRetries = 3 | |||
| 6 | - | ||||
| 7 | - | // acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane | |||
| 8 | - | // owner generations which accepted the failure. A moved pane cannot lend its | |||
| 9 | - | // old source worker authority over either the PTY or source publications. | |||
| 10 | - | type acceptedTabLayoutRetryToken struct { | |||
| 11 | - | session *session | |||
| 12 | - | tab *tab | |||
| 13 | - | members []resizeMember | |||
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken { | |||
| 17 | - | return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)} | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func (t acceptedTabLayoutRetryToken) current() bool { | |||
| 21 | - | if t.session == nil || t.tab == nil || len(t.members) == 0 { | |||
| 22 | - | return false | |||
| 23 | - | } | |||
| 24 | - | for i := range t.members { | |||
| 25 | - | member := &t.members[i] | |||
| 26 | - | if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrent(member) { | |||
| 27 | - | return false | |||
| 28 | - | } | |||
| 29 | - | } | |||
| 30 | - | return true | |||
| 31 | - | } | |||
| 32 | - | ||||
| 33 | - | // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The | |||
| 34 | - | // worker is deduplicated, derives cancellation from the tab lifecycle, and | |||
| 35 | - | // suppresses repeat degradation notices after the accepted initial failure. | |||
| 36 | - | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool { | |||
| 37 | - | if d.clock == nil || tb == nil || tb.ctx == nil { | |||
| 38 | - | return false | |||
| 39 | - | } | |||
| 40 | - | var members []resizeMember | |||
| 41 | - | if len(failed) != 0 { | |||
| 42 | - | members = failed[0] | |||
| 43 | - | } else { | |||
| 44 | - | tb.mu.Lock() | |||
| 45 | - | plan := prepareTabLayoutLocked(sess, tb) | |||
| 46 | - | tb.mu.Unlock() | |||
| 47 | - | for i := range plan.members { | |||
| 48 | - | member := plan.members[i] | |||
| 49 | - | member.pane.mu.Lock() | |||
| 50 | - | pending := member.pane.resizeRetry | |||
| 51 | - | member.pane.mu.Unlock() | |||
| 52 | - | if pending { | |||
| 53 | - | members = append(members, member) | |||
| 54 | - | } | |||
| 55 | - | } | |||
| 56 | - | } | |||
| 57 | - | token := newAcceptedTabLayoutRetryToken(sess, tb, members) | |||
| 58 | - | var ctx context.Context | |||
| 59 | - | var cancel context.CancelFunc | |||
| 60 | - | start := false | |||
| 61 | - | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() { | |||
| 62 | - | tb.layoutRetryMu.Lock() | |||
| 63 | - | defer tb.layoutRetryMu.Unlock() | |||
| 64 | - | if tb.layoutRetryRunning { | |||
| 65 | - | return | |||
| 66 | - | } | |||
| 67 | - | ctx, cancel = context.WithCancel(tb.ctx) | |||
| 68 | - | tb.layoutRetryRunning = true | |||
| 69 | - | tb.layoutRetryCancel = cancel | |||
| 70 | - | start = true | |||
| 71 | - | }) { | |||
| 72 | - | return false | |||
| 73 | - | } | |||
| 74 | - | if !start { | |||
| 75 | - | return true | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | go func() { | |||
| 79 | - | defer func() { | |||
| 80 | - | cancel() | |||
| 81 | - | tb.layoutRetryMu.Lock() | |||
| 82 | - | tb.layoutRetryRunning = false | |||
| 83 | - | tb.layoutRetryCancel = nil | |||
| 84 | - | tb.layoutRetryMu.Unlock() | |||
| 85 | - | }() | |||
| 86 | - | for range maxAcceptedTabLayoutRetries { | |||
| 87 | - | if !token.current() { | |||
| 88 | - | return | |||
| 89 | - | } | |||
| 90 | - | timer := d.clock.NewTimer(minOutputRenderDeadline) | |||
| 91 | - | if timer == nil { | |||
| 92 | - | return | |||
| 93 | - | } | |||
| 94 | - | timerC := timer.C() | |||
| 95 | - | if timerC == nil { | |||
| 96 | - | timer.Stop() | |||
| 97 | - | return | |||
| 98 | - | } | |||
| 99 | - | select { | |||
| 100 | - | case <-ctx.Done(): | |||
| 101 | - | timer.Stop() | |||
| 102 | - | return | |||
| 103 | - | case <-timerC: | |||
| 104 | - | } | |||
| 105 | - | if ctx.Err() != nil || !token.current() { | |||
| 106 | - | return | |||
| 107 | - | } | |||
| 108 | - | result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { | |||
| 109 | - | return ctx.Err() == nil && token.current() | |||
| 110 | - | }) | |||
| 111 | - | if !ok || !token.current() { | |||
| 112 | - | return | |||
| 113 | - | } | |||
| 114 | - | if len(result.failed) == 0 { | |||
| 115 | - | return | |||
| 116 | - | } | |||
| 117 | - | token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed) | |||
| 118 | - | if !token.current() { | |||
| 119 | - | return | |||
| 120 | - | } | |||
| 121 | - | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |||
| 122 | - | markSnapshotDirty(sess) | |||
| 123 | - | }) { | |||
| 124 | - | return | |||
| 125 | - | } | |||
| 126 | - | sess.mu.Lock() | |||
| 127 | - | ac := sess.client | |||
| 128 | - | sess.mu.Unlock() | |||
| 129 | - | if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0, | |||
| 130 | - | renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |||
| 131 | - | return | |||
| 132 | - | } | |||
| 133 | - | } | |||
| 134 | - | }() | |||
| 135 | - | return true | |||
| 136 | - | } | |||
A internal/usecase/daemon/resize_retry_token.go +27 -0
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | // acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane | |||
| 4 | + | // owner generations which accepted the failure. A moved pane cannot lend its | |||
| 5 | + | // old source worker authority over either the PTY or source publications. | |||
| 6 | + | type acceptedTabLayoutRetryToken struct { | |||
| 7 | + | session *session | |||
| 8 | + | tab *tab | |||
| 9 | + | members []resizeMember | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken { | |||
| 13 | + | return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)} | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (t acceptedTabLayoutRetryToken) current() bool { | |||
| 17 | + | if t.session == nil || t.tab == nil || len(t.members) == 0 { | |||
| 18 | + | return false | |||
| 19 | + | } | |||
| 20 | + | for i := range t.members { | |||
| 21 | + | member := &t.members[i] | |||
| 22 | + | if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrent(member) { | |||
| 23 | + | return false | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | return true | |||
| 27 | + | } | |||
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 | 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) | ||
M internal/usecase/daemon/snatched_render.go +2 -1
| @@ -23,7 +23,8 @@ func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string | |||||
| 23 | return frame | 23 | return frame | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | - | inner := snatchedModal.Composite(frame, styles.BorderMuted, styles.PickerBase) | 26 | + | presentation := snatchedModal.Resolve(domain.Size{Cols: width, Rows: height}) |
| 27 | + | inner := snatchedModal.CompositePresentation(frame, presentation, styles.BorderMuted, styles.PickerBase) | |||
| 27 | message := "This session is now active elsewhere." | 28 | message := "This session is now active elsewhere." | ||
| 28 | if feedback != "" { | 29 | if feedback != "" { | ||
| 29 | message = feedback | 30 | message = feedback | ||
M internal/usecase/daemon/snatched_render_test.go +1 -1
| @@ -92,7 +92,7 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||||
| 92 | return | 92 | return | ||
| 93 | } | 93 | } | ||
| 94 | 94 | ||||
| 95 | - | require.Equal(t, tt.wantBounds, snatchedModal.Bounds(tt.wantSize)) | 95 | + | require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds) |
| 96 | topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y) | 96 | topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y) | ||
| 97 | bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1) | 97 | bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1) | ||
| 98 | require.Equal(t, '┌', topLeft.Rune) | 98 | require.Equal(t, '┌', topLeft.Rune) | ||
M internal/usecase/daemon/transactional_resize.go +295 -111
| @@ -1,6 +1,8 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | ||||
| 4 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 5 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 6 | "github.com/bnema/vev/internal/usecase/layout" | 8 | "github.com/bnema/vev/internal/usecase/layout" | ||
| @@ -9,13 +11,13 @@ import ( | |||||
| 9 | type resizeOwnerPostEffect uint8 | 11 | type resizeOwnerPostEffect uint8 | ||
| 10 | 12 | ||||
| 11 | const ( | 13 | const ( | ||
| 12 | - | resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota + 1 | 14 | + | resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota |
| 13 | resizeOwnerPostRetrySchedule | 15 | resizeOwnerPostRetrySchedule | ||
| 14 | resizeOwnerPostRenderInvalidation | 16 | resizeOwnerPostRenderInvalidation | ||
| 15 | resizeOwnerPostCommitPublication | 17 | resizeOwnerPostCommitPublication | ||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| 18 | - | // resizeMember is an unpublished layout decision. Prepare only reads guarded | 20 | + | // resizeMember is an unpublished layout decision. Prepare only reads guarded |
| 19 | // state; apply performs the external PTY call; commit is the sole publisher. | 21 | // state; apply performs the external PTY call; commit is the sole publisher. | ||
| 20 | type resizeMember struct { | 22 | type resizeMember struct { | ||
| 21 | session *session | 23 | session *session | ||
| @@ -43,11 +45,6 @@ type preparedTabLayout struct { | |||||
| 43 | members []resizeMember | 45 | members []resizeMember | ||
| 44 | } | 46 | } | ||
| 45 | 47 | ||||
| 46 | - | type resizeApplyResult struct { | |||
| 47 | - | members []resizeMember | |||
| 48 | - | failed []resizeMember | |||
| 49 | - | } | |||
| 50 | - | ||||
| 51 | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | 48 | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | ||
| 52 | return prepareTabLayoutForSizeLocked(sess, tb, tb.size) | 49 | return prepareTabLayoutForSizeLocked(sess, tb, tb.size) | ||
| 53 | } | 50 | } | ||
| @@ -138,9 +135,6 @@ func resizeMemberOwnerCurrent(member *resizeMember) bool { | |||||
| 138 | if member == nil || member.pane == nil { | 135 | if member == nil || member.pane == nil { | ||
| 139 | return false | 136 | return false | ||
| 140 | } | 137 | } | ||
| 141 | - | // A zero lease is retained only for focused tests which construct the | |||
| 142 | - | // transactional primitive directly. Every prepared production member owns | |||
| 143 | - | // an immutable generation lease. | |||
| 144 | if member.owner.owner == nil { | 138 | if member.owner.owner == nil { | ||
| 145 | return true | 139 | return true | ||
| 146 | } | 140 | } | ||
| @@ -170,33 +164,6 @@ func resizeMembersOwnerCurrent(members []resizeMember) bool { | |||||
| 170 | return true | 164 | return true | ||
| 171 | } | 165 | } | ||
| 172 | 166 | ||||
| 173 | - | func (d *Daemon) publishResizeOwnerInvalidation(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool { | |||
| 174 | - | var reservation *renderInvalidationReservation | |||
| 175 | - | fallback := false | |||
| 176 | - | if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRenderInvalidation, func() { | |||
| 177 | - | if rc := sess.renderCoordinator(); rc != nil { | |||
| 178 | - | reservation, _ = rc.reserveInvalidationForLeaseAtResizeEpoch(ac, lease, epoch, inv) | |||
| 179 | - | return | |||
| 180 | - | } | |||
| 181 | - | fallback = ac != nil | |||
| 182 | - | }) { | |||
| 183 | - | return false | |||
| 184 | - | } | |||
| 185 | - | if reservation != nil { | |||
| 186 | - | reservation.finish() | |||
| 187 | - | return true | |||
| 188 | - | } | |||
| 189 | - | if fallback { | |||
| 190 | - | d.paint(sess, ac, inv.reset, nil) | |||
| 191 | - | return true | |||
| 192 | - | } | |||
| 193 | - | return ac == nil | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | func preparedTabOwnerStale(plan *preparedTabLayout) bool { | |||
| 197 | - | return plan != nil && !resizeMembersOwnerCurrent(plan.members) | |||
| 198 | - | } | |||
| 199 | - | ||||
| 200 | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool { | 167 | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool { | ||
| 201 | for i := range plan.members { | 168 | for i := range plan.members { | ||
| 202 | member := &plan.members[i] | 169 | member := &plan.members[i] | ||
| @@ -270,22 +237,22 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool | |||||
| 270 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | 237 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | ||
| 271 | // one per-tab single-writer loop, but never holds tab or pane state locks while | 238 | // one per-tab single-writer loop, but never holds tab or pane state locks while | ||
| 272 | // invoking PTY.Resize. | 239 | // invoking PTY.Resize. | ||
| 273 | - | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) (resizeApplyResult, bool) { | 240 | + | func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) { |
| 274 | return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...) | 241 | return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...) | ||
| 275 | } | 242 | } | ||
| 276 | 243 | ||||
| 277 | - | func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) (resizeApplyResult, bool) { | 244 | + | func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) { |
| 278 | if tb == nil { | 245 | if tb == nil { | ||
| 279 | - | return resizeApplyResult{}, false | 246 | + | return nil, false |
| 280 | } | 247 | } | ||
| 281 | tb.layoutApplyMu.Lock() | 248 | tb.layoutApplyMu.Lock() | ||
| 282 | defer tb.layoutApplyMu.Unlock() | 249 | defer tb.layoutApplyMu.Unlock() | ||
| 283 | for { | 250 | for { | ||
| 284 | if tb.ctx != nil && tb.ctx.Err() != nil { | 251 | if tb.ctx != nil && tb.ctx.Err() != nil { | ||
| 285 | - | return resizeApplyResult{}, false | 252 | + | return nil, false |
| 286 | } | 253 | } | ||
| 287 | if len(current) != 0 && !current[0]() { | 254 | if len(current) != 0 && !current[0]() { | ||
| 288 | - | return resizeApplyResult{}, false | 255 | + | return nil, false |
| 289 | } | 256 | } | ||
| 290 | tb.mu.Lock() | 257 | tb.mu.Lock() | ||
| 291 | plan := prepareTabLayoutLocked(sess, tb) | 258 | plan := prepareTabLayoutLocked(sess, tb) | ||
| @@ -304,10 +271,6 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 304 | d.finishPreparedTabMembers(&plan, accepted) | 271 | d.finishPreparedTabMembers(&plan, accepted) | ||
| 305 | if !accepted { | 272 | if !accepted { | ||
| 306 | d.cancelStalePreparedGates(sess, tb, &plan) | 273 | d.cancelStalePreparedGates(sess, tb, &plan) | ||
| 307 | - | if preparedTabOwnerStale(&plan) { | |||
| 308 | - | d.releasePreparedSessionGates([]*preparedTabLayout{&plan}) | |||
| 309 | - | return resizeApplyResult{}, false | |||
| 310 | - | } | |||
| 311 | if len(current) != 0 && !current[0]() { | 274 | if len(current) != 0 && !current[0]() { | ||
| 312 | for i := range plan.members { | 275 | for i := range plan.members { | ||
| 313 | member := &plan.members[i] | 276 | member := &plan.members[i] | ||
| @@ -318,7 +281,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 318 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | 281 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | ||
| 319 | member.pane.resizeMu.Unlock() | 282 | member.pane.resizeMu.Unlock() | ||
| 320 | } | 283 | } | ||
| 321 | - | return resizeApplyResult{}, false | 284 | + | return nil, false |
| 322 | } | 285 | } | ||
| 323 | continue | 286 | continue | ||
| 324 | } | 287 | } | ||
| @@ -328,32 +291,87 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||||
| 328 | failed = append(failed, member) | 291 | failed = append(failed, member) | ||
| 329 | } | 292 | } | ||
| 330 | } | 293 | } | ||
| 331 | - | if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) { | 294 | + | if reportFailure && len(failed) != 0 { |
| 332 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | 295 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | ||
| 333 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | 296 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | ||
| 334 | } | 297 | } | ||
| 335 | - | return resizeApplyResult{members: append([]resizeMember(nil), plan.members...), failed: failed}, true | 298 | + | return failed, true |
| 336 | } | 299 | } | ||
| 337 | } | 300 | } | ||
| 338 | 301 | ||||
| 339 | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | 302 | func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool { | ||
| 340 | - | result, ok := d.applyTabLayoutTransaction(sess, tb) | 303 | + | failed, ok := d.applyTabLayoutTransaction(sess, tb) |
| 341 | if !ok { | 304 | if !ok { | ||
| 342 | return false | 305 | return false | ||
| 343 | } | 306 | } | ||
| 344 | - | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | 307 | + | markSnapshotDirty(sess) |
| 345 | - | markSnapshotDirty(sess) | 308 | + | if len(failed) != 0 { |
| 346 | - | }) { | 309 | + | d.scheduleAcceptedTabLayoutRetry(sess, tb) |
| 347 | - | return false | |||
| 348 | - | } | |||
| 349 | - | if len(result.failed) != 0 { | |||
| 350 | - | if !d.scheduleAcceptedTabLayoutRetry(sess, tb, result.failed) { | |||
| 351 | - | return false | |||
| 352 | - | } | |||
| 353 | } | 310 | } | ||
| 354 | return true | 311 | return true | ||
| 355 | } | 312 | } | ||
| 356 | 313 | ||||
| 314 | + | const maxAcceptedTabLayoutRetries = 3 | |||
| 315 | + | ||||
| 316 | + | // scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The | |||
| 317 | + | // worker is deduplicated, derives cancellation from the tab lifecycle, and | |||
| 318 | + | // suppresses repeat degradation notices after the accepted initial failure. | |||
| 319 | + | func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) { | |||
| 320 | + | if d.clock == nil || tb == nil || tb.ctx == nil { | |||
| 321 | + | return | |||
| 322 | + | } | |||
| 323 | + | tb.layoutRetryMu.Lock() | |||
| 324 | + | if tb.layoutRetryRunning { | |||
| 325 | + | tb.layoutRetryMu.Unlock() | |||
| 326 | + | return | |||
| 327 | + | } | |||
| 328 | + | ctx, cancel := context.WithCancel(tb.ctx) | |||
| 329 | + | tb.layoutRetryRunning = true | |||
| 330 | + | tb.layoutRetryCancel = cancel | |||
| 331 | + | tb.layoutRetryMu.Unlock() | |||
| 332 | + | ||||
| 333 | + | go func() { | |||
| 334 | + | defer func() { | |||
| 335 | + | cancel() | |||
| 336 | + | tb.layoutRetryMu.Lock() | |||
| 337 | + | tb.layoutRetryRunning = false | |||
| 338 | + | tb.layoutRetryCancel = nil | |||
| 339 | + | tb.layoutRetryMu.Unlock() | |||
| 340 | + | }() | |||
| 341 | + | for range maxAcceptedTabLayoutRetries { | |||
| 342 | + | timer := d.clock.NewTimer(minOutputRenderDeadline) | |||
| 343 | + | if timer == nil { | |||
| 344 | + | return | |||
| 345 | + | } | |||
| 346 | + | timerC := timer.C() | |||
| 347 | + | if timerC == nil { | |||
| 348 | + | timer.Stop() | |||
| 349 | + | return | |||
| 350 | + | } | |||
| 351 | + | select { | |||
| 352 | + | case <-ctx.Done(): | |||
| 353 | + | timer.Stop() | |||
| 354 | + | return | |||
| 355 | + | case <-timerC: | |||
| 356 | + | } | |||
| 357 | + | if ctx.Err() != nil { | |||
| 358 | + | return | |||
| 359 | + | } | |||
| 360 | + | failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil }) | |||
| 361 | + | if !ok || len(failed) == 0 { | |||
| 362 | + | return | |||
| 363 | + | } | |||
| 364 | + | markSnapshotDirty(sess) | |||
| 365 | + | sess.mu.Lock() | |||
| 366 | + | ac := sess.client | |||
| 367 | + | sess.mu.Unlock() | |||
| 368 | + | if ac != nil { | |||
| 369 | + | d.invalidateRender(sess, ac, true, "transactional_resize.go") | |||
| 370 | + | } | |||
| 371 | + | } | |||
| 372 | + | }() | |||
| 373 | + | } | |||
| 374 | + | ||||
| 357 | // replayResizePending completes a pane's apply epoch. It keeps the gate set | 375 | // replayResizePending completes a pane's apply epoch. It keeps the gate set | ||
| 358 | // while each buffered batch goes through the normal VT path, so reads arriving | 376 | // while each buffered batch goes through the normal VT path, so reads arriving | ||
| 359 | // during replay join the same ordered stream. Failure deliberately omits the | 377 | // during replay join the same ordered stream. Failure deliberately omits the | ||
| @@ -390,30 +408,53 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo | |||||
| 390 | // used for retryable PTY work. Floating state is not part of a tiled layout | 408 | // used for retryable PTY work. Floating state is not part of a tiled layout | ||
| 391 | // generation, so its slot generation and exact pane identity validate its | 409 | // generation, so its slot generation and exact pane identity validate its | ||
| 392 | // publication instead. | 410 | // publication instead. | ||
| 393 | - | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) (resizeApplyResult, bool) { | 411 | + | func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) { |
| 394 | return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil) | 412 | return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil) | ||
| 395 | } | 413 | } | ||
| 396 | 414 | ||||
| 397 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | 415 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | ||
| 398 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | 416 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | ||
| 399 | // so accepting a replacement here would silently retry unrelated geometry. | 417 | // so accepting a replacement here would silently retry unrelated geometry. | ||
| 400 | - | func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) (resizeApplyResult, bool) { | 418 | + | func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) { |
| 401 | if tb == nil { | 419 | if tb == nil { | ||
| 402 | - | return resizeApplyResult{}, true | 420 | + | return nil, true |
| 403 | } | 421 | } | ||
| 404 | tb.mu.Lock() | 422 | tb.mu.Lock() | ||
| 405 | if tb.floating.state != floatingVisible || tb.floating.pane == nil || | 423 | if tb.floating.state != floatingVisible || tb.floating.pane == nil || | ||
| 406 | (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) { | 424 | (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) { | ||
| 407 | tb.mu.Unlock() | 425 | tb.mu.Unlock() | ||
| 408 | - | return resizeApplyResult{}, true | 426 | + | return nil, true |
| 409 | } | 427 | } | ||
| 410 | p := tb.floating.pane | 428 | p := tb.floating.pane | ||
| 411 | generation := tb.floating.generation | 429 | generation := tb.floating.generation | ||
| 412 | size := tb.size | 430 | size := tb.size | ||
| 413 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | 431 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | ||
| 414 | tb.mu.Unlock() | 432 | tb.mu.Unlock() | ||
| 415 | - | if !geometry.valid() { | 433 | + | if !geometry.committable() { |
| 416 | - | return resizeApplyResult{}, true | 434 | + | return nil, true |
| 435 | + | } | |||
| 436 | + | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |||
| 437 | + | // A drawer can validly reserve only its separator row. Publish that | |||
| 438 | + | // presentation without inventing a physical PTY size; the last usable | |||
| 439 | + | // rectangle and screen remain committed until a later resize has content. | |||
| 440 | + | tb.mu.Lock() | |||
| 441 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && | |||
| 442 | + | tb.floating.pane == p && tb.size == size | |||
| 443 | + | if current != nil && !current() { | |||
| 444 | + | currentSlot = false | |||
| 445 | + | } | |||
| 446 | + | if currentSlot { | |||
| 447 | + | p.mu.Lock() | |||
| 448 | + | p.popupGeometry = geometry | |||
| 449 | + | p.mu.Unlock() | |||
| 450 | + | tb.mu.Unlock() | |||
| 451 | + | return nil, true | |||
| 452 | + | } | |||
| 453 | + | tb.mu.Unlock() | |||
| 454 | + | if current != nil && !current() { | |||
| 455 | + | return nil, false | |||
| 456 | + | } | |||
| 457 | + | return nil, true | |||
| 417 | } | 458 | } | ||
| 418 | 459 | ||||
| 419 | // This keeps successful PTY resizes gated until the | 460 | // This keeps successful PTY resizes gated until the | ||
| @@ -423,34 +464,30 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 423 | owner := p.effectLeaseLocked() | 464 | owner := p.effectLeaseLocked() | ||
| 424 | p.mu.Unlock() | 465 | p.mu.Unlock() | ||
| 425 | plan := preparedTabLayout{members: []resizeMember{{ | 466 | plan := preparedTabLayout{members: []resizeMember{{ | ||
| 426 | - | session: sess, tab: tb, pane: p, owner: owner, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation, | 467 | + | session: sess, tab: tb, pane: p, owner: owner, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation, |
| 427 | }}} | 468 | }}} | ||
| 428 | d.applyPreparedTabMembers(&plan) | 469 | d.applyPreparedTabMembers(&plan) | ||
| 429 | 470 | ||||
| 430 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | 471 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | ||
| 431 | // relaunched, or resized slot must never receive this attempt's geometry. | 472 | // relaunched, or resized slot must never receive this attempt's geometry. | ||
| 432 | tb.mu.Lock() | 473 | tb.mu.Lock() | ||
| 433 | - | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && | 474 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && resizeMemberOwnerCurrent(&plan.members[0]) |
| 434 | - | resizeMemberOwnerCurrent(&plan.members[0]) | |||
| 435 | if current != nil && !current() { | 475 | if current != nil && !current() { | ||
| 436 | currentSlot = false | 476 | currentSlot = false | ||
| 437 | } | 477 | } | ||
| 438 | - | if currentSlot { | 478 | + | if currentSlot && plan.members[0].ok { |
| 439 | p.mu.Lock() | 479 | p.mu.Lock() | ||
| 440 | currentSlot = resizeMemberOwnerCurrent(&plan.members[0]) | 480 | currentSlot = resizeMemberOwnerCurrent(&plan.members[0]) | ||
| 441 | if currentSlot { | 481 | if currentSlot { | ||
| 442 | - | p.rect = geometry.Inner | 482 | + | p.rect = plan.members[0].rect |
| 443 | p.popupGeometry = geometry | 483 | p.popupGeometry = geometry | ||
| 444 | - | if plan.members[0].ok { | 484 | + | p.resizeRetry = false |
| 445 | - | p.resizeRetry = false | 485 | + | p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height) |
| 446 | - | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |||
| 447 | - | } | |||
| 448 | } | 486 | } | ||
| 449 | p.mu.Unlock() | 487 | p.mu.Unlock() | ||
| 450 | } | 488 | } | ||
| 451 | tb.mu.Unlock() | 489 | tb.mu.Unlock() | ||
| 452 | if !currentSlot { | 490 | if !currentSlot { | ||
| 453 | - | ownerCurrent := resizeMemberOwnerCurrent(&plan.members[0]) | |||
| 454 | // This attempt has no tiled transaction loop to retain the gate for, so | 491 | // This attempt has no tiled transaction loop to retain the gate for, so | ||
| 455 | // discard its buffered bytes at the old screen size before returning. | 492 | // discard its buffered bytes at the old screen size before returning. | ||
| 456 | for i := range plan.members { | 493 | for i := range plan.members { | ||
| @@ -462,17 +499,16 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 462 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | 499 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | ||
| 463 | member.pane.resizeMu.Unlock() | 500 | member.pane.resizeMu.Unlock() | ||
| 464 | } | 501 | } | ||
| 465 | - | if !ownerCurrent || (current != nil && !current()) { | 502 | + | if current != nil && !current() { |
| 466 | - | return resizeApplyResult{}, false | 503 | + | return nil, false |
| 467 | } | 504 | } | ||
| 468 | - | return resizeApplyResult{}, true | 505 | + | return nil, true |
| 469 | } | 506 | } | ||
| 470 | if plan.members[0].ok { | 507 | if plan.members[0].ok { | ||
| 471 | d.finishPreparedTabMembers(&plan, true) | 508 | d.finishPreparedTabMembers(&plan, true) | ||
| 472 | - | return resizeApplyResult{members: append([]resizeMember(nil), plan.members...)}, true | 509 | + | return nil, true |
| 473 | } | 510 | } | ||
| 474 | - | members := append([]resizeMember(nil), plan.members...) | 511 | + | return plan.members, true |
| 475 | - | return resizeApplyResult{members: members, failed: members}, true | |||
| 476 | } | 512 | } | ||
| 477 | 513 | ||||
| 478 | // releasePreparedSessionGates abandons a session attempt which cannot be | 514 | // releasePreparedSessionGates abandons a session attempt which cannot be | ||
| @@ -501,9 +537,9 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||||
| 501 | // transaction: all PTYs are applied and plans validated first, then the | 537 | // transaction: all PTYs are applied and plans validated first, then the | ||
| 502 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | 538 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | ||
| 503 | // snapshot dirtiness, or resize telemetry becomes visible. | 539 | // snapshot dirtiness, or resize telemetry becomes visible. | ||
| 504 | - | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) (resizeApplyResult, bool) { | 540 | + | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { |
| 505 | if sess == nil { | 541 | if sess == nil { | ||
| 506 | - | return resizeApplyResult{}, false | 542 | + | return nil, false |
| 507 | } | 543 | } | ||
| 508 | sess.layoutApplyMu.Lock() | 544 | sess.layoutApplyMu.Lock() | ||
| 509 | defer sess.layoutApplyMu.Unlock() | 545 | defer sess.layoutApplyMu.Unlock() | ||
| @@ -511,10 +547,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 511 | target := tabSize(size) | 547 | target := tabSize(size) | ||
| 512 | for { | 548 | for { | ||
| 513 | if sess.ctx != nil && sess.ctx.Err() != nil { | 549 | if sess.ctx != nil && sess.ctx.Err() != nil { | ||
| 514 | - | return resizeApplyResult{}, false | 550 | + | return nil, false |
| 515 | } | 551 | } | ||
| 516 | if current != nil && !current() { | 552 | if current != nil && !current() { | ||
| 517 | - | return resizeApplyResult{}, false | 553 | + | return nil, false |
| 518 | } | 554 | } | ||
| 519 | sess.mu.Lock() | 555 | sess.mu.Lock() | ||
| 520 | tabs := append([]*tab(nil), sess.tabs...) | 556 | tabs := append([]*tab(nil), sess.tabs...) | ||
| @@ -565,11 +601,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 565 | valid = false | 601 | valid = false | ||
| 566 | break | 602 | break | ||
| 567 | } | 603 | } | ||
| 568 | - | } | 604 | + | plan.tab.size = plan.size |
| 569 | - | if valid { | |||
| 570 | - | for _, plan := range plans { | |||
| 571 | - | plan.tab.size = plan.size | |||
| 572 | - | } | |||
| 573 | } | 605 | } | ||
| 574 | } | 606 | } | ||
| 575 | for i := len(tabs) - 1; i >= 0; i-- { | 607 | for i := len(tabs) - 1; i >= 0; i-- { | ||
| @@ -577,27 +609,20 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 577 | } | 609 | } | ||
| 578 | if !valid { | 610 | if !valid { | ||
| 579 | d.releasePreparedSessionGates(plans) | 611 | d.releasePreparedSessionGates(plans) | ||
| 580 | - | for _, plan := range plans { | |||
| 581 | - | if preparedTabOwnerStale(plan) { | |||
| 582 | - | return resizeApplyResult{}, false | |||
| 583 | - | } | |||
| 584 | - | } | |||
| 585 | // Headless requests have no attachment epoch. A live tab mutation | 612 | // Headless requests have no attachment epoch. A live tab mutation | ||
| 586 | // still invalidates their plan, but must trigger a fresh prepare rather | 613 | // still invalidates their plan, but must trigger a fresh prepare rather | ||
| 587 | // than being mistaken for a canceled request. | 614 | // than being mistaken for a canceled request. | ||
| 588 | if current != nil && !current() { | 615 | if current != nil && !current() { | ||
| 589 | - | return resizeApplyResult{}, false | 616 | + | return nil, false |
| 590 | } | 617 | } | ||
| 591 | // A layout mutation invalidated the plans while this epoch remains | 618 | // A layout mutation invalidated the plans while this epoch remains | ||
| 592 | // current. Reapply the fresh session geometry before admitting it. | 619 | // current. Reapply the fresh session geometry before admitting it. | ||
| 593 | continue | 620 | continue | ||
| 594 | } | 621 | } | ||
| 595 | 622 | ||||
| 596 | - | members := make([]resizeMember, 0) | |||
| 597 | failed := make([]resizeMember, 0) | 623 | failed := make([]resizeMember, 0) | ||
| 598 | for _, plan := range plans { | 624 | for _, plan := range plans { | ||
| 599 | d.finishPreparedTabMembers(plan, true) | 625 | d.finishPreparedTabMembers(plan, true) | ||
| 600 | - | members = append(members, plan.members...) | |||
| 601 | for _, member := range plan.members { | 626 | for _, member := range plan.members { | ||
| 602 | if !member.ok { | 627 | if !member.ok { | ||
| 603 | failed = append(failed, member) | 628 | failed = append(failed, member) | ||
| @@ -605,12 +630,11 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 605 | } | 630 | } | ||
| 606 | } | 631 | } | ||
| 607 | if active >= 0 && active < len(tabs) { | 632 | if active >= 0 && active < len(tabs) { | ||
| 608 | - | floatingResult, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) | 633 | + | floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current) |
| 609 | if !ok { | 634 | if !ok { | ||
| 610 | - | return resizeApplyResult{}, false | 635 | + | return nil, false |
| 611 | } | 636 | } | ||
| 612 | - | members = append(members, floatingResult.members...) | 637 | + | failed = append(failed, floatingFailed...) |
| 613 | - | failed = append(failed, floatingResult.failed...) | |||
| 614 | } | 638 | } | ||
| 615 | failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed) | 639 | failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed) | ||
| 616 | if len(failed) != 0 && failedCurrent { | 640 | if len(failed) != 0 && failedCurrent { | ||
| @@ -618,9 +642,174 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||||
| 618 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | 642 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | ||
| 619 | } | 643 | } | ||
| 620 | if len(failed) != 0 && !failedCurrent { | 644 | if len(failed) != 0 && !failedCurrent { | ||
| 621 | - | return resizeApplyResult{}, false | 645 | + | return nil, false |
| 622 | } | 646 | } | ||
| 623 | - | return resizeApplyResult{members: members, failed: failed}, true | 647 | + | markSnapshotDirty(sess) |
| 648 | + | d.observeRuntime(ports.RuntimeResizeCommitted, 0, true) | |||
| 649 | + | return failed, true | |||
| 650 | + | } | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool { | |||
| 654 | + | rc := sess.renderCoordinator() | |||
| 655 | + | if rc == nil { | |||
| 656 | + | return false | |||
| 657 | + | } | |||
| 658 | + | snap := rc.resizeSnapshot() | |||
| 659 | + | current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) } | |||
| 660 | + | if !current() { | |||
| 661 | + | return false | |||
| 662 | + | } | |||
| 663 | + | d.exitCopyMode(ac) | |||
| 664 | + | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { | |||
| 665 | + | return rc.resizeCurrentForLease(epoch, ac, lease, true) | |||
| 666 | + | }) | |||
| 667 | + | if !ok { | |||
| 668 | + | return false | |||
| 669 | + | } | |||
| 670 | + | ac.sendMu.Lock() | |||
| 671 | + | ac.size = snap.size | |||
| 672 | + | ac.sendMu.Unlock() | |||
| 673 | + | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | |||
| 674 | + | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |||
| 675 | + | } | |||
| 676 | + | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |||
| 677 | + | // A successful transaction publishes exactly one full S2 frame. The | |||
| 678 | + | // coordinator is the only emission route and stale epochs never reach it. | |||
| 679 | + | if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) { | |||
| 680 | + | return false | |||
| 681 | + | } | |||
| 682 | + | // The resize debounce has already elapsed. Consume this sticky reset now; | |||
| 683 | + | // fire preserves ACK and synchronized-output gates rather than scheduling a | |||
| 684 | + | // second urgent deadline. | |||
| 685 | + | rc.fireCurrent(false) | |||
| 686 | + | return true | |||
| 687 | + | } | |||
| 688 | + | ||||
| 689 | + | // retryResizeMembers retries failed committed members through a freshly | |||
| 690 | + | // prepared tab transaction. The captured members identify retry candidates | |||
| 691 | + | // only: their rectangles must never cross this delayed boundary, because any | |||
| 692 | + | // later layout mutation may have changed their geometry or removed them. | |||
| 693 | + | func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) { | |||
| 694 | + | rc := sess.renderCoordinator() | |||
| 695 | + | if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 696 | + | return | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | // Retain tab order for deterministic external PTY ordering while collapsing | |||
| 700 | + | // several failed members from the same tab into one canonical transaction. | |||
| 701 | + | tabs := make([]*tab, 0, len(members)) | |||
| 702 | + | seen := make(map[*tab]struct{}, len(members)) | |||
| 703 | + | for _, member := range members { | |||
| 704 | + | if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) { | |||
| 705 | + | continue | |||
| 706 | + | } | |||
| 707 | + | if _, ok := seen[member.tab]; ok { | |||
| 708 | + | continue | |||
| 709 | + | } | |||
| 710 | + | seen[member.tab] = struct{}{} | |||
| 711 | + | tabs = append(tabs, member.tab) | |||
| 712 | + | } | |||
| 713 | + | ||||
| 714 | + | failed := make([]resizeMember, 0, len(members)) | |||
| 715 | + | succeeded := false | |||
| 716 | + | for _, tb := range tabs { | |||
| 717 | + | if !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 718 | + | return | |||
| 719 | + | } | |||
| 720 | + | // A later accepted layout transaction clears resizeRetry. In that case | |||
| 721 | + | // this delayed callback has no remaining work and, importantly, must not | |||
| 722 | + | // replay the obsolete rectangle it captured before the mutation. | |||
| 723 | + | retryPending := false | |||
| 724 | + | tb.mu.Lock() | |||
| 725 | + | for _, member := range members { | |||
| 726 | + | if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane { | |||
| 727 | + | continue | |||
| 728 | + | } | |||
| 729 | + | member.pane.mu.Lock() | |||
| 730 | + | retryPending = retryPending || member.pane.resizeRetry | |||
| 731 | + | member.pane.mu.Unlock() | |||
| 732 | + | } | |||
| 733 | + | tb.mu.Unlock() | |||
| 734 | + | if !retryPending { | |||
| 735 | + | continue | |||
| 736 | + | } | |||
| 737 | + | ||||
| 738 | + | // applyTabLayoutTransaction captures the current generation, pane | |||
| 739 | + | // pointers, and solved rectangles, and validates them again before any | |||
| 740 | + | // VT/rectangle publication. It also preserves the resize gate and | |||
| 741 | + | // degradation notice behavior for another failed external attempt. | |||
| 742 | + | freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool { | |||
| 743 | + | return rc.retryCurrentForLease(epoch, ac, lease) | |||
| 744 | + | }) | |||
| 745 | + | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 746 | + | return | |||
| 747 | + | } | |||
| 748 | + | failed = append(failed, freshFailed...) | |||
| 749 | + | // A retry success is a formerly failed, still-current target whose | |||
| 750 | + | // canonical apply cleared its retry bit. A collapsed or removed target | |||
| 751 | + | // is neither a retry completion nor a reason to publish a reset. | |||
| 752 | + | tb.mu.Lock() | |||
| 753 | + | for _, member := range members { | |||
| 754 | + | if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane { | |||
| 755 | + | continue | |||
| 756 | + | } | |||
| 757 | + | member.pane.mu.Lock() | |||
| 758 | + | succeeded = succeeded || !member.pane.resizeRetry | |||
| 759 | + | member.pane.mu.Unlock() | |||
| 760 | + | } | |||
| 761 | + | tb.mu.Unlock() | |||
| 762 | + | } | |||
| 763 | + | for _, member := range members { | |||
| 764 | + | if !member.isFloating || member.tab == nil || member.pane == nil { | |||
| 765 | + | continue | |||
| 766 | + | } | |||
| 767 | + | if !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 768 | + | return | |||
| 769 | + | } | |||
| 770 | + | // Floating panes are outside tb.panes. Validate the exact accepted slot | |||
| 771 | + | // before preparing a fresh geometry; applyVisibleFloatingLayout repeats | |||
| 772 | + | // the same pointer/generation check around external PTY.Resize. | |||
| 773 | + | member.tab.mu.Lock() | |||
| 774 | + | currentSlot := member.tab.floating.state == floatingVisible && | |||
| 775 | + | member.tab.floating.generation == member.floatingGeneration && | |||
| 776 | + | member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member) | |||
| 777 | + | retryPending := false | |||
| 778 | + | if currentSlot { | |||
| 779 | + | member.pane.mu.Lock() | |||
| 780 | + | retryPending = member.pane.resizeRetry | |||
| 781 | + | member.pane.mu.Unlock() | |||
| 782 | + | } | |||
| 783 | + | member.tab.mu.Unlock() | |||
| 784 | + | if !currentSlot || !retryPending { | |||
| 785 | + | continue | |||
| 786 | + | } | |||
| 787 | + | freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool { | |||
| 788 | + | return rc.retryCurrentForLease(epoch, ac, lease) | |||
| 789 | + | }, &member) | |||
| 790 | + | if !ok || !rc.retryCurrentForLease(epoch, ac, lease) { | |||
| 791 | + | return | |||
| 792 | + | } | |||
| 793 | + | failed = append(failed, freshFailed...) | |||
| 794 | + | member.tab.mu.Lock() | |||
| 795 | + | stillCurrent := member.tab.floating.state == floatingVisible && | |||
| 796 | + | member.tab.floating.generation == member.floatingGeneration && | |||
| 797 | + | member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member) | |||
| 798 | + | member.tab.mu.Unlock() | |||
| 799 | + | if stillCurrent && len(freshFailed) == 0 { | |||
| 800 | + | member.pane.mu.Lock() | |||
| 801 | + | succeeded = succeeded || !member.pane.resizeRetry | |||
| 802 | + | member.pane.mu.Unlock() | |||
| 803 | + | } | |||
| 804 | + | } | |||
| 805 | + | if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) { | |||
| 806 | + | rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) }) | |||
| 807 | + | } | |||
| 808 | + | if succeeded { | |||
| 809 | + | // Retry completion changes VT state after the original layout commit. | |||
| 810 | + | // Keep a named session's eventual snapshot generation aligned with it. | |||
| 811 | + | markSnapshotDirty(sess) | |||
| 812 | + | rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) | |||
| 624 | } | 813 | } | ||
| 625 | } | 814 | } | ||
| 626 | 815 | ||||
| @@ -646,13 +835,8 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC | |||||
| 646 | if ac == nil { | 835 | if ac == nil { | ||
| 647 | // Headless geometry has no coordinator/transport to coalesce, but keeps | 836 | // Headless geometry has no coordinator/transport to coalesce, but keeps | ||
| 648 | // the same prepare/apply/commit ordering. | 837 | // the same prepare/apply/commit ordering. | ||
| 649 | - | result, ok := d.applySessionLayout(sess, size, nil, nil) | 838 | + | _, ok := d.applySessionLayout(sess, size, nil, nil) |
| 650 | - | if !ok || !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | 839 | + | return ok |
| 651 | - | markSnapshotDirty(sess) | |||
| 652 | - | }) { | |||
| 653 | - | return false | |||
| 654 | - | } | |||
| 655 | - | return d.publishResizeCommit(result.members, sess, nil, nil, 0, nil, size) | |||
| 656 | } | 840 | } | ||
| 657 | rc := sess.renderCoordinator() | 841 | rc := sess.renderCoordinator() | ||
| 658 | if rc == nil || !rc.leaseCurrent(lease, true) { | 842 | if rc == nil || !rc.leaseCurrent(lease, true) { | ||
M internal/usecase/daemon/transactional_resize_test.go +190 -399
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "io" | 5 | "io" | ||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | - | "sync/atomic" | |||
| 9 | "testing" | 8 | "testing" | ||
| 10 | "time" | 9 | "time" | ||
| 11 | 10 | ||||
| @@ -14,6 +13,7 @@ import ( | |||||
| 14 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 15 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 16 | "github.com/bnema/vev/internal/usecase/layout" | 15 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 16 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 17 | "github.com/bnema/vev/internal/usecase/visualsearch" | 17 | "github.com/bnema/vev/internal/usecase/visualsearch" | ||
| 18 | ) | 18 | ) | ||
| 19 | 19 | ||||
| @@ -272,10 +272,10 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) { | |||||
| 272 | } | 272 | } | ||
| 273 | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | 273 | d, sess, _, _ := newManualSessionWithPTYs(t, first, second) | ||
| 274 | 274 | ||||
| 275 | - | result, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) | 275 | + | failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil) |
| 276 | 276 | ||||
| 277 | require.Equal(t, tc.wantOK, ok) | 277 | require.Equal(t, tc.wantOK, ok) | ||
| 278 | - | require.Len(t, result.failed, tc.wantFailed) | 278 | + | require.Len(t, failed, tc.wantFailed) |
| 279 | count := 0 | 279 | count := 0 | ||
| 280 | for _, notification := range d.notices.history() { | 280 | for _, notification := range d.notices.history() { | ||
| 281 | if notification.Code == domain.NoticeResizeFailed { | 281 | if notification.Code == domain.NoticeResizeFailed { | ||
| @@ -407,402 +407,6 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) { | |||||
| 407 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | 407 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | ||
| 408 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | 408 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | ||
| 409 | // channels make callback order explicit and do not use wall-clock waits. | 409 | // channels make callback order explicit and do not use wall-clock waits. | ||
| 410 | - | func publishResizeOwnerMoveUnderFence(t *testing.T, source, destination *session, tb *tab, p *pane) { | |||
| 411 | - | t.Helper() | |||
| 412 | - | fences := newMovePaneResizeFences(source, destination, tb, tb, p) | |||
| 413 | - | require.True(t, fences.acquire(func() bool { | |||
| 414 | - | p.mu.Lock() | |||
| 415 | - | p.publishOwnerLocked(destination, tb, 0) | |||
| 416 | - | p.mu.Unlock() | |||
| 417 | - | return true | |||
| 418 | - | })) | |||
| 419 | - | fences.Release() | |||
| 420 | - | } | |||
| 421 | - | ||||
| 422 | - | func pauseResizeOwnerPostEffect(d *Daemon, effect resizeOwnerPostEffect) (<-chan struct{}, func()) { | |||
| 423 | - | entered := make(chan struct{}) | |||
| 424 | - | release := make(chan struct{}) | |||
| 425 | - | var once sync.Once | |||
| 426 | - | d.beforeResizeOwnerPostEffect = func(got resizeOwnerPostEffect) { | |||
| 427 | - | if got != effect { | |||
| 428 | - | return | |||
| 429 | - | } | |||
| 430 | - | once.Do(func() { close(entered) }) | |||
| 431 | - | <-release | |||
| 432 | - | } | |||
| 433 | - | return entered, func() { close(release) } | |||
| 434 | - | } | |||
| 435 | - | ||||
| 436 | - | func TestResizeOwnerMoveImmediatelyBeforeSnapshotDirtyDropsStaleSource(t *testing.T) { | |||
| 437 | - | d, source, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 438 | - | source.snapEligible.Store(true) | |||
| 439 | - | tb := source.activeTab() | |||
| 440 | - | p := tb.focusedPane() | |||
| 441 | - | tb.mu.Lock() | |||
| 442 | - | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 443 | - | tb.bumpLayoutGenerationLocked() | |||
| 444 | - | tb.mu.Unlock() | |||
| 445 | - | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 446 | - | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostSnapshotDirty) | |||
| 447 | - | ||||
| 448 | - | result := make(chan bool, 1) | |||
| 449 | - | go func() { result <- d.applyTabLayout(source, tb) }() | |||
| 450 | - | awaitTestCompletion(t, entered, "resize did not pause before snapshot dirtiness") | |||
| 451 | - | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |||
| 452 | - | release() | |||
| 453 | - | ||||
| 454 | - | require.False(t, awaitTestValue(t, result, "stale source layout did not return")) | |||
| 455 | - | require.False(t, source.snapDirty.Load(), "stale source dirtied its snapshot after the move won the fence") | |||
| 456 | - | } | |||
| 457 | - | ||||
| 458 | - | func TestRetryOwnerMoveImmediatelyBeforeScheduleContinuesAtDestination(t *testing.T) { | |||
| 459 | - | resizeErr := errors.New("scripted resize failure") | |||
| 460 | - | pty := &transactionalResizePTY{errs: []error{resizeErr, resizeErr}} | |||
| 461 | - | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 462 | - | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)} | |||
| 463 | - | d.clock = clock | |||
| 464 | - | tb := source.activeTab() | |||
| 465 | - | p := tb.focusedPane() | |||
| 466 | - | tb.mu.Lock() | |||
| 467 | - | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 468 | - | tb.bumpLayoutGenerationLocked() | |||
| 469 | - | tb.mu.Unlock() | |||
| 470 | - | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 471 | - | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRetrySchedule) | |||
| 472 | - | ||||
| 473 | - | result := make(chan bool, 1) | |||
| 474 | - | go func() { result <- d.applyTabLayout(source, tb) }() | |||
| 475 | - | awaitTestCompletion(t, entered, "resize did not pause before retry scheduling") | |||
| 476 | - | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |||
| 477 | - | release() | |||
| 478 | - | ||||
| 479 | - | require.False(t, awaitTestValue(t, result, "stale source retry publication did not return")) | |||
| 480 | - | tb.layoutRetryMu.Lock() | |||
| 481 | - | require.False(t, tb.layoutRetryRunning, "stale source published a retry worker") | |||
| 482 | - | tb.layoutRetryMu.Unlock() | |||
| 483 | - | for { | |||
| 484 | - | select { | |||
| 485 | - | case timer := <-clock.timers: | |||
| 486 | - | require.NotEqual(t, minOutputRenderDeadline, timer.delay, "stale source armed a retry timer") | |||
| 487 | - | default: | |||
| 488 | - | goto timersDrained | |||
| 489 | - | } | |||
| 490 | - | } | |||
| 491 | - | ||||
| 492 | - | timersDrained: | |||
| 493 | - | d.beforeResizeOwnerPostEffect = nil | |||
| 494 | - | require.True(t, d.applyTabLayout(destination, tb), "destination must recreate retry from pane metadata") | |||
| 495 | - | for { | |||
| 496 | - | timer := awaitTestValue(t, clock.timers, "destination did not continue the moved retry") | |||
| 497 | - | if timer.delay == minOutputRenderDeadline { | |||
| 498 | - | break | |||
| 499 | - | } | |||
| 500 | - | } | |||
| 501 | - | } | |||
| 502 | - | ||||
| 503 | - | type resizeFenceCheckingClock struct { | |||
| 504 | - | locks []*sync.Mutex | |||
| 505 | - | calls atomic.Int32 | |||
| 506 | - | locksFree atomic.Bool | |||
| 507 | - | } | |||
| 508 | - | ||||
| 509 | - | func (c *resizeFenceCheckingClock) check() { | |||
| 510 | - | c.calls.Add(1) | |||
| 511 | - | locked := make([]*sync.Mutex, 0, len(c.locks)) | |||
| 512 | - | for _, lock := range c.locks { | |||
| 513 | - | if !lock.TryLock() { | |||
| 514 | - | for i := len(locked) - 1; i >= 0; i-- { | |||
| 515 | - | locked[i].Unlock() | |||
| 516 | - | } | |||
| 517 | - | c.locksFree.Store(false) | |||
| 518 | - | return | |||
| 519 | - | } | |||
| 520 | - | locked = append(locked, lock) | |||
| 521 | - | } | |||
| 522 | - | for i := len(locked) - 1; i >= 0; i-- { | |||
| 523 | - | locked[i].Unlock() | |||
| 524 | - | } | |||
| 525 | - | } | |||
| 526 | - | ||||
| 527 | - | func (c *resizeFenceCheckingClock) Now() time.Time { | |||
| 528 | - | c.check() | |||
| 529 | - | return time.Time{} | |||
| 530 | - | } | |||
| 531 | - | ||||
| 532 | - | func (c *resizeFenceCheckingClock) NewTimer(time.Duration) ports.Timer { | |||
| 533 | - | c.check() | |||
| 534 | - | return stubTimer{} | |||
| 535 | - | } | |||
| 536 | - | ||||
| 537 | - | type blockingResizeCommitObserver struct { | |||
| 538 | - | entered chan struct{} | |||
| 539 | - | release chan struct{} | |||
| 540 | - | once sync.Once | |||
| 541 | - | } | |||
| 542 | - | ||||
| 543 | - | func (o *blockingResizeCommitObserver) ObserveRuntime(mark ports.RuntimeMark) { | |||
| 544 | - | if mark.Kind != ports.RuntimeResizeCommitted { | |||
| 545 | - | return | |||
| 546 | - | } | |||
| 547 | - | o.once.Do(func() { close(o.entered) }) | |||
| 548 | - | <-o.release | |||
| 549 | - | } | |||
| 550 | - | ||||
| 551 | - | func TestResizeCommitObserverBlocksWithoutHoldingOwnerOrAttachmentFences(t *testing.T) { | |||
| 552 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 553 | - | observer := &blockingResizeCommitObserver{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 554 | - | d.runtimeObserver = observer | |||
| 555 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 556 | - | lease := rc.attachmentLease(ac) | |||
| 557 | - | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 558 | - | tb := sess.activeTab() | |||
| 559 | - | p := tb.focusedPane() | |||
| 560 | - | ||||
| 561 | - | result := make(chan bool, 1) | |||
| 562 | - | go func() { result <- d.runResizeTransaction(sess, ac, lease, epoch) }() | |||
| 563 | - | awaitTestCompletion(t, observer.entered, "resize commit observer did not block") | |||
| 564 | - | for _, lock := range []struct { | |||
| 565 | - | name string | |||
| 566 | - | mu *sync.Mutex | |||
| 567 | - | }{ | |||
| 568 | - | {name: "attachment send", mu: &ac.sendMu}, | |||
| 569 | - | {name: "session resize", mu: &sess.layoutApplyMu}, | |||
| 570 | - | {name: "tab resize", mu: &tb.layoutApplyMu}, | |||
| 571 | - | {name: "pane resize", mu: &p.resizeMu}, | |||
| 572 | - | } { | |||
| 573 | - | require.True(t, lock.mu.TryLock(), "%s fence remained held across observer callback", lock.name) | |||
| 574 | - | lock.mu.Unlock() | |||
| 575 | - | } | |||
| 576 | - | close(observer.release) | |||
| 577 | - | require.True(t, awaitTestValue(t, result, "resize did not finish after observer release")) | |||
| 578 | - | } | |||
| 579 | - | ||||
| 580 | - | func TestResizeOwnerPostEffectsReleaseFencesBeforeTimerAndRenderCallbacks(t *testing.T) { | |||
| 581 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 582 | - | tb := sess.activeTab() | |||
| 583 | - | p := tb.focusedPane() | |||
| 584 | - | clock := &resizeFenceCheckingClock{locks: []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu}} | |||
| 585 | - | clock.locksFree.Store(true) | |||
| 586 | - | d.clock = clock | |||
| 587 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 588 | - | callbackLocksFree := atomic.Bool{} | |||
| 589 | - | callbackLocksFree.Store(true) | |||
| 590 | - | rc.opts.onInvalidate = func(renderInvalidation) { | |||
| 591 | - | clock.check() | |||
| 592 | - | callbackLocksFree.Store(clock.locksFree.Load()) | |||
| 593 | - | } | |||
| 594 | - | lease := rc.attachmentLease(ac) | |||
| 595 | - | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 596 | - | ||||
| 597 | - | require.True(t, d.runResizeTransaction(sess, ac, lease, epoch)) | |||
| 598 | - | require.Positive(t, clock.calls.Load(), "clock boundary was not exercised") | |||
| 599 | - | require.True(t, clock.locksFree.Load(), "timer or clock work ran under a resize ownership fence") | |||
| 600 | - | require.True(t, callbackLocksFree.Load(), "render callback ran under a resize ownership fence") | |||
| 601 | - | } | |||
| 602 | - | ||||
| 603 | - | func TestResizeOwnerMoveBeforeClientGeometryAndCommitTelemetryDropsStaleSource(t *testing.T) { | |||
| 604 | - | first := &transactionalResizePTY{} | |||
| 605 | - | second := &transactionalResizePTY{} | |||
| 606 | - | d, source, ac, _ := newManualSessionWithPTYs(t, first, second) | |||
| 607 | - | observer := &daemonRuntimeObserver{} | |||
| 608 | - | d.runtimeObserver = observer | |||
| 609 | - | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 610 | - | lease := rc.attachmentLease(ac) | |||
| 611 | - | target := domain.Size{Cols: 100, Rows: 30} | |||
| 612 | - | epoch := rc.recordResizeRequestForLease(target, ac, lease) | |||
| 613 | - | require.NotZero(t, epoch) | |||
| 614 | - | movedTab := source.tabs[1] | |||
| 615 | - | movedPane := movedTab.focusedPane() | |||
| 616 | - | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{movedTab}} | |||
| 617 | - | ||||
| 618 | - | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostCommitPublication) | |||
| 619 | - | result := make(chan bool, 1) | |||
| 620 | - | go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }() | |||
| 621 | - | select { | |||
| 622 | - | case <-entered: | |||
| 623 | - | publishResizeOwnerMoveUnderFence(t, source, destination, movedTab, movedPane) | |||
| 624 | - | release() | |||
| 625 | - | case accepted := <-result: | |||
| 626 | - | ac.sendMu.Lock() | |||
| 627 | - | publishedSize := ac.size | |||
| 628 | - | ac.sendMu.Unlock() | |||
| 629 | - | t.Fatalf("resize completed before geometry/telemetry owner fence: accepted=%v size=%+v marks=%+v", accepted, publishedSize, observer.marks) | |||
| 630 | - | case <-time.After(time.Second): | |||
| 631 | - | t.Fatal("resize neither reached geometry/telemetry publication nor completed") | |||
| 632 | - | } | |||
| 633 | - | ||||
| 634 | - | require.False(t, awaitTestValue(t, result, "stale source geometry publication did not return")) | |||
| 635 | - | ac.sendMu.Lock() | |||
| 636 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "stale source published attachment geometry after ownership commit") | |||
| 637 | - | ac.sendMu.Unlock() | |||
| 638 | - | for _, mark := range observer.marks { | |||
| 639 | - | require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "stale source published resize-commit telemetry after ownership commit") | |||
| 640 | - | } | |||
| 641 | - | ||||
| 642 | - | // The destination's finalizer is the only accepted geometry/telemetry owner. | |||
| 643 | - | d.beforeResizeOwnerPostEffect = nil | |||
| 644 | - | destinationSize := domain.Size{Cols: 90, Rows: 28} | |||
| 645 | - | require.True(t, d.requestTransactionalResizeForLease(destination, nil, nil, destinationSize, true)) | |||
| 646 | - | movedPane.mu.Lock() | |||
| 647 | - | require.Equal(t, domain.Size{Cols: 90, Rows: 26}, domain.Size{Cols: movedPane.screen.Frame.Width, Rows: movedPane.screen.Frame.Height}) | |||
| 648 | - | movedPane.mu.Unlock() | |||
| 649 | - | commits := 0 | |||
| 650 | - | for _, mark := range observer.marks { | |||
| 651 | - | if mark.Kind == ports.RuntimeResizeCommitted { | |||
| 652 | - | commits++ | |||
| 653 | - | } | |||
| 654 | - | } | |||
| 655 | - | require.Equal(t, 1, commits, "only the destination finalizer may publish resize-commit telemetry") | |||
| 656 | - | } | |||
| 657 | - | ||||
| 658 | - | func TestSameOwnerResizePublishesClientGeometryAndCommitTelemetryOnce(t *testing.T) { | |||
| 659 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}, &transactionalResizePTY{}) | |||
| 660 | - | observer := &daemonRuntimeObserver{} | |||
| 661 | - | d.runtimeObserver = observer | |||
| 662 | - | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 663 | - | lease := rc.attachmentLease(ac) | |||
| 664 | - | target := domain.Size{Cols: 100, Rows: 30} | |||
| 665 | - | epoch := rc.recordResizeRequestForLease(target, ac, lease) | |||
| 666 | - | ||||
| 667 | - | require.True(t, d.runResizeTransaction(sess, ac, lease, epoch)) | |||
| 668 | - | ac.sendMu.Lock() | |||
| 669 | - | require.Equal(t, target, ac.size) | |||
| 670 | - | ac.sendMu.Unlock() | |||
| 671 | - | commits := 0 | |||
| 672 | - | for _, mark := range observer.marks { | |||
| 673 | - | if mark.Kind == ports.RuntimeResizeCommitted { | |||
| 674 | - | commits++ | |||
| 675 | - | } | |||
| 676 | - | } | |||
| 677 | - | require.Equal(t, 1, commits) | |||
| 678 | - | } | |||
| 679 | - | ||||
| 680 | - | func TestResizeOwnerMoveImmediatelyBeforeRenderInvalidationDropsStaleSource(t *testing.T) { | |||
| 681 | - | d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 682 | - | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 683 | - | lease := rc.attachmentLease(ac) | |||
| 684 | - | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 685 | - | var invalidations atomic.Int32 | |||
| 686 | - | rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) } | |||
| 687 | - | tb := source.activeTab() | |||
| 688 | - | p := tb.focusedPane() | |||
| 689 | - | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 690 | - | entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRenderInvalidation) | |||
| 691 | - | ||||
| 692 | - | result := make(chan bool, 1) | |||
| 693 | - | go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }() | |||
| 694 | - | awaitTestCompletion(t, entered, "resize did not pause before render invalidation") | |||
| 695 | - | publishResizeOwnerMoveUnderFence(t, source, destination, tb, p) | |||
| 696 | - | release() | |||
| 697 | - | ||||
| 698 | - | require.False(t, awaitTestValue(t, result, "stale source invalidation did not return")) | |||
| 699 | - | require.Zero(t, invalidations.Load(), "stale source invalidated its renderer after the move won the fence") | |||
| 700 | - | } | |||
| 701 | - | ||||
| 702 | - | func TestRetryOwnerAcceptedTabStopsWhenFailedPaneMoves(t *testing.T) { | |||
| 703 | - | resizeErr := errors.New("scripted resize failure") | |||
| 704 | - | pty := &transactionalResizePTY{errs: []error{resizeErr}} | |||
| 705 | - | d, source, _, _ := newManualSessionWithPTYs(t, pty) | |||
| 706 | - | clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)} | |||
| 707 | - | d.clock = clock | |||
| 708 | - | tb := source.activeTab() | |||
| 709 | - | p := tb.focusedPane() | |||
| 710 | - | tb.mu.Lock() | |||
| 711 | - | tb.size = domain.Size{Cols: 100, Rows: 20} | |||
| 712 | - | tb.bumpLayoutGenerationLocked() | |||
| 713 | - | tb.mu.Unlock() | |||
| 714 | - | ||||
| 715 | - | require.True(t, d.applyTabLayout(source, tb)) | |||
| 716 | - | var timer *layoutRetryTimer | |||
| 717 | - | for timer == nil { | |||
| 718 | - | candidate := awaitTestValue(t, clock.timers, "accepted failure did not schedule its retry") | |||
| 719 | - | if candidate.delay == minOutputRenderDeadline { | |||
| 720 | - | timer = candidate | |||
| 721 | - | } | |||
| 722 | - | } | |||
| 723 | - | source.snapshotMu.Lock() | |||
| 724 | - | sourceGeneration := source.snapshotGeneration | |||
| 725 | - | source.snapshotMu.Unlock() | |||
| 726 | - | notices := len(d.notices.history()) | |||
| 727 | - | ||||
| 728 | - | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 729 | - | publishPaneOwner(p, destination, tb, 0) | |||
| 730 | - | timer.fire() | |||
| 731 | - | ||||
| 732 | - | require.Eventually(t, func() bool { | |||
| 733 | - | tb.layoutRetryMu.Lock() | |||
| 734 | - | defer tb.layoutRetryMu.Unlock() | |||
| 735 | - | return !tb.layoutRetryRunning | |||
| 736 | - | }, time.Second, time.Millisecond, "stale source retry did not retire") | |||
| 737 | - | require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested(), "stale source retry reached the moved pane PTY") | |||
| 738 | - | source.snapshotMu.Lock() | |||
| 739 | - | require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale source retry dirtied its former session") | |||
| 740 | - | source.snapshotMu.Unlock() | |||
| 741 | - | require.Len(t, d.notices.history(), notices, "stale source retry published a notice") | |||
| 742 | - | } | |||
| 743 | - | ||||
| 744 | - | func TestTransactionalResizeOwnerMoveAfterExternalApplyPublishesOnlyDestinationGeometry(t *testing.T) { | |||
| 745 | - | entered := make(chan struct{}) | |||
| 746 | - | release := make(chan struct{}) | |||
| 747 | - | pty := &transactionalResizePTY{} | |||
| 748 | - | pty.onResize = func() { | |||
| 749 | - | if len(pty.requested()) != 1 { | |||
| 750 | - | return | |||
| 751 | - | } | |||
| 752 | - | close(entered) | |||
| 753 | - | <-release | |||
| 754 | - | } | |||
| 755 | - | d, source, ac, _ := newManualSessionWithPTYs(t, pty) | |||
| 756 | - | observer := &daemonRuntimeObserver{} | |||
| 757 | - | d.runtimeObserver = observer | |||
| 758 | - | rc := d.attachCoordinator(source, nil, ac, true) | |||
| 759 | - | var invalidations atomic.Int32 | |||
| 760 | - | rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) } | |||
| 761 | - | lease := rc.attachmentLease(ac) | |||
| 762 | - | epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease) | |||
| 763 | - | require.NotZero(t, epoch) | |||
| 764 | - | tb := source.activeTab() | |||
| 765 | - | p := tb.focusedPane() | |||
| 766 | - | destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}} | |||
| 767 | - | ||||
| 768 | - | result := make(chan bool, 1) | |||
| 769 | - | go func() { | |||
| 770 | - | result <- d.runResizeTransaction(source, ac, lease, epoch) | |||
| 771 | - | }() | |||
| 772 | - | awaitTestCompletion(t, entered, "source resize did not reach the external PTY") | |||
| 773 | - | ||||
| 774 | - | source.mu.Lock() | |||
| 775 | - | source.tabs = nil | |||
| 776 | - | source.active = -1 | |||
| 777 | - | source.mu.Unlock() | |||
| 778 | - | publishPaneOwner(p, destination, tb, 0) | |||
| 779 | - | close(release) | |||
| 780 | - | ||||
| 781 | - | require.False(t, awaitTestValue(t, result, "source resize did not finish after the owner move")) | |||
| 782 | - | require.False(t, source.snapDirty.Load(), "retired source owner dirtied its snapshot") | |||
| 783 | - | require.Zero(t, invalidations.Load(), "retired source owner invalidated its renderer") | |||
| 784 | - | require.Empty(t, d.notices.history(), "retired source owner published a resize notice") | |||
| 785 | - | for _, mark := range observer.marks { | |||
| 786 | - | require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "retired source owner published resize telemetry") | |||
| 787 | - | } | |||
| 788 | - | ac.sendMu.Lock() | |||
| 789 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "retired source owner published client geometry") | |||
| 790 | - | ac.sendMu.Unlock() | |||
| 791 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "retired source owner published the moved tab size") | |||
| 792 | - | p.mu.Lock() | |||
| 793 | - | require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "retired source owner published pane geometry") | |||
| 794 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "retired source owner published VT geometry") | |||
| 795 | - | require.True(t, p.resizeRetry, "the destination owner needs metadata for the externally applied stale size") | |||
| 796 | - | p.mu.Unlock() | |||
| 797 | - | ||||
| 798 | - | require.True(t, d.applyTabLayout(destination, tb)) | |||
| 799 | - | require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 80, Rows: 23}}, pty.requested(), "destination layout must restore its owned geometry") | |||
| 800 | - | p.mu.Lock() | |||
| 801 | - | require.False(t, p.resizeRetry) | |||
| 802 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}) | |||
| 803 | - | p.mu.Unlock() | |||
| 804 | - | } | |||
| 805 | - | ||||
| 806 | func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) { | 410 | func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) { | ||
| 807 | first, second := &transactionalResizePTY{}, &transactionalResizePTY{} | 411 | first, second := &transactionalResizePTY{}, &transactionalResizePTY{} | ||
| 808 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | 412 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | ||
| @@ -941,6 +545,193 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||||
| 941 | source.snapshotMu.Unlock() | 545 | source.snapshotMu.Unlock() | ||
| 942 | } | 546 | } | ||
| 943 | 547 | ||||
| 548 | + | func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) { | |||
| 549 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 550 | + | for _, tc := range []struct { | |||
| 551 | + | name string | |||
| 552 | + | from domain.Size | |||
| 553 | + | to domain.Size | |||
| 554 | + | wantMode ui.PresentationMode | |||
| 555 | + | }{ | |||
| 556 | + | {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer}, | |||
| 557 | + | {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating}, | |||
| 558 | + | } { | |||
| 559 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 560 | + | d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | |||
| 561 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 562 | + | require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true)) | |||
| 563 | + | ||||
| 564 | + | tb := sess.activeTab() | |||
| 565 | + | fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg) | |||
| 566 | + | toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg) | |||
| 567 | + | entered, release := make(chan struct{}), make(chan struct{}) | |||
| 568 | + | var once sync.Once | |||
| 569 | + | popupPTY := &transactionalResizePTY{onResize: func() { | |||
| 570 | + | once.Do(func() { close(entered) }) | |||
| 571 | + | <-release | |||
| 572 | + | }} | |||
| 573 | + | popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner)) | |||
| 574 | + | popup.rect = fromGeometry.Inner | |||
| 575 | + | popup.popupGeometry = fromGeometry | |||
| 576 | + | installTestFloating(tb, popup, true) | |||
| 577 | + | ||||
| 578 | + | done := make(chan bool, 1) | |||
| 579 | + | go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }() | |||
| 580 | + | <-entered | |||
| 581 | + | popup.mu.Lock() | |||
| 582 | + | require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode") | |||
| 583 | + | require.Equal(t, fromGeometry.Inner, popup.rect) | |||
| 584 | + | require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 585 | + | popup.mu.Unlock() | |||
| 586 | + | close(release) | |||
| 587 | + | require.True(t, <-done) | |||
| 588 | + | ||||
| 589 | + | require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested()) | |||
| 590 | + | popup.mu.Lock() | |||
| 591 | + | require.Equal(t, tc.wantMode, popup.popupGeometry.Mode) | |||
| 592 | + | require.Equal(t, toGeometry, popup.popupGeometry) | |||
| 593 | + | require.Equal(t, toGeometry.Inner, popup.rect) | |||
| 594 | + | require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 595 | + | popup.mu.Unlock() | |||
| 596 | + | }) | |||
| 597 | + | } | |||
| 598 | + | } | |||
| 599 | + | ||||
| 600 | + | func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) { | |||
| 601 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |||
| 602 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg) | |||
| 603 | + | requestedSize := domain.Size{Cols: 79, Rows: 40} | |||
| 604 | + | requested := calculateContentFloatingGeometry(requestedSize, cfg) | |||
| 605 | + | require.Equal(t, ui.PresentationFloating, committed.Mode) | |||
| 606 | + | require.Equal(t, ui.PresentationDrawer, requested.Mode) | |||
| 607 | + | resizeErr := errors.New("floating resize failed") | |||
| 608 | + | pty := &transactionalResizePTY{errs: []error{resizeErr}} | |||
| 609 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 610 | + | popup.rect = committed.Inner | |||
| 611 | + | popup.popupGeometry = committed | |||
| 612 | + | tb := newTab(nil, requestedSize) | |||
| 613 | + | installTestFloating(tb, popup, true) | |||
| 614 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 615 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 616 | + | var tabUnlocked, paneUnlocked bool | |||
| 617 | + | pty.onResize = func() { | |||
| 618 | + | tabUnlocked = tb.mu.TryLock() | |||
| 619 | + | if tabUnlocked { | |||
| 620 | + | tb.mu.Unlock() | |||
| 621 | + | } | |||
| 622 | + | paneUnlocked = popup.mu.TryLock() | |||
| 623 | + | if paneUnlocked { | |||
| 624 | + | popup.mu.Unlock() | |||
| 625 | + | } | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |||
| 629 | + | ||||
| 630 | + | require.True(t, ok) | |||
| 631 | + | require.Len(t, failed, 1) | |||
| 632 | + | require.ErrorIs(t, failed[0].err, resizeErr) | |||
| 633 | + | require.Equal(t, []domain.Size{rectSize(requested.Inner)}, pty.requested()) | |||
| 634 | + | require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu") | |||
| 635 | + | require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu") | |||
| 636 | + | popup.mu.Lock() | |||
| 637 | + | require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle") | |||
| 638 | + | require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry") | |||
| 639 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 640 | + | require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable") | |||
| 641 | + | require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate") | |||
| 642 | + | popup.mu.Unlock() | |||
| 643 | + | tb.mu.Lock() | |||
| 644 | + | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |||
| 645 | + | tb.mu.Unlock() | |||
| 646 | + | require.True(t, visible) | |||
| 647 | + | require.Equal(t, committed, inputGeometry, "input mapping must retain committed geometry") | |||
| 648 | + | } | |||
| 649 | + | ||||
| 650 | + | func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *testing.T) { | |||
| 651 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |||
| 652 | + | size := domain.Size{Cols: 79, Rows: 20} | |||
| 653 | + | requested := calculateContentFloatingGeometry(size, cfg) | |||
| 654 | + | require.Equal(t, ui.PresentationDrawer, requested.Mode) | |||
| 655 | + | committed := requested | |||
| 656 | + | committed.Mode = ui.PresentationFloating | |||
| 657 | + | committed.Bounds.X++ | |||
| 658 | + | committed.Inner.X++ | |||
| 659 | + | pty := &transactionalResizePTY{} | |||
| 660 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 661 | + | popup.rect = committed.Inner | |||
| 662 | + | popup.popupGeometry = committed | |||
| 663 | + | tb := newTab(nil, size) | |||
| 664 | + | installTestFloating(tb, popup, true) | |||
| 665 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 666 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 667 | + | ||||
| 668 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil) | |||
| 669 | + | ||||
| 670 | + | require.True(t, ok) | |||
| 671 | + | require.Empty(t, failed) | |||
| 672 | + | require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY") | |||
| 673 | + | popup.mu.Lock() | |||
| 674 | + | require.Equal(t, requested.Inner, popup.rect) | |||
| 675 | + | require.Equal(t, requested, popup.popupGeometry) | |||
| 676 | + | require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 677 | + | require.False(t, popup.resizeRetry) | |||
| 678 | + | popup.mu.Unlock() | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) { | |||
| 682 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 683 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |||
| 684 | + | pty := &transactionalResizePTY{} | |||
| 685 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 686 | + | popup.rect = committed.Inner | |||
| 687 | + | popup.popupGeometry = committed | |||
| 688 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 689 | + | installTestFloating(tb, popup, true) | |||
| 690 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 691 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 692 | + | calls := 0 | |||
| 693 | + | ||||
| 694 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { | |||
| 695 | + | calls++ | |||
| 696 | + | return calls == 1 | |||
| 697 | + | }) | |||
| 698 | + | ||||
| 699 | + | require.True(t, ok, "a publication accepted while current must not later report stale") | |||
| 700 | + | require.Equal(t, 1, calls, "current must not be rechecked after publication") | |||
| 701 | + | require.Empty(t, failed) | |||
| 702 | + | require.Empty(t, pty.requested()) | |||
| 703 | + | requested := calculateContentFloatingGeometry(tb.size, cfg) | |||
| 704 | + | popup.mu.Lock() | |||
| 705 | + | require.Equal(t, requested, popup.popupGeometry) | |||
| 706 | + | require.Equal(t, committed.Inner, popup.rect) | |||
| 707 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 708 | + | popup.mu.Unlock() | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) { | |||
| 712 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |||
| 713 | + | committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg) | |||
| 714 | + | pty := &transactionalResizePTY{} | |||
| 715 | + | popup := newPane("floating", pty, rectSize(committed.Inner)) | |||
| 716 | + | popup.rect = committed.Inner | |||
| 717 | + | popup.popupGeometry = committed | |||
| 718 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |||
| 719 | + | installTestFloating(tb, popup, true) | |||
| 720 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 721 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 722 | + | ||||
| 723 | + | failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false }) | |||
| 724 | + | ||||
| 725 | + | require.False(t, ok) | |||
| 726 | + | require.Empty(t, failed) | |||
| 727 | + | require.Empty(t, pty.requested()) | |||
| 728 | + | popup.mu.Lock() | |||
| 729 | + | require.Equal(t, committed, popup.popupGeometry) | |||
| 730 | + | require.Equal(t, committed.Inner, popup.rect) | |||
| 731 | + | require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height}) | |||
| 732 | + | popup.mu.Unlock() | |||
| 733 | + | } | |||
| 734 | + | ||||
| 944 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | 735 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | ||
| 945 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | 736 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | ||
| 946 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | 737 | d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}) | ||
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 | + | } | |||
M internal/usecase/keys/keyspec.go +23 -21
| @@ -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 | {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 | 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 | + | } | |||
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 | + | } | |||
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 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | 492 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | ||
| 327 | t.Parallel() | 493 | t.Parallel() | ||
| 328 | 494 | ||||
M internal/usecase/ui/modal.go +65 -39
| @@ -18,8 +18,7 @@ type Modal struct { | |||||
| 18 | FixedHeight int | 18 | FixedHeight int | ||
| 19 | } | 19 | } | ||
| 20 | 20 | ||||
| 21 | - | // Bounds returns the modal rectangle positioned within base and clamped to base. | 21 | + | func (m Modal) preferredBounds(base domain.Size) domain.Rect { |
| 22 | - | func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 23 | width := percentOf(base.Cols, m.WidthPct) | 22 | width := percentOf(base.Cols, m.WidthPct) | ||
| 24 | height := percentOf(base.Rows, m.HeightPct) | 23 | height := percentOf(base.Rows, m.HeightPct) | ||
| 25 | if m.FixedWidth > 0 { | 24 | if m.FixedWidth > 0 { | ||
| @@ -37,38 +36,41 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||||
| 37 | return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins) | 36 | return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins) | ||
| 38 | } | 37 | } | ||
| 39 | 38 | ||||
| 40 | - | // Inner returns the modal content rectangle after removing a one-cell border. | 39 | + | // Resolve computes the modal's responsive presentation from one preferred |
| 41 | - | func (m Modal) Inner(base domain.Size) domain.Rect { | 40 | + | // bounds and inner pair. |
| 42 | - | bounds := m.Bounds(base) | 41 | + | func (m Modal) Resolve(base domain.Size) Presentation { |
| 43 | - | return domain.Rect{ | 42 | + | bounds := m.preferredBounds(base) |
| 44 | - | X: bounds.X + 1, | 43 | + | return ResolvePresentation(base, bounds, modalInner(bounds)) |
| 45 | - | Y: bounds.Y + 1, | 44 | + | } |
| 46 | - | Width: max(0, bounds.Width-2), | 45 | + | |
| 47 | - | Height: max(0, bounds.Height-2), | 46 | + | // CompositePresentation draws an already-resolved modal presentation and |
| 47 | + | // returns its inner content rectangle. | |||
| 48 | + | func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect { | |||
| 49 | + | FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior}) | |||
| 50 | + | drawBorderEdges(f, p.Bounds, p.Borders, border) | |||
| 51 | + | if p.Borders&BorderTop != 0 { | |||
| 52 | + | m.drawTitle(f, p.Bounds, border) | |||
| 53 | + | } | |||
| 54 | + | return p.Inner | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) { | |||
| 58 | + | if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" { | |||
| 59 | + | return | |||
| 48 | } | 60 | } | ||
| 61 | + | left := bounds.X + 1 | |||
| 62 | + | right := bounds.X + bounds.Width - 1 | |||
| 63 | + | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |||
| 64 | + | DrawText(f, start, bounds.Y, right, m.Title, style) | |||
| 49 | } | 65 | } | ||
| 50 | 66 | ||||
| 51 | - | // Composite draws the modal border and title, fills its interior, and returns | 67 | + | func modalInner(bounds domain.Rect) domain.Rect { |
| 52 | - | // the inner content rectangle. Border and interior styles are deliberately | 68 | + | return domain.Rect{ |
| 53 | - | // independent so unfocused structure and chrome surfaces retain their roles. | |||
| 54 | - | // Cells outside the modal bounds are not changed. | |||
| 55 | - | func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect { | |||
| 56 | - | bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height}) | |||
| 57 | - | inner := domain.Rect{ | |||
| 58 | X: bounds.X + 1, | 69 | X: bounds.X + 1, | ||
| 59 | Y: bounds.Y + 1, | 70 | Y: bounds.Y + 1, | ||
| 60 | Width: max(0, bounds.Width-2), | 71 | Width: max(0, bounds.Width-2), | ||
| 61 | Height: max(0, bounds.Height-2), | 72 | Height: max(0, bounds.Height-2), | ||
| 62 | } | 73 | } | ||
| 63 | - | DrawBox(f, bounds, border) | |||
| 64 | - | FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior}) | |||
| 65 | - | if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" { | |||
| 66 | - | left := bounds.X + 1 | |||
| 67 | - | right := bounds.X + bounds.Width - 1 | |||
| 68 | - | start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2) | |||
| 69 | - | DrawText(f, start, bounds.Y, right, m.Title, border) | |||
| 70 | - | } | |||
| 71 | - | return inner | |||
| 72 | } | 74 | } | ||
| 73 | 75 | ||||
| 74 | // FillRect fills rect with cell, clipped to the frame bounds. | 76 | // FillRect fills rect with cell, clipped to the frame bounds. | ||
| @@ -86,7 +88,11 @@ func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) { | |||||
| 86 | 88 | ||||
| 87 | // DrawBox draws a single-cell box clipped to the frame bounds. | 89 | // DrawBox draws a single-cell box clipped to the frame bounds. | ||
| 88 | func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | 90 | func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | ||
| 89 | - | if rect.Width <= 0 || rect.Height <= 0 { | 91 | + | drawBorderEdges(f, rect, BorderAll, style) |
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func drawBorderEdges(f renderer.Frame, rect domain.Rect, edges BorderEdges, style renderer.Style) { | |||
| 95 | + | if rect.Width <= 0 || rect.Height <= 0 || edges == 0 { | |||
| 90 | return | 96 | return | ||
| 91 | } | 97 | } | ||
| 92 | left := rect.X | 98 | left := rect.X | ||
| @@ -94,18 +100,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | |||||
| 94 | right := rect.X + rect.Width - 1 | 100 | right := rect.X + rect.Width - 1 | ||
| 95 | bottom := rect.Y + rect.Height - 1 | 101 | bottom := rect.Y + rect.Height - 1 | ||
| 96 | 102 | ||||
| 97 | - | for x := left; x <= right; x++ { | 103 | + | if edges&BorderTop != 0 { |
| 98 | - | setCell(f, x, top, '─', style) | 104 | + | for x := left; x <= right; x++ { |
| 99 | - | setCell(f, x, bottom, '─', style) | 105 | + | setCell(f, x, top, '─', style) |
| 100 | - | } | 106 | + | } |
| 101 | - | for y := top; y <= bottom; y++ { | 107 | + | } |
| 102 | - | setCell(f, left, y, '│', style) | 108 | + | if edges&BorderBottom != 0 { |
| 103 | - | setCell(f, right, y, '│', style) | 109 | + | for x := left; x <= right; x++ { |
| 104 | - | } | 110 | + | setCell(f, x, bottom, '─', style) |
| 105 | - | setCell(f, left, top, '┌', style) | 111 | + | } |
| 106 | - | setCell(f, right, top, '┐', style) | 112 | + | } |
| 107 | - | setCell(f, left, bottom, '└', style) | 113 | + | if edges&BorderLeft != 0 { |
| 108 | - | setCell(f, right, bottom, '┘', style) | 114 | + | for y := top; y <= bottom; y++ { |
| 115 | + | setCell(f, left, y, '│', style) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | if edges&BorderRight != 0 { | |||
| 119 | + | for y := top; y <= bottom; y++ { | |||
| 120 | + | setCell(f, right, y, '│', style) | |||
| 121 | + | } | |||
| 122 | + | } | |||
| 123 | + | if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft { | |||
| 124 | + | setCell(f, left, top, '┌', style) | |||
| 125 | + | } | |||
| 126 | + | if edges&(BorderTop|BorderRight) == BorderTop|BorderRight { | |||
| 127 | + | setCell(f, right, top, '┐', style) | |||
| 128 | + | } | |||
| 129 | + | if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft { | |||
| 130 | + | setCell(f, left, bottom, '└', style) | |||
| 131 | + | } | |||
| 132 | + | if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight { | |||
| 133 | + | setCell(f, right, bottom, '┘', style) | |||
| 134 | + | } | |||
| 109 | } | 135 | } | ||
| 110 | 136 | ||||
| 111 | // DrawText draws text starting at x,y before exclusive clipX and returns the | 137 | // DrawText draws text starting at x,y before exclusive clipX and returns the | ||
M internal/usecase/ui/modal_test.go +178 -72
| @@ -8,7 +8,7 @@ import ( | |||||
| 8 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | - | func TestModalBounds(t *testing.T) { | 11 | + | func TestModalResolveBounds(t *testing.T) { |
| 12 | tests := []struct { | 12 | tests := []struct { | ||
| 13 | name string | 13 | name string | ||
| 14 | base domain.Size | 14 | base domain.Size | ||
| @@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) { | |||||
| 22 | want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, | 22 | want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, | ||
| 23 | }, | 23 | }, | ||
| 24 | { | 24 | { | ||
| 25 | - | name: "minimum clamps to base", | 25 | + | name: "minimum clamps to drawer availability", |
| 26 | base: domain.Size{Cols: 20, Rows: 6}, | 26 | base: domain.Size{Cols: 20, Rows: 6}, | ||
| 27 | m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12}, | 27 | m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12}, | ||
| 28 | - | want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}, | 28 | + | want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}, |
| 29 | }, | 29 | }, | ||
| 30 | { | 30 | { | ||
| 31 | - | name: "odd centering rounds down", | 31 | + | name: "narrow preferred height becomes drawer height", |
| 32 | base: domain.Size{Cols: 11, Rows: 7}, | 32 | base: domain.Size{Cols: 11, Rows: 7}, | ||
| 33 | m: Modal{WidthPct: 50, HeightPct: 50}, | 33 | m: Modal{WidthPct: 50, HeightPct: 50}, | ||
| 34 | - | want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3}, | 34 | + | want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3}, |
| 35 | }, | 35 | }, | ||
| 36 | { | 36 | { | ||
| 37 | - | name: "zero value fills base centered", | 37 | + | name: "zero value fills available drawer area", |
| 38 | base: domain.Size{Cols: 12, Rows: 8}, | 38 | base: domain.Size{Cols: 12, Rows: 8}, | ||
| 39 | m: Modal{}, | 39 | m: Modal{}, | ||
| 40 | - | want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8}, | 40 | + | want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4}, |
| 41 | }, | 41 | }, | ||
| 42 | { | 42 | { | ||
| 43 | name: "bottom anchor honors bottom margin", | 43 | name: "bottom anchor honors bottom margin", | ||
| @@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) { | |||||
| 63 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | 63 | want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12}, | ||
| 64 | }, | 64 | }, | ||
| 65 | { | 65 | { | ||
| 66 | - | name: "right margin clamps to screen", | 66 | + | name: "drawer ignores right margin", |
| 67 | base: domain.Size{Cols: 20, Rows: 10}, | 67 | base: domain.Size{Cols: 20, Rows: 10}, | ||
| 68 | m: Modal{ | 68 | m: Modal{ | ||
| 69 | FixedWidth: 8, | 69 | FixedWidth: 8, | ||
| @@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) { | |||||
| 71 | Anchor: domain.AnchorRight, | 71 | Anchor: domain.AnchorRight, | ||
| 72 | Margins: Margins{Right: 20}, | 72 | Margins: Margins{Right: 20}, | ||
| 73 | }, | 73 | }, | ||
| 74 | - | want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4}, | 74 | + | want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, |
| 75 | }, | 75 | }, | ||
| 76 | { | 76 | { | ||
| 77 | - | name: "right anchored fixed dimensions clamp on small terminal", | 77 | + | name: "right anchored drawer can have zero height", |
| 78 | base: domain.Size{Cols: 10, Rows: 4}, | 78 | base: domain.Size{Cols: 10, Rows: 4}, | ||
| 79 | m: Modal{ | 79 | m: Modal{ | ||
| 80 | FixedWidth: 30, | 80 | FixedWidth: 30, | ||
| @@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) { | |||||
| 82 | Anchor: domain.AnchorRight, | 82 | Anchor: domain.AnchorRight, | ||
| 83 | Margins: Margins{Right: 3}, | 83 | Margins: Margins{Right: 3}, | ||
| 84 | }, | 84 | }, | ||
| 85 | - | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | 85 | + | want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0}, |
| 86 | }, | 86 | }, | ||
| 87 | { | 87 | { | ||
| 88 | - | name: "fixed dimensions clamp on small terminal", | 88 | + | name: "fixed drawer dimensions clamp on small terminal", |
| 89 | base: domain.Size{Cols: 10, Rows: 4}, | 89 | base: domain.Size{Cols: 10, Rows: 4}, | ||
| 90 | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}}, | 90 | m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}}, | ||
| 91 | - | want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4}, | 91 | + | want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0}, |
| 92 | }, | 92 | }, | ||
| 93 | { | 93 | { | ||
| 94 | - | name: "bottom margin clamps to screen", | 94 | + | name: "drawer ignores bottom margin", |
| 95 | base: domain.Size{Cols: 20, Rows: 10}, | 95 | base: domain.Size{Cols: 20, Rows: 10}, | ||
| 96 | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}}, | 96 | m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}}, | ||
| 97 | - | want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4}, | 97 | + | want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, |
| 98 | }, | 98 | }, | ||
| 99 | { | 99 | { | ||
| 100 | name: "bottom right anchor honors combined margins", | 100 | name: "bottom right anchor honors combined margins", | ||
| @@ -110,58 +110,171 @@ func TestModalBounds(t *testing.T) { | |||||
| 110 | } | 110 | } | ||
| 111 | for _, tt := range tests { | 111 | for _, tt := range tests { | ||
| 112 | t.Run(tt.name, func(t *testing.T) { | 112 | t.Run(tt.name, func(t *testing.T) { | ||
| 113 | - | if got := tt.m.Bounds(tt.base); got != tt.want { | 113 | + | if got := tt.m.Resolve(tt.base).Bounds; got != tt.want { |
| 114 | - | t.Fatalf("Bounds() = %+v, want %+v", got, tt.want) | 114 | + | t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want) |
| 115 | } | 115 | } | ||
| 116 | }) | 116 | }) | ||
| 117 | } | 117 | } | ||
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | - | func TestModalInnerDegenerate(t *testing.T) { | 120 | + | func TestModalResolveInnerDegenerate(t *testing.T) { |
| 121 | - | m := Modal{WidthPct: 100, HeightPct: 100} | 121 | + | got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner |
| 122 | - | got := m.Inner(domain.Size{Cols: 2, Rows: 2}) | 122 | + | require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got) |
| 123 | - | want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0} | |||
| 124 | - | if got != want { | |||
| 125 | - | t.Fatalf("Inner() = %+v, want %+v", got, want) | |||
| 126 | - | } | |||
| 127 | } | 123 | } | ||
| 128 | 124 | ||||
| 129 | - | func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | 125 | + | func TestModalResolve(t *testing.T) { |
| 130 | - | f := renderer.NewFrame(10, 6) | 126 | + | tests := []struct { |
| 131 | - | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | 127 | + | name string |
| 132 | - | FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior) | 128 | + | base domain.Size |
| 129 | + | want Presentation | |||
| 130 | + | }{ | |||
| 131 | + | { | |||
| 132 | + | name: "narrow modal resolves to drawer", | |||
| 133 | + | base: domain.Size{Cols: 20, Rows: 10}, | |||
| 134 | + | want: Presentation{ | |||
| 135 | + | Mode: PresentationDrawer, | |||
| 136 | + | Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4}, | |||
| 137 | + | Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3}, | |||
| 138 | + | Borders: BorderTop, | |||
| 139 | + | }, | |||
| 140 | + | }, | |||
| 141 | + | { | |||
| 142 | + | name: "complete frame preserves floating modal geometry", | |||
| 143 | + | base: domain.Size{Cols: 80, Rows: 10}, | |||
| 144 | + | want: Presentation{ | |||
| 145 | + | Mode: PresentationFloating, | |||
| 146 | + | Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4}, | |||
| 147 | + | Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2}, | |||
| 148 | + | Borders: BorderAll, | |||
| 149 | + | }, | |||
| 150 | + | }, | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "} | |||
| 154 | + | for _, tt := range tests { | |||
| 155 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 156 | + | require.Equal(t, tt.want, modal.Resolve(tt.base)) | |||
| 157 | + | }) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 133 | 160 | ||||
| 161 | + | func TestCompositePresentationDrawsFloatingChrome(t *testing.T) { | |||
| 162 | + | modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "} | |||
| 163 | + | presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10}) | |||
| 164 | + | frame := renderer.NewFrame(80, 10) | |||
| 165 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |||
| 166 | + | FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior) | |||
| 134 | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | 167 | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | ||
| 135 | interior := renderer.Style{Foreground: 3, Background: 4} | 168 | interior := renderer.Style{Foreground: 3, Background: 4} | ||
| 136 | - | m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"} | 169 | + | |
| 137 | - | inner := m.Composite(f, border, interior) | 170 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) |
| 138 | - | wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2} | 171 | + | require.Equal(t, presentation.Inner, inner) |
| 139 | - | if inner != wantInner { | 172 | + | require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune) |
| 140 | - | t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner) | 173 | + | require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune) |
| 174 | + | require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune) | |||
| 175 | + | require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune) | |||
| 176 | + | require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune) | |||
| 177 | + | require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior)) | |||
| 178 | + | assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) { | |||
| 182 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |||
| 183 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |||
| 184 | + | bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4} | |||
| 185 | + | inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2} | |||
| 186 | + | ||||
| 187 | + | tests := []struct { | |||
| 188 | + | name string | |||
| 189 | + | presentation Presentation | |||
| 190 | + | assert func(*testing.T, renderer.Frame) | |||
| 191 | + | }{ | |||
| 192 | + | { | |||
| 193 | + | name: "all edges render for drawer mode", | |||
| 194 | + | presentation: Presentation{ | |||
| 195 | + | Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll, | |||
| 196 | + | }, | |||
| 197 | + | assert: func(t *testing.T, frame renderer.Frame) { | |||
| 198 | + | require.Equal(t, '┌', frame.At(1, 1).Rune) | |||
| 199 | + | require.Equal(t, '│', frame.At(1, 2).Rune) | |||
| 200 | + | require.Equal(t, '┘', frame.At(8, 4).Rune) | |||
| 201 | + | require.Equal(t, 'T', frame.At(2, 1).Rune) | |||
| 202 | + | }, | |||
| 203 | + | }, | |||
| 204 | + | { | |||
| 205 | + | name: "top edge alone renders for floating mode", | |||
| 206 | + | presentation: Presentation{ | |||
| 207 | + | Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop, | |||
| 208 | + | }, | |||
| 209 | + | assert: func(t *testing.T, frame renderer.Frame) { | |||
| 210 | + | require.Equal(t, '─', frame.At(1, 1).Rune) | |||
| 211 | + | require.Equal(t, 'T', frame.At(2, 1).Rune) | |||
| 212 | + | assertCell(t, frame, 1, 2, exterior) | |||
| 213 | + | assertCell(t, frame, 8, 4, exterior) | |||
| 214 | + | }, | |||
| 215 | + | }, | |||
| 216 | + | { | |||
| 217 | + | name: "title is hidden without top edge", | |||
| 218 | + | presentation: Presentation{ | |||
| 219 | + | Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft, | |||
| 220 | + | }, | |||
| 221 | + | assert: func(t *testing.T, frame renderer.Frame) { | |||
| 222 | + | require.Equal(t, '│', frame.At(1, 1).Rune) | |||
| 223 | + | require.Equal(t, '│', frame.At(1, 4).Rune) | |||
| 224 | + | assertCell(t, frame, 4, 1, exterior) | |||
| 225 | + | assertCell(t, frame, 8, 4, exterior) | |||
| 226 | + | }, | |||
| 227 | + | }, | |||
| 141 | } | 228 | } | ||
| 142 | 229 | ||||
| 143 | - | assertRune(t, f, 2, 1, '┌') | 230 | + | for _, tt := range tests { |
| 144 | - | assertRune(t, f, 7, 1, '┐') | 231 | + | t.Run(tt.name, func(t *testing.T) { |
| 145 | - | assertRune(t, f, 2, 4, '└') | 232 | + | frame := renderer.NewFrame(10, 6) |
| 146 | - | assertRune(t, f, 7, 4, '┘') | 233 | + | FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior) |
| 147 | - | assertRune(t, f, 3, 1, '─') | 234 | + | (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle()) |
| 148 | - | assertRune(t, f, 2, 2, '│') | 235 | + | tt.assert(t, frame) |
| 149 | - | assertRune(t, f, 4, 1, 'H') | 236 | + | }) |
| 150 | - | assertRune(t, f, 5, 1, 'i') | |||
| 151 | - | ||||
| 152 | - | for y := inner.Y; y < inner.Y+inner.Height; y++ { | |||
| 153 | - | for x := inner.X; x < inner.X+inner.Width; x++ { | |||
| 154 | - | want := renderer.Cell{Rune: ' ', Style: interior} | |||
| 155 | - | if got := f.At(x, y); !got.Equal(want) { | |||
| 156 | - | t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want) | |||
| 157 | - | } | |||
| 158 | - | } | |||
| 159 | } | 237 | } | ||
| 160 | - | if got := f.At(2, 1).Style; !got.Equal(border) { | 238 | + | } |
| 161 | - | t.Fatalf("border style = %+v, want %+v", got, border) | 239 | + | |
| 240 | + | func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) { | |||
| 241 | + | modal := Modal{FixedHeight: 4, Title: " Rename "} | |||
| 242 | + | presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10}) | |||
| 243 | + | frame := renderer.NewFrame(20, 10) | |||
| 244 | + | exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}} | |||
| 245 | + | FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior) | |||
| 246 | + | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |||
| 247 | + | interior := renderer.Style{Foreground: 3, Background: 4} | |||
| 248 | + | ||||
| 249 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) | |||
| 250 | + | require.Equal(t, presentation.Inner, inner) | |||
| 251 | + | require.Equal(t, '─', frame.At(0, 5).Rune) | |||
| 252 | + | require.True(t, frame.At(0, 5).Style.Equal(border)) | |||
| 253 | + | require.Equal(t, 'R', frame.At(7, 5).Rune) | |||
| 254 | + | require.NotEqual(t, '│', frame.At(0, 6).Rune) | |||
| 255 | + | require.Equal(t, ' ', frame.At(0, 6).Rune) | |||
| 256 | + | require.NotEqual(t, '─', frame.At(0, 8).Rune) | |||
| 257 | + | require.Equal(t, ' ', frame.At(0, 8).Rune) | |||
| 258 | + | ||||
| 259 | + | for _, y := range []int{0, 1, 2, 3, 4, 9} { | |||
| 260 | + | for x := range frame.Width { | |||
| 261 | + | assertCell(t, frame, x, y, exterior) | |||
| 262 | + | } | |||
| 162 | } | 263 | } | ||
| 163 | - | assertCell(t, f, 0, 0, exterior) | 264 | + | } |
| 164 | - | assertCell(t, f, 9, 5, exterior) | 265 | + | |
| 266 | + | func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) { | |||
| 267 | + | modal := Modal{FixedHeight: 4, Title: "你你"} | |||
| 268 | + | presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10}) | |||
| 269 | + | frame := renderer.NewFrame(4, 10) | |||
| 270 | + | border := renderer.Style{Foreground: 2, Background: -1} | |||
| 271 | + | ||||
| 272 | + | modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle()) | |||
| 273 | + | ||||
| 274 | + | require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune) | |||
| 275 | + | require.True(t, frame.At(2, presentation.Bounds.Y).Continuation) | |||
| 276 | + | require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune) | |||
| 277 | + | require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip") | |||
| 165 | } | 278 | } | ||
| 166 | 279 | ||||
| 167 | func TestFillRectClips(t *testing.T) { | 280 | func TestFillRectClips(t *testing.T) { | ||
| @@ -221,26 +334,19 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) { | |||||
| 221 | } | 334 | } | ||
| 222 | } | 335 | } | ||
| 223 | 336 | ||||
| 224 | - | func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) { | 337 | + | func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) { |
| 225 | border := renderer.Style{Foreground: 1, Background: 2} | 338 | border := renderer.Style{Foreground: 1, Background: 2} | ||
| 226 | interior := renderer.Style{Foreground: 3, Background: 4} | 339 | interior := renderer.Style{Foreground: 3, Background: 4} | ||
| 227 | - | for _, tt := range []struct { | 340 | + | frame := renderer.NewFrame(2, 2) |
| 228 | - | name string | 341 | + | modal := Modal{WidthPct: 100, HeightPct: 100} |
| 229 | - | size domain.Size | 342 | + | presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2}) |
| 230 | - | want domain.Rect | 343 | + | |
| 231 | - | }{ | 344 | + | got := modal.CompositePresentation(frame, presentation, border, interior) |
| 232 | - | {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}}, | 345 | + | |
| 233 | - | {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}}, | 346 | + | require.Equal(t, presentation.Inner, got) |
| 234 | - | } { | 347 | + | for y := range frame.Height { |
| 235 | - | t.Run(tt.name, func(t *testing.T) { | 348 | + | for x := range frame.Width { |
| 236 | - | frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows) | 349 | + | require.Equal(t, renderer.BlankCell(), frame.At(x, y)) |
| 237 | - | got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior) | 350 | + | } |
| 238 | - | require.Equal(t, tt.want, got) | |||
| 239 | - | for y := range frame.Height { | |||
| 240 | - | for x := range frame.Width { | |||
| 241 | - | require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds") | |||
| 242 | - | } | |||
| 243 | - | } | |||
| 244 | - | }) | |||
| 245 | } | 351 | } | ||
| 246 | } | 352 | } | ||
A internal/usecase/ui/presentation.go +75 -0
| @@ -0,0 +1,75 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/domain" | |||
| 4 | + | ||||
| 5 | + | const ( | |||
| 6 | + | ResponsiveDrawerBreakpoint = 80 | |||
| 7 | + | DrawerContextRows = 3 | |||
| 8 | + | DrawerBottomRows = 1 | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // PresentationMode describes whether an overlay uses its preferred floating | |||
| 12 | + | // geometry or becomes a narrow-terminal drawer. | |||
| 13 | + | type PresentationMode uint8 | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | PresentationFloating PresentationMode = iota | |||
| 17 | + | PresentationDrawer | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | // BorderEdges identifies the edges drawn around a presentation. | |||
| 21 | + | type BorderEdges uint8 | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | BorderTop BorderEdges = 1 << iota | |||
| 25 | + | BorderRight | |||
| 26 | + | BorderBottom | |||
| 27 | + | BorderLeft | |||
| 28 | + | BorderAll = BorderTop | BorderRight | BorderBottom | BorderLeft | |||
| 29 | + | ) | |||
| 30 | + | ||||
| 31 | + | // Presentation is the resolved outer and content geometry for an overlay. | |||
| 32 | + | type Presentation struct { | |||
| 33 | + | Mode PresentationMode | |||
| 34 | + | Bounds domain.Rect | |||
| 35 | + | Inner domain.Rect | |||
| 36 | + | Borders BorderEdges | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // ResolvePresentation preserves preferred geometry on complete frames at or | |||
| 40 | + | // above the responsive breakpoint. Narrow frames use a full-width bottom | |||
| 41 | + | // drawer while reserving frame rows 0-2 and the bottom bar. | |||
| 42 | + | func ResolvePresentation(base domain.Size, preferredBounds, preferredInner domain.Rect) Presentation { | |||
| 43 | + | if !base.Valid() { | |||
| 44 | + | return Presentation{} | |||
| 45 | + | } | |||
| 46 | + | if base.Cols >= ResponsiveDrawerBreakpoint { | |||
| 47 | + | return Presentation{ | |||
| 48 | + | Mode: PresentationFloating, | |||
| 49 | + | Bounds: preferredBounds, | |||
| 50 | + | Inner: preferredInner, | |||
| 51 | + | Borders: BorderAll, | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | available := max(0, base.Rows-DrawerContextRows-DrawerBottomRows) | |||
| 56 | + | height := min(max(preferredBounds.Height, 0), available) | |||
| 57 | + | bounds := domain.Rect{ | |||
| 58 | + | X: 0, | |||
| 59 | + | Y: base.Rows - DrawerBottomRows - height, | |||
| 60 | + | Width: base.Cols, | |||
| 61 | + | Height: height, | |||
| 62 | + | } | |||
| 63 | + | inner := domain.Rect{ | |||
| 64 | + | X: 0, | |||
| 65 | + | Y: bounds.Y + min(1, height), | |||
| 66 | + | Width: base.Cols, | |||
| 67 | + | Height: max(0, height-1), | |||
| 68 | + | } | |||
| 69 | + | return Presentation{ | |||
| 70 | + | Mode: PresentationDrawer, | |||
| 71 | + | Bounds: bounds, | |||
| 72 | + | Inner: inner, | |||
| 73 | + | Borders: BorderTop, | |||
| 74 | + | } | |||
| 75 | + | } | |||
A internal/usecase/ui/presentation_test.go +111 -0
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolvePresentation(t *testing.T) { | |||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | base domain.Size | |||
| 14 | + | bounds, inner domain.Rect | |||
| 15 | + | want Presentation | |||
| 16 | + | }{ | |||
| 17 | + | { | |||
| 18 | + | name: "79 columns becomes full-width drawer", | |||
| 19 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 20 | + | bounds: domain.Rect{X: 8, Y: 6, Width: 63, Height: 11}, | |||
| 21 | + | inner: domain.Rect{X: 9, Y: 7, Width: 61, Height: 9}, | |||
| 22 | + | want: Presentation{ | |||
| 23 | + | Mode: PresentationDrawer, | |||
| 24 | + | Bounds: domain.Rect{X: 0, Y: 12, Width: 79, Height: 11}, | |||
| 25 | + | Inner: domain.Rect{X: 0, Y: 13, Width: 79, Height: 10}, | |||
| 26 | + | Borders: BorderTop, | |||
| 27 | + | }, | |||
| 28 | + | }, | |||
| 29 | + | { | |||
| 30 | + | name: "80 columns preserves preferred geometry", | |||
| 31 | + | base: domain.Size{Cols: 80, Rows: 24}, | |||
| 32 | + | bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |||
| 33 | + | inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |||
| 34 | + | want: Presentation{ | |||
| 35 | + | Mode: PresentationFloating, | |||
| 36 | + | Bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18}, | |||
| 37 | + | Inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16}, | |||
| 38 | + | Borders: BorderAll, | |||
| 39 | + | }, | |||
| 40 | + | }, | |||
| 41 | + | { | |||
| 42 | + | name: "100 percent height preserves three rows and bottom bar", | |||
| 43 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 44 | + | bounds: domain.Rect{Width: 79, Height: 24}, | |||
| 45 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 22}, | |||
| 46 | + | want: Presentation{ | |||
| 47 | + | Mode: PresentationDrawer, | |||
| 48 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 20}, | |||
| 49 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 19}, | |||
| 50 | + | Borders: BorderTop, | |||
| 51 | + | }, | |||
| 52 | + | }, | |||
| 53 | + | { | |||
| 54 | + | name: "preferred height larger than available is capped", | |||
| 55 | + | base: domain.Size{Cols: 79, Rows: 10}, | |||
| 56 | + | bounds: domain.Rect{X: -100, Y: -100, Width: 1000, Height: 1000}, | |||
| 57 | + | inner: domain.Rect{X: -99, Y: -99, Width: 998, Height: 998}, | |||
| 58 | + | want: Presentation{ | |||
| 59 | + | Mode: PresentationDrawer, | |||
| 60 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 6}, | |||
| 61 | + | Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 5}, | |||
| 62 | + | Borders: BorderTop, | |||
| 63 | + | }, | |||
| 64 | + | }, | |||
| 65 | + | { | |||
| 66 | + | name: "negative preferred height becomes empty drawer", | |||
| 67 | + | base: domain.Size{Cols: 79, Rows: 24}, | |||
| 68 | + | bounds: domain.Rect{Height: -1}, | |||
| 69 | + | inner: domain.Rect{Height: -3}, | |||
| 70 | + | want: Presentation{ | |||
| 71 | + | Mode: PresentationDrawer, | |||
| 72 | + | Bounds: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |||
| 73 | + | Inner: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0}, | |||
| 74 | + | Borders: BorderTop, | |||
| 75 | + | }, | |||
| 76 | + | }, | |||
| 77 | + | { | |||
| 78 | + | name: "four-row terminal reserves all protected rows", | |||
| 79 | + | base: domain.Size{Cols: 79, Rows: 4}, | |||
| 80 | + | bounds: domain.Rect{Width: 79, Height: 4}, | |||
| 81 | + | inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 2}, | |||
| 82 | + | want: Presentation{ | |||
| 83 | + | Mode: PresentationDrawer, | |||
| 84 | + | Bounds: domain.Rect{X: 0, Y: 3, Width: 79}, | |||
| 85 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79}, | |||
| 86 | + | Borders: BorderTop, | |||
| 87 | + | }, | |||
| 88 | + | }, | |||
| 89 | + | { | |||
| 90 | + | name: "one-row terminal is panic-free and empty", | |||
| 91 | + | base: domain.Size{Cols: 1, Rows: 1}, | |||
| 92 | + | bounds: domain.Rect{Width: 1, Height: 1}, | |||
| 93 | + | inner: domain.Rect{X: 1, Y: 1}, | |||
| 94 | + | want: Presentation{ | |||
| 95 | + | Mode: PresentationDrawer, | |||
| 96 | + | Bounds: domain.Rect{X: 0, Y: 0, Width: 1}, | |||
| 97 | + | Inner: domain.Rect{X: 0, Y: 0, Width: 1}, | |||
| 98 | + | Borders: BorderTop, | |||
| 99 | + | }, | |||
| 100 | + | }, | |||
| 101 | + | {name: "zero frame is empty", base: domain.Size{}, want: Presentation{}}, | |||
| 102 | + | {name: "negative columns are empty", base: domain.Size{Cols: -1, Rows: 24}, want: Presentation{}}, | |||
| 103 | + | {name: "negative rows are empty", base: domain.Size{Cols: 79, Rows: -1}, want: Presentation{}}, | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | for _, tt := range tests { | |||
| 107 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 108 | + | require.Equal(t, tt.want, ResolvePresentation(tt.base, tt.bounds, tt.inner)) | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||