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 | 52 | | Alt+h/j/k/l, Alt+Arrow | focus pane | | |
| 53 | 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 | 57 | ## Remote attach | |
| 58 | 58 | ||
M docs/configuration.md +21 -2
| @@ -34,6 +34,9 @@ focus-pane-left = alt+h | |||
| 34 | 34 | focus-pane-right = alt+l | |
| 35 | 35 | focus-pane-up = alt+k | |
| 36 | 36 | focus-pane-down = alt+j | |
| 37 | + | # Optional pane rearrangement actions are unbound by default. | |
| 38 | + | # consume-or-expel-pane-left = alt+H | |
| 39 | + | # consume-or-expel-pane-right = alt+L | |
| 37 | 40 | switch-tab-1 = alt+1 | |
| 38 | 41 | # ... through switch-tab-9 = alt+9 | |
| 39 | 42 | ||
| @@ -53,6 +56,8 @@ code.split-right = SPR | |||
| 53 | 56 | code.split-left = SPL | |
| 54 | 57 | code.split-up = SPU | |
| 55 | 58 | code.split-down = SPD | |
| 59 | + | code.consume-or-expel-pane-left = CEL | |
| 60 | + | code.consume-or-expel-pane-right = CER | |
| 56 | 61 | code.stack-pane = STP | |
| 57 | 62 | code.toggle-stack = TST | |
| 58 | 63 | code.close-pane = CLP | |
| @@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b | |||
| 96 | 101 | ||
| 97 | 102 | Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config. | |
| 98 | 103 | ||
| 104 | + | `alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev. | |
| 105 | + | ||
| 106 | + | ## Pane consume or expel | |
| 107 | + | ||
| 108 | + | The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`. | |
| 109 | + | ||
| 110 | + | A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported. | |
| 111 | + | ||
| 99 | 112 | ## Navigation overflow | |
| 100 | 113 | ||
| 101 | 114 | Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination. | |
| @@ -116,13 +129,19 @@ copy.word-separators = " -_@" | |||
| 116 | 129 | ||
| 117 | 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 | 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 | 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 | 146 | ## Bar anchors | |
| 128 | 147 | ||
M internal/app/cmd_test.go +8 -3
| @@ -26,6 +26,8 @@ func TestParseCmdArgs(t *testing.T) { | |||
| 26 | 26 | {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false}, | |
| 27 | 27 | {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false}, | |
| 28 | 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 | 31 | {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true}, | |
| 30 | 32 | {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false}, | |
| 31 | 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 | 96 | if called { | |
| 95 | 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 | 100 | if !strings.Contains(out.String(), want) { | |
| 99 | 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) { | |
| 177 | - | for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} { | |
| 178 | + | func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) { | |
| 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 | 183 | t.Run(slug, func(t *testing.T) { | |
| 179 | 184 | invocation, err := parseArgs([]string{"cmd", "--self", slug}) | |
| 180 | 185 | require.NoError(t, err) | |
M internal/usecase/command/command.go +4 -0
| @@ -44,6 +44,8 @@ type Context interface { | |||
| 44 | 44 | SplitLeft() error | |
| 45 | 45 | SplitUp() error | |
| 46 | 46 | SplitDown() error | |
| 47 | + | ConsumeOrExpelPaneLeft() error | |
| 48 | + | ConsumeOrExpelPaneRight() error | |
| 47 | 49 | StackPane() error | |
| 48 | 50 | ToggleStack() error | |
| 49 | 51 | ToggleFloatingPane() error | |
| @@ -131,6 +133,8 @@ type ControlContext interface { | |||
| 131 | 133 | SplitLeft() error | |
| 132 | 134 | SplitUp() error | |
| 133 | 135 | SplitDown() error | |
| 136 | + | ConsumeOrExpelPaneLeft() error | |
| 137 | + | ConsumeOrExpelPaneRight() error | |
| 134 | 138 | StackPane() error | |
| 135 | 139 | ToggleStack() error | |
| 136 | 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 | 167 | return _c | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 170 | + | // ConsumeOrExpelPaneLeft provides a mock function for the type MockContext | |
| 171 | + | func (_mock *MockContext) ConsumeOrExpelPaneLeft() error { | |
| 172 | + | ret := _mock.Called() | |
| 173 | + | ||
| 174 | + | if len(ret) == 0 { | |
| 175 | + | panic("no return value specified for ConsumeOrExpelPaneLeft") | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | var r0 error | |
| 179 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 180 | + | r0 = returnFunc() | |
| 181 | + | } else { | |
| 182 | + | r0 = ret.Error(0) | |
| 183 | + | } | |
| 184 | + | return r0 | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // MockContext_ConsumeOrExpelPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneLeft' | |
| 188 | + | type MockContext_ConsumeOrExpelPaneLeft_Call struct { | |
| 189 | + | *mock.Call | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // ConsumeOrExpelPaneLeft is a helper method to define mock.On call | |
| 193 | + | func (_e *MockContext_Expecter) ConsumeOrExpelPaneLeft() *MockContext_ConsumeOrExpelPaneLeft_Call { | |
| 194 | + | return &MockContext_ConsumeOrExpelPaneLeft_Call{Call: _e.mock.On("ConsumeOrExpelPaneLeft")} | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneLeft_Call { | |
| 198 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 199 | + | run() | |
| 200 | + | }) | |
| 201 | + | return _c | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Return(err error) *MockContext_ConsumeOrExpelPaneLeft_Call { | |
| 205 | + | _c.Call.Return(err) | |
| 206 | + | return _c | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneLeft_Call { | |
| 210 | + | _c.Call.Return(run) | |
| 211 | + | return _c | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | // ConsumeOrExpelPaneRight provides a mock function for the type MockContext | |
| 215 | + | func (_mock *MockContext) ConsumeOrExpelPaneRight() error { | |
| 216 | + | ret := _mock.Called() | |
| 217 | + | ||
| 218 | + | if len(ret) == 0 { | |
| 219 | + | panic("no return value specified for ConsumeOrExpelPaneRight") | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | var r0 error | |
| 223 | + | if returnFunc, ok := ret.Get(0).(func() error); ok { | |
| 224 | + | r0 = returnFunc() | |
| 225 | + | } else { | |
| 226 | + | r0 = ret.Error(0) | |
| 227 | + | } | |
| 228 | + | return r0 | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // MockContext_ConsumeOrExpelPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneRight' | |
| 232 | + | type MockContext_ConsumeOrExpelPaneRight_Call struct { | |
| 233 | + | *mock.Call | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | // ConsumeOrExpelPaneRight is a helper method to define mock.On call | |
| 237 | + | func (_e *MockContext_Expecter) ConsumeOrExpelPaneRight() *MockContext_ConsumeOrExpelPaneRight_Call { | |
| 238 | + | return &MockContext_ConsumeOrExpelPaneRight_Call{Call: _e.mock.On("ConsumeOrExpelPaneRight")} | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneRight_Call { | |
| 242 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 243 | + | run() | |
| 244 | + | }) | |
| 245 | + | return _c | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Return(err error) *MockContext_ConsumeOrExpelPaneRight_Call { | |
| 249 | + | _c.Call.Return(err) | |
| 250 | + | return _c | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneRight_Call { | |
| 254 | + | _c.Call.Return(run) | |
| 255 | + | return _c | |
| 256 | + | } | |
| 257 | + | ||
| 170 | 258 | // CreateSession provides a mock function for the type MockContext | |
| 171 | 259 | func (_mock *MockContext) CreateSession() error { | |
| 172 | 260 | ret := _mock.Called() | |
M internal/usecase/command/registry.go +2 -0
| @@ -12,6 +12,8 @@ func Registry() []Command { | |||
| 12 | 12 | paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }), | |
| 13 | 13 | paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }), | |
| 14 | 14 | paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }), | |
| 15 | + | paletteControl("consume-or-expel-pane-left", "CEL", "Consume or expel pane left", "Move the focused pane into or out of the column on the left", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneLeft() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneLeft() }), | |
| 16 | + | paletteControl("consume-or-expel-pane-right", "CER", "Consume or expel pane right", "Move the focused pane into or out of the column on the right", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneRight() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneRight() }), | |
| 15 | 17 | paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }), | |
| 16 | 18 | paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }), | |
| 17 | 19 | paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }), | |
M internal/usecase/command/registry_test.go +11 -2
| @@ -9,7 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 11 | 11 | commands := PaletteRegistry() | |
| 12 | - | wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "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 | 14 | if len(commands) != len(wantCodes) { | |
| 15 | 15 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -115,6 +115,8 @@ func TestBySlugExactMatch(t *testing.T) { | |||
| 115 | 115 | ok bool | |
| 116 | 116 | }{ | |
| 117 | 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 | 120 | {slug: "SPLIT-RIGHT", ok: false}, | |
| 119 | 121 | {slug: "toast", ok: true}, | |
| 120 | 122 | {slug: "list-panes", ok: true}, | |
| @@ -132,7 +134,8 @@ func TestBySlugExactMatch(t *testing.T) { | |||
| 132 | 134 | func TestRegistryControlMetadata(t *testing.T) { | |
| 133 | 135 | scriptable := map[string]TargetKind{ | |
| 134 | 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 | 139 | "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane, | |
| 137 | 140 | "focus-pane-up": TargetPane, "focus-pane-down": TargetPane, | |
| 138 | 141 | "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession, | |
| @@ -217,6 +220,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||
| 217 | 220 | }{ | |
| 218 | 221 | {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"}, | |
| 219 | 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 | 225 | {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"}, | |
| 221 | 226 | {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"}, | |
| 222 | 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 | 272 | func (s *controlSpy) SplitLeft() error { return s.record("split-left") } | |
| 268 | 273 | func (s *controlSpy) SplitUp() error { return s.record("split-up") } | |
| 269 | 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 | 277 | func (s *controlSpy) StackPane() error { return s.record("stack-pane") } | |
| 271 | 278 | func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") } | |
| 272 | 279 | func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") } | |
| @@ -360,6 +367,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 360 | 367 | {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }}, | |
| 361 | 368 | {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }}, | |
| 362 | 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 | 372 | {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }}, | |
| 364 | 373 | {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }}, | |
| 365 | 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 | 226 | daemonActionRenameTab | |
| 227 | 227 | daemonActionResizePane | |
| 228 | 228 | daemonActionEqualizePanes | |
| 229 | + | daemonActionConsumeOrExpelPane | |
| 229 | 230 | ) | |
| 230 | 231 | ||
| 231 | 232 | type daemonActionTarget struct { | |
| @@ -309,6 +310,8 @@ func (a daemonActions) Run(request daemonActionRequest) error { | |||
| 309 | 310 | return a.d.resizePane(target, request.axis, request.delta) | |
| 310 | 311 | case daemonActionEqualizePanes: | |
| 311 | 312 | return a.d.equalizePanes(target) | |
| 313 | + | case daemonActionConsumeOrExpelPane: | |
| 314 | + | return a.d.consumeOrExpelPane(target, request.direction) | |
| 312 | 315 | default: | |
| 313 | 316 | return errors.New("daemon: unknown action") | |
| 314 | 317 | } | |
| @@ -365,13 +368,19 @@ func (e controlExec) runAction(request daemonActionRequest) error { | |||
| 365 | 368 | runner = daemonActions{d: e.d} | |
| 366 | 369 | } | |
| 367 | 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 | 378 | e.sess.mu.Lock() | |
| 370 | 379 | ac := e.sess.client | |
| 371 | 380 | e.sess.mu.Unlock() | |
| 372 | 381 | finishDaemonActionForClient(e.d, request, ac, "control.go") | |
| 373 | 382 | } | |
| 374 | - | return err | |
| 383 | + | return nil | |
| 375 | 384 | } | |
| 376 | 385 | ||
| 377 | 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 | 474 | func (e controlExec) SplitLeft() error { return e.split(layout.Left) } | |
| 466 | 475 | func (e controlExec) SplitUp() error { return e.split(layout.Up) } | |
| 467 | 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 | 486 | func (e controlExec) StackPane() error { | |
| 469 | 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 | 18 | func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) { | |
| 19 | - | d := newTestDaemon(t, nil, stubClock{}) | |
| 20 | - | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 21 | - | tb := sess.activeTab() | |
| 22 | - | tb.mu.Lock() | |
| 23 | - | pane := tb.focusedPane() | |
| 24 | - | tb.mu.Unlock() | |
| 25 | - | spy := &actionRunnerSpy{} | |
| 26 | - | ||
| 27 | - | require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight()) | |
| 28 | - | require.NoError(t, (controlExec{ | |
| 29 | - | d: d, sess: sess, tab: tb, actions: spy, | |
| 30 | - | target: daemonActionTarget{session: sess, tab: tb, pane: pane}, | |
| 31 | - | }).SplitRight()) | |
| 32 | - | ||
| 33 | - | require.Len(t, spy.requests, 2) | |
| 34 | - | for _, request := range spy.requests { | |
| 35 | - | require.Equal(t, daemonActionSplitPane, request.kind) | |
| 36 | - | require.Same(t, sess, request.target.session) | |
| 37 | - | require.Same(t, tb, request.target.tab) | |
| 38 | - | require.Same(t, pane, request.target.pane) | |
| 39 | - | require.Equal(t, layout.Right, request.direction) | |
| 19 | + | tests := []struct { | |
| 20 | + | name string | |
| 21 | + | palette func(paletteExec) error | |
| 22 | + | control func(controlExec) error | |
| 23 | + | kind daemonActionKind | |
| 24 | + | direction layout.Direction | |
| 25 | + | }{ | |
| 26 | + | {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right}, | |
| 27 | + | {name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left}, | |
| 28 | + | {name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right}, | |
| 40 | 29 | } | |
| 30 | + | ||
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |
| 33 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 34 | + | sess := addControlSession(d, "work", "t_work", "p_work") | |
| 35 | + | tb := sess.activeTab() | |
| 36 | + | tb.mu.Lock() | |
| 37 | + | pane := tb.focusedPane() | |
| 38 | + | tb.mu.Unlock() | |
| 39 | + | spy := &actionRunnerSpy{} | |
| 40 | + | target := daemonActionTarget{session: sess, tab: tb, pane: pane} | |
| 41 | + | ||
| 42 | + | require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy})) | |
| 43 | + | require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target})) | |
| 44 | + | ||
| 45 | + | require.Len(t, spy.requests, 2) | |
| 46 | + | for _, request := range spy.requests { | |
| 47 | + | require.Equal(t, tt.kind, request.kind) | |
| 48 | + | require.Same(t, sess, request.target.session) | |
| 49 | + | require.Same(t, tb, request.target.tab) | |
| 50 | + | require.Same(t, pane, request.target.pane) | |
| 51 | + | require.Equal(t, tt.direction, request.direction) | |
| 52 | + | } | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) { | |
| 58 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 59 | + | h.tab.mu.Lock() | |
| 60 | + | h.tab.tree.Focus = "pane-1" | |
| 61 | + | beforeGeneration := h.tab.layoutGeneration | |
| 62 | + | tabStableID := h.tab.stableID | |
| 63 | + | targetStableID := h.panes["pane-3"].stableID | |
| 64 | + | h.tab.mu.Unlock() | |
| 65 | + | ||
| 66 | + | result := sendCommand(t, h.daemon, ports.CommandRequest{ | |
| 67 | + | Slug: "consume-or-expel-pane-left", | |
| 68 | + | Self: true, | |
| 69 | + | TargetSession: "work", | |
| 70 | + | TargetTab: tabStableID, | |
| 71 | + | TargetPane: targetStableID, | |
| 72 | + | }) | |
| 73 | + | ||
| 74 | + | require.True(t, result.OK, result.Text) | |
| 75 | + | require.Empty(t, result.Output) | |
| 76 | + | h.session.mu.Lock() | |
| 77 | + | require.Zero(t, h.session.active, "--self must not change the active tab") | |
| 78 | + | h.session.mu.Unlock() | |
| 79 | + | h.tab.mu.Lock() | |
| 80 | + | require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused") | |
| 81 | + | require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration) | |
| 82 | + | require.Len(t, h.tab.tree.Root.Children, 2) | |
| 83 | + | h.tab.mu.Unlock() | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) { | |
| 87 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree()) | |
| 88 | + | ac := &attachedClient{} | |
| 89 | + | ac.setSession(h.session) | |
| 90 | + | h.session.mu.Lock() | |
| 91 | + | h.session.client = ac | |
| 92 | + | h.session.mu.Unlock() | |
| 93 | + | invalidations := make(chan renderInvalidation, 1) | |
| 94 | + | rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }}) | |
| 95 | + | rc.attach(ac) | |
| 96 | + | h.session.installRenderCoordinator(rc) | |
| 97 | + | before := h.snapshot() | |
| 98 | + | ||
| 99 | + | result := sendCommand(t, h.daemon, ports.CommandRequest{ | |
| 100 | + | Slug: "consume-or-expel-pane-left", | |
| 101 | + | Self: true, | |
| 102 | + | TargetSession: "work", | |
| 103 | + | TargetTab: h.tab.stableID, | |
| 104 | + | TargetPane: h.panes["pane-1"].stableID, | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | require.True(t, result.OK, result.Text) | |
| 108 | + | require.Empty(t, result.Output) | |
| 109 | + | require.Equal(t, before, h.snapshot()) | |
| 110 | + | require.Empty(t, h.daemon.notices.history()) | |
| 111 | + | requireNoInvalidation(t, invalidations) | |
| 41 | 112 | } | |
| 42 | 113 | ||
| 43 | 114 | type actionRunnerSpy struct { | |
M internal/usecase/daemon/copymode_test.go +1 -1
| @@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil } | |||
| 100 | 100 | func TestCopySearchModalGeometry(t *testing.T) { | |
| 101 | 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 | 104 | require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor) | |
| 105 | 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 | 144 | p.mu.Lock() | |
| 145 | 145 | geometry := p.committedFloatingGeometryLocked(desired) | |
| 146 | 146 | p.mu.Unlock() | |
| 147 | - | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |
| 147 | + | if !geometry.committable() { | |
| 148 | 148 | return nil, floatingGeometry{}, false | |
| 149 | 149 | } | |
| 150 | 150 | return p, geometry, true | |
| @@ -345,11 +345,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 345 | 345 | sess.mu.Unlock() | |
| 346 | 346 | tb.mu.Lock() | |
| 347 | 347 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg) | |
| 348 | - | size := rectSize(geometry.Inner) | |
| 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 | - | } | |
| 348 | + | size := rectSize(geometry.ptyRect()) | |
| 353 | 349 | focused := tb.focusedPane() | |
| 354 | 350 | tabStableID, tabCtx := tb.stableID, tb.ctx | |
| 355 | 351 | tb.mu.Unlock() | |
| @@ -419,7 +415,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 419 | 415 | return | |
| 420 | 416 | } | |
| 421 | 417 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | |
| 422 | - | p.rect = spec.geometry.Inner | |
| 418 | + | p.rect = spec.geometry.ptyRect() | |
| 423 | 419 | p.popupGeometry = spec.geometry | |
| 424 | 420 | p.title.displayFallback = spec.fallback | |
| 425 | 421 | if !lifetime.publish(p) { | |
M internal/usecase/daemon/floating_render.go +191 -27
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | |
| 14 | 14 | // must not translate a composed or cached value again. | |
| 15 | 15 | type floatingGeometry struct { | |
| 16 | + | Mode ui.PresentationMode | |
| 16 | 17 | Bounds domain.Rect | |
| 17 | 18 | Inner domain.Rect | |
| 18 | 19 | } | |
| @@ -21,6 +22,22 @@ func (g floatingGeometry) valid() bool { | |||
| 21 | 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 | 41 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |
| 25 | 42 | g.Bounds.X += dx | |
| 26 | 43 | g.Bounds.Y += dy | |
| @@ -33,7 +50,7 @@ func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |||
| 33 | 50 | // last committed. Newly constructed test panes fall back to the requested | |
| 34 | 51 | // geometry until their first resize. The caller holds p.mu. | |
| 35 | 52 | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | |
| 36 | - | if p.popupGeometry.valid() { | |
| 53 | + | if p.popupGeometry.committable() { | |
| 37 | 54 | return p.popupGeometry | |
| 38 | 55 | } | |
| 39 | 56 | return fallback | |
| @@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry | |||
| 58 | 75 | return axis | |
| 59 | 76 | } | |
| 60 | 77 | ||
| 61 | - | // calculateContentFloatingGeometry returns origin-zero, tab-content-relative | |
| 62 | - | // coordinates for a percentage-sized popup. Launch sizing derives from Inner | |
| 63 | - | // too, so rendering and PTY geometry share the same per-axis percentage and | |
| 64 | - | // tiny-border rules. | |
| 65 | - | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |
| 78 | + | // calculatePreferredFloatingGeometry retains the canonical percentage and | |
| 79 | + | // tiny-axis rules used by wide floating terminals. | |
| 80 | + | func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |
| 66 | 81 | if !content.Valid() { | |
| 67 | 82 | return floatingGeometry{} | |
| 68 | 83 | } | |
| @@ -75,6 +90,7 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||
| 75 | 90 | Height: y.BoundsSize, | |
| 76 | 91 | } | |
| 77 | 92 | return floatingGeometry{ | |
| 93 | + | Mode: ui.PresentationFloating, | |
| 78 | 94 | Bounds: bounds, | |
| 79 | 95 | Inner: domain.Rect{ | |
| 80 | 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 | 126 | type floatingComposeInput struct { | |
| 89 | 127 | baseFrame renderer.Frame | |
| 90 | 128 | baseDamage []renderer.Damage | |
| 91 | 129 | floating capturedFloatingRenderState | |
| 92 | 130 | content domain.Rect | |
| 93 | - | layout capturedTabLayout | |
| 94 | 131 | theme themeui.Theme | |
| 95 | 132 | borderMuted renderer.Style | |
| 96 | 133 | borderActive renderer.Style | |
| @@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 103 | 140 | baseDamage := input.baseDamage | |
| 104 | 141 | floating := input.floating | |
| 105 | 142 | content := input.content | |
| 106 | - | layoutSnap := input.layout | |
| 107 | 143 | theme := input.theme | |
| 108 | 144 | borderMuted := input.borderMuted | |
| 109 | 145 | borderActive := input.borderActive | |
| @@ -111,10 +147,9 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 111 | 147 | full := input.full | |
| 112 | 148 | ||
| 113 | 149 | frame := base.Clone() | |
| 114 | - | layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | |
| 115 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme) | |
| 150 | + | applyOverlayBackdrop(frame, theme) | |
| 116 | 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 | 153 | damage := append([]renderer.Damage(nil), baseDamage...) | |
| 119 | 154 | for _, d := range floating.pane.damage { | |
| 120 | 155 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| @@ -125,39 +160,168 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 125 | 160 | if floating.focused { | |
| 126 | 161 | border = borderActive | |
| 127 | 162 | } | |
| 128 | - | drawFloatingBorder(frame, geometry.Bounds, floating.title, border) | |
| 163 | + | drawFloatingBorder(frame, geometry, floating.title, border) | |
| 129 | 164 | if full || popupChanged { | |
| 130 | 165 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 131 | 166 | } | |
| 132 | - | if titleChanged && 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}) | |
| 167 | + | hasTitleBorder := geometry.Bounds.Height >= 3 | |
| 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 | 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 | 238 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 140 | 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 | 246 | // Each axis omits its borders independently for tiny popups, matching | |
| 143 | 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 | 250 | if bounds.Height >= 3 { | |
| 145 | - | for x := bounds.X; x < bounds.X+bounds.Width; x++ { | |
| 146 | - | frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style}) | |
| 147 | - | frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style}) | |
| 251 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style) | |
| 252 | + | if hasBottom { | |
| 253 | + | drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style) | |
| 148 | 254 | } | |
| 149 | 255 | } | |
| 150 | 256 | if bounds.Width >= 3 { | |
| 151 | - | for y := bounds.Y; y < bounds.Y+bounds.Height; y++ { | |
| 152 | - | frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style}) | |
| 153 | - | frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style}) | |
| 257 | + | drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style) | |
| 258 | + | if hasRight { | |
| 259 | + | drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style) | |
| 154 | 260 | } | |
| 155 | 261 | } | |
| 156 | - | if bounds.Width >= 3 && bounds.Height >= 3 { | |
| 157 | - | frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style}) | |
| 158 | - | frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style}) | |
| 159 | - | frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style}) | |
| 160 | - | frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style}) | |
| 161 | - | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | |
| 262 | + | if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom { | |
| 263 | + | setFloatingCell(frame, bounds.X, bounds.Y, '┌', style) | |
| 264 | + | setFloatingCell(frame, right, bounds.Y, '┐', style) | |
| 265 | + | setFloatingCell(frame, bounds.X, bottom, '└', style) | |
| 266 | + | setFloatingCell(frame, right, bottom, '┘', 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 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | 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 | 41 | func TestFloatingGeometryTranslate(t *testing.T) { | |
| 41 | 42 | geometry := floatingGeometry{ | |
| 43 | + | Mode: ui.PresentationDrawer, | |
| 42 | 44 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| 43 | 45 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | 48 | translated := geometry.translate(11, 13) | |
| 47 | 49 | require.Equal(t, floatingGeometry{ | |
| 50 | + | Mode: ui.PresentationDrawer, | |
| 48 | 51 | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | |
| 49 | 52 | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | |
| 50 | 53 | }, translated) | |
| 51 | 54 | require.Equal(t, floatingGeometry{ | |
| 55 | + | Mode: ui.PresentationDrawer, | |
| 52 | 56 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| 53 | 57 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |
| 54 | 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 | 103 | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |
| 58 | 104 | const frameX, frameY = 7, 3 | |
| 59 | 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 | 143 | tests := []struct { | |
| 98 | 144 | name string | |
| 99 | 145 | content domain.Size | |
| 100 | 146 | cfg domain.FloatingConfig | |
| 101 | 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}}}, | |
| 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}}}, | |
| 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}}}, | |
| 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}}}, | |
| 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}}}, | |
| 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}}}, | |
| 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}}}, | |
| 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}}}, | |
| 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}}}, | |
| 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 | 155 | for _, tt := range tests { | |
| 110 | 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 | 213 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |
| 117 | 214 | tests := []struct { | |
| 118 | 215 | name string | |
| @@ -133,9 +230,9 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |||
| 133 | 230 | } | |
| 134 | 231 | ||
| 135 | 232 | func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |
| 136 | - | base := renderer.NewFrame(40, 12) | |
| 233 | + | base := renderer.NewFrame(80, 12) | |
| 137 | 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 | 236 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | |
| 140 | 237 | paneFrame := renderer.NewFrame(6, 3) | |
| 141 | 238 | paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |
| @@ -181,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi | |||
| 181 | 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 | 413 | func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) { | |
| 185 | 414 | base := renderer.NewFrame(20, 9) | |
| 186 | 415 | content := domain.Rect{Y: 1, Width: 20, Height: 7} | |
| @@ -401,12 +630,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 401 | 630 | ||
| 402 | 631 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |
| 403 | 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 | 634 | for _, cell := range frame.Cells { | |
| 406 | 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 | 681 | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | |
| 411 | 682 | base := renderer.NewFrame(80, 24) | |
| 412 | 683 | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
| @@ -574,13 +845,13 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 574 | 845 | require.Empty(t, pty.sizes()) | |
| 575 | 846 | ||
| 576 | 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 | 849 | require.NoError(t, d.toggleFloating(sess, nil)) | |
| 579 | 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 | 852 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |
| 582 | 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 | 856 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |
| 586 | 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 | 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 | 276 | func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) { | |
| 212 | 277 | floatingPTY := portsmocks.NewMockPTY(t) | |
| 213 | 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 | 94 | tb.mu.Lock() | |
| 95 | - | contentRow := ev.Row - 1 | |
| 95 | + | contentRow := ev.Row - clientTopBarRows | |
| 96 | 96 | floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig()) | |
| 97 | 97 | if floatingVisible { | |
| 98 | 98 | if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) { | |
| @@ -201,7 +201,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane, | |||
| 201 | 201 | if translated { | |
| 202 | 202 | d.writeToPane(sess, p, ev.Raw) | |
| 203 | 203 | } else { | |
| 204 | - | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1)) | |
| 204 | + | d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows)) | |
| 205 | 205 | } | |
| 206 | 206 | return | |
| 207 | 207 | } | |
| @@ -359,14 +359,21 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 359 | 359 | if owned { | |
| 360 | 360 | defer effect.End() | |
| 361 | 361 | } | |
| 362 | - | runResizeAction := func(request daemonActionRequest) { | |
| 362 | + | runAction := func(request daemonActionRequest) { | |
| 363 | 363 | request.target = resolveDaemonActionTarget(sess) | |
| 364 | 364 | runner := h.actions | |
| 365 | 365 | if runner == nil { | |
| 366 | 366 | runner = daemonActions{d: h.d} | |
| 367 | 367 | } | |
| 368 | 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 | 377 | return | |
| 371 | 378 | } | |
| 372 | 379 | if h.actions == nil { | |
| @@ -405,15 +412,19 @@ func (h daemonKeyHandler) Action(action keys.Action) { | |||
| 405 | 412 | h.d.reportError(sess, err) | |
| 406 | 413 | } | |
| 407 | 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 | 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 | 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 | 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 | 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 | 428 | case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3, | |
| 418 | 429 | keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6, | |
| 419 | 430 | keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9: | |
M internal/usecase/daemon/input_test.go +177 -2
| @@ -21,11 +21,114 @@ import ( | |||
| 21 | 21 | "github.com/bnema/vev/internal/usecase/layout" | |
| 22 | 22 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 23 | 23 | "github.com/bnema/vev/internal/usecase/picker" | |
| 24 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 24 | 25 | "github.com/bnema/vev/pkg/vt" | |
| 25 | 26 | ) | |
| 26 | 27 | ||
| 27 | 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 | 132 | func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) { | |
| 30 | 133 | tests := []struct { | |
| 31 | 134 | name string | |
| @@ -602,7 +705,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) { | |||
| 602 | 705 | floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h")) | |
| 603 | 706 | installTestFloating(tb, floating, true) | |
| 604 | 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 | 710 | d.handleInput(sess, ac, raw) | |
| 608 | 711 | } | |
| @@ -620,12 +723,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) { | |||
| 620 | 723 | installTestFloating(tb, floating, true) | |
| 621 | 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 | 727 | outside := []byte("\x1b[<0;1;2M") | |
| 625 | 728 | d.handleInput(sess, ac, border) | |
| 626 | 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 | 804 | func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) { | |
| 630 | 805 | writes := make(chan []byte, 1) | |
| 631 | 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 | 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) | |
| 12 | - | modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height}) | |
| 13 | - | for y := range min(inner.Height, modalFrame.Height) { | |
| 14 | - | for x := range min(inner.Width, modalFrame.Width) { | |
| 15 | - | base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y)) | |
| 16 | - | } | |
| 17 | - | } | |
| 11 | + | presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height}) | |
| 12 | + | modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase) | |
| 13 | + | modalFrame := renderInner(rectSize(presentation.Inner)) | |
| 14 | + | copyModalInner(base, presentation.Inner, modalFrame) | |
| 18 | 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 | 187 | func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event { | |
| 188 | 188 | ev.Col -= colOffset | |
| 189 | 189 | ev.Row -= contentYOffset | |
| 190 | - | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1) | |
| 190 | + | ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows) | |
| 191 | 191 | return ev | |
| 192 | 192 | } | |
| 193 | 193 | ||
M internal/usecase/daemon/overlay_backdrop.go +5 -27
| @@ -6,33 +6,11 @@ import ( | |||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | // overlayBackdrop controls optional dimming of pane content behind an overlay. | |
| 10 | - | // Chrome (bars, dividers, and pane titles) is deliberately not part of the | |
| 11 | - | // backdrop. | |
| 12 | - | type overlayBackdrop struct { | |
| 13 | - | DimPaneContents bool | |
| 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 | - | } | |
| 9 | + | // applyOverlayBackdrop dims every cell already composed into the complete | |
| 10 | + | // renderer frame. Modal composition calls it immediately before painting each | |
| 11 | + | // active layer so bars, panes, toasts, and lower overlays share one backdrop. | |
| 12 | + | func applyOverlayBackdrop(frame renderer.Frame, theme themeui.Theme) { | |
| 13 | + | dimFrameRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, theme) | |
| 36 | 14 | } | |
| 37 | 15 | ||
| 38 | 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 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 11 | ) | |
| @@ -15,82 +14,39 @@ func backdropTheme() themeui.Theme { | |||
| 15 | 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) { | |
| 19 | - | frame := renderer.NewFrame(8, 6) | |
| 17 | + | func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { | |
| 18 | + | frame := renderer.NewFrame(4, 3) | |
| 20 | 19 | original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()} | |
| 21 | - | frame.Set(2, 2, original) | |
| 22 | - | frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | |
| 23 | - | frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title | |
| 24 | - | frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar | |
| 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 | - | }, | |
| 20 | + | for y := range frame.Height { | |
| 21 | + | for x := range frame.Width { | |
| 22 | + | frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*frame.Width + x), Style: renderer.DefaultStyle()}) | |
| 23 | + | } | |
| 63 | 24 | } | |
| 64 | - | ||
| 65 | - | for _, tt := range tests { | |
| 66 | - | t.Run(tt.name, func(t *testing.T) { | |
| 67 | - | frame := renderer.NewFrame(4, 4) | |
| 68 | - | original := renderer.DefaultStyle() | |
| 69 | - | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}} | |
| 70 | - | ||
| 71 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme()) | |
| 72 | - | ||
| 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 | - | }) | |
| 25 | + | frame.Set(1, 1, original) | |
| 26 | + | frame.Set(2, 1, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | |
| 27 | + | ||
| 28 | + | applyOverlayBackdrop(frame, backdropTheme()) | |
| 29 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | |
| 30 | + | for y := range frame.Height { | |
| 31 | + | for x := range frame.Width { | |
| 32 | + | require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y) | |
| 33 | + | } | |
| 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 | 41 | frame := renderer.NewFrame(4, 4) | |
| 85 | - | frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 86 | 42 | before := frame.Clone() | |
| 87 | - | area := domain.Rect{Y: 1, Width: 4, Height: 2} | |
| 88 | - | (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | |
| 89 | - | require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default") | |
| 43 | + | dimFrameRect(frame, domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, backdropTheme()) | |
| 44 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | |
| 90 | 45 | ||
| 91 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | |
| 92 | - | require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area") | |
| 93 | - | require.True(t, frame.At(3, 2).Style.HasForegroundRGB) | |
| 94 | - | require.False(t, frame.At(0, 0).Style.HasForegroundRGB) | |
| 95 | - | require.False(t, frame.At(0, 3).Style.HasForegroundRGB) | |
| 46 | + | for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} { | |
| 47 | + | require.Equal(t, dimmed, frame.At(point[0], point[1]).Style) | |
| 48 | + | } | |
| 49 | + | for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} { | |
| 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 | 322 | return | |
| 323 | 323 | } | |
| 324 | 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 | 326 | sess.dispatchMu.Unlock() | |
| 327 | 327 | if roleHandoff { | |
| 328 | 328 | if current := ac.currentSession(); current != nil { | |
| @@ -393,12 +393,13 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin | |||
| 393 | 393 | } | |
| 394 | 394 | ||
| 395 | 395 | type paletteExec struct { | |
| 396 | - | d *Daemon | |
| 397 | - | sess *session | |
| 398 | - | ac *attachedClient | |
| 399 | - | recent []recentSession | |
| 400 | - | actions daemonActionRunner | |
| 401 | - | effect *roleEffectTicket | |
| 396 | + | d *Daemon | |
| 397 | + | sess *session | |
| 398 | + | ac *attachedClient | |
| 399 | + | recent []recentSession | |
| 400 | + | actions daemonActionRunner | |
| 401 | + | effect *roleEffectTicket | |
| 402 | + | redrawClosedPalette bool | |
| 402 | 403 | } | |
| 403 | 404 | ||
| 404 | 405 | func (e paletteExec) runAction(request daemonActionRequest) error { | |
| @@ -410,10 +411,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error { | |||
| 410 | 411 | runner = daemonActions{d: e.d} | |
| 411 | 412 | } | |
| 412 | 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 | 424 | finishDaemonActionForClient(e.d, request, e.ac, "palette.go") | |
| 415 | 425 | } | |
| 416 | - | return err | |
| 426 | + | return nil | |
| 417 | 427 | } | |
| 418 | 428 | ||
| 419 | 429 | func (e paletteExec) CreateTab() error { | |
| @@ -442,6 +452,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) } | |||
| 442 | 452 | func (e paletteExec) SplitLeft() error { return e.split(layout.Left) } | |
| 443 | 453 | func (e paletteExec) SplitUp() error { return e.split(layout.Up) } | |
| 444 | 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 | 465 | func (e paletteExec) StackPane() error { | |
| 447 | 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 | 521 | d.handleInput(sess, ac, []byte("\x1b ")) | |
| 522 | 522 | awaitFrame(t, sends, ports.MsgOutput) | |
| 523 | - | go d.handleInput(sess, ac, []byte("JRS 1\r")) | |
| 524 | - | <-validated | |
| 523 | + | inputHandled := make(chan struct{}) | |
| 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 | 529 | d.mu.Lock() | |
| 526 | 530 | delete(d.sessions, target.id) | |
| 527 | 531 | d.mu.Unlock() | |
| 528 | 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 | 539 | require.True(t, ac.overlays.paletteActive()) | |
| 532 | 540 | ac.overlays.paletteMu.Lock() | |
| 533 | 541 | require.Equal(t, "JRS 1", ac.overlays.palette.Query()) | |
| @@ -734,7 +742,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 734 | 742 | for i, cmd := range commands { | |
| 735 | 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 | 748 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| @@ -852,10 +860,12 @@ func TestPaletteModalGeometry(t *testing.T) { | |||
| 852 | 860 | ||
| 853 | 861 | auto := domain.PaletteConfig{} | |
| 854 | 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 | 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 | 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 | 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 | 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 | 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 | 879 | for _, tt := range tests { | |
| 870 | 880 | t.Run(tt.name, func(t *testing.T) { | |
| 871 | 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 | 891 | require.Equal(t, " Commands ", modal.Title) | |
| 874 | 892 | require.Equal(t, 11, modal.FixedHeight) | |
| 875 | 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 | 918 | func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) { | |
| 881 | 919 | model := palette.New(nil) | |
| 882 | 920 | base := renderer.NewFrame(120, 40) | |
| @@ -902,7 +940,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) { | |||
| 902 | 940 | styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 903 | 941 | ||
| 904 | 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 | 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 | 64 | func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) { | |
| 65 | 65 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000}) | |
| 66 | 66 | ||
| 67 | - | markSnapshotDirty(fixture.sess) | |
| 68 | - | require.True(t, fixture.d.scheduleSnapshot(fixture.sess)) | |
| 69 | - | awaitSnapshotClean(t, fixture.sess) | |
| 67 | + | // This contract concerns capture and publication bytes, not queue latency. | |
| 68 | + | // Publish synchronously so the 10k-row setup cannot race the test deadline. | |
| 69 | + | publishBenchmarkSnapshot(t, fixture, 1) | |
| 70 | 70 | ||
| 71 | 71 | var copies atomic.Uint64 | |
| 72 | 72 | fixture.sess.snapshotMu.Lock() | |
| @@ -83,9 +83,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin | |||
| 83 | 83 | for i, r := range "changed" { | |
| 84 | 84 | require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i) | |
| 85 | 85 | } | |
| 86 | - | markSnapshotDirty(fixture.sess) | |
| 87 | - | require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess)) | |
| 88 | - | awaitSnapshotClean(t, fixture.sess) | |
| 86 | + | publishBenchmarkSnapshot(t, fixture, 2) | |
| 89 | 87 | ||
| 90 | 88 | metrics := fixture.snapshots.metrics() | |
| 91 | 89 | require.Equal(t, uint64(1), metrics.writes) | |
M internal/usecase/daemon/picker_test.go +1 -1
| @@ -1344,7 +1344,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) { | |||
| 1344 | 1344 | func TestPickerModalGeometry(t *testing.T) { | |
| 1345 | 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 | 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 | 20 | func TestPromptModalGeometry(t *testing.T) { | |
| 21 | 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 | 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 | 104 | type capturedModal struct { | |
| 105 | - | modal ui.Modal | |
| 106 | - | inner renderer.Frame | |
| 107 | - | focused bool | |
| 105 | + | active bool | |
| 106 | + | title string | |
| 107 | + | presentation ui.Presentation | |
| 108 | + | inner renderer.Frame | |
| 109 | + | focused bool | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 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 | 126 | damage = append(damage, d) | |
| 127 | 127 | } | |
| 128 | 128 | } | |
| 129 | - | // Keep the committed cache unadorned: floating composition clones this | |
| 130 | - | // base, so closing or moving a popup cannot retain dimmed/bordered cells. | |
| 129 | + | // Keep the committed cache unadorned. Toasts are composed first so floating | |
| 130 | + | // terminals and scoped modals dim them with the rest of the complete frame. | |
| 131 | 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 | 139 | if state.floating.visible { | |
| 133 | 140 | var floatingDamage []renderer.Damage | |
| 134 | 141 | frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ | |
| 135 | - | baseFrame: baseFrame, | |
| 142 | + | baseFrame: frame, | |
| 136 | 143 | baseDamage: damage, | |
| 137 | 144 | floating: state.floating, | |
| 138 | 145 | content: content, | |
| 139 | - | layout: state.layout, | |
| 140 | 146 | theme: state.theme, | |
| 141 | 147 | borderMuted: styles.BorderMuted, | |
| 142 | 148 | borderActive: styles.BorderActive, | |
| 143 | 149 | cache: in, | |
| 144 | - | full: full || state.overlays.active(), | |
| 150 | + | full: full || overlaysActive, | |
| 145 | 151 | }) | |
| 146 | 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 | 161 | if !full { | |
| 149 | - | if !sameCells(in.bars.top, frame.Row(0)) { | |
| 162 | + | if !sameCells(in.bars.top, baseFrame.Row(0)) { | |
| 150 | 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 | 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 | 170 | // toasts, so every render damages both the last and current toast coverage. | |
| 158 | 171 | // This restores cells exposed by dismissal and redraws a stable toast over | |
| 159 | 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 | 173 | if full || overlaysActive { | |
| 172 | 174 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 173 | 175 | } else { | |
| @@ -222,91 +224,111 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||
| 222 | 224 | } | |
| 223 | 225 | size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2} | |
| 224 | 226 | if snap.copySearchModel != nil { | |
| 225 | - | o.copySearch.modal = copySearchModal | |
| 226 | - | o.copySearch.focused = true | |
| 227 | - | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | |
| 227 | + | presentation := copySearchModal.Resolve(size) | |
| 228 | + | o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true} | |
| 229 | + | o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection}) | |
| 228 | 230 | } | |
| 229 | 231 | if snap.pickerActive && snap.pickerModel != nil { | |
| 230 | - | o.picker.modal = pickerModal | |
| 231 | - | o.picker.focused = true | |
| 232 | + | presentation := pickerModal.Resolve(size) | |
| 233 | + | o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true} | |
| 232 | 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 | 237 | if snap.noticesOverlayActive && snap.noticesOverlayModel != nil { | |
| 236 | - | o.noticesOverlay.modal = noticesModal | |
| 237 | - | o.noticesOverlay.focused = true | |
| 238 | + | presentation := noticesModal.Resolve(size) | |
| 239 | + | o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true} | |
| 238 | 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 | 243 | if snap.paletteActive && snap.paletteModel != nil { | |
| 242 | - | o.palette.modal = paletteModalFor(size, paletteCfg) | |
| 243 | - | o.palette.focused = true | |
| 244 | + | modal := paletteModalFor(size, paletteCfg) | |
| 245 | + | presentation := modal.Resolve(size) | |
| 246 | + | o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true} | |
| 244 | 247 | guidance := "" | |
| 245 | 248 | if snap.paletteHints != nil { | |
| 246 | 249 | guidance = snap.paletteHints.Feedback | |
| 247 | 250 | } | |
| 248 | 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 | 254 | if snap.promptActive && snap.promptModel != nil { | |
| 252 | - | o.prompt.modal = promptModalFor(snap.promptModel.Title()) | |
| 253 | - | o.prompt.focused = true | |
| 254 | - | o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive}) | |
| 255 | + | modal := promptModalFor(snap.promptModel.Title()) | |
| 256 | + | presentation := modal.Resolve(size) | |
| 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 | 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 | 264 | o := state.overlays | |
| 261 | - | if o.copyActive { | |
| 262 | - | target := domain.Rect{} | |
| 263 | - | if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) { | |
| 264 | - | target = state.floating.geometry.translate(content.X, content.Y).Inner | |
| 265 | + | if !o.copyActive { | |
| 266 | + | return frame, damage | |
| 267 | + | } | |
| 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 { | |
| 267 | - | id := o.copyPaneID | |
| 268 | - | if id == "" { | |
| 269 | - | id = state.layout.focus | |
| 270 | - | } | |
| 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 | + | for _, p := range state.panes { | |
| 278 | + | if p.id == id && !p.placement.Collapsed { | |
| 279 | + | target = p.placement.Content | |
| 280 | + | target.Y += content.Y | |
| 281 | + | break | |
| 277 | 282 | } | |
| 278 | 283 | } | |
| 279 | - | frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles) | |
| 280 | - | } | |
| 281 | - | layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid} | |
| 282 | - | if o.paletteActive && !state.floating.visible { | |
| 283 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme) | |
| 284 | - | } | |
| 285 | - | // This paint order intentionally differs from HandleInput's keyboard | |
| 286 | - | // priority (prompt > palette > picker > notices > copy, see | |
| 287 | - | // overlay_runtime.go), which paints the picker under notices instead of | |
| 288 | - | // over it. Currently unreachable: notices only opens via the palette, and | |
| 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 { | |
| 284 | + | } | |
| 285 | + | return composeCopyClientFrame(o.copyMode, target, frame, state.styles) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) { | |
| 289 | + | o := state.overlays | |
| 290 | + | // Paint in reverse keyboard priority so the same layer that owns input is | |
| 291 | + | // visually topmost: prompt > palette > picker > notices > copy search. | |
| 292 | + | for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} { | |
| 293 | + | if !modal.active { | |
| 295 | 294 | continue | |
| 296 | 295 | } | |
| 296 | + | applyOverlayBackdrop(frame, state.theme) | |
| 297 | 297 | border := state.styles.BorderMuted | |
| 298 | 298 | if modal.focused { | |
| 299 | 299 | border = state.styles.BorderActive | |
| 300 | 300 | } | |
| 301 | - | inner := modal.modal.Composite(frame, border, state.styles.PickerBase) | |
| 302 | - | for y := range min(inner.Height, modal.inner.Height) { | |
| 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 | - | } | |
| 301 | + | (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase) | |
| 302 | + | copyModalInner(frame, modal.presentation.Inner, modal.inner) | |
| 305 | 303 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 306 | 304 | } | |
| 307 | 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 | 332 | func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect { | |
| 311 | 333 | if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 { | |
| 312 | 334 | return nil | |
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -1
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 15 | "github.com/stretchr/testify/require" | |
| 15 | 16 | ) | |
| @@ -326,7 +327,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||
| 326 | 327 | reset: false, | |
| 327 | 328 | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | |
| 328 | 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 | 331 | bars: barState{topRight: title, bottomRight: title}, | |
| 331 | 332 | styles: resolveStyles(nil), | |
| 332 | 333 | } | |
M internal/usecase/daemon/render_pipeline_test.go +253 -7
| @@ -9,13 +9,20 @@ import ( | |||
| 9 | 9 | "path/filepath" | |
| 10 | 10 | "strings" | |
| 11 | 11 | "testing" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/vev/internal/domain" | |
| 14 | 15 | "github.com/bnema/vev/internal/ports" | |
| 15 | 16 | "github.com/bnema/vev/internal/testutil/replaytest" | |
| 17 | + | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 16 | 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 | 23 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 18 | 24 | "github.com/bnema/vev/internal/usecase/ui" | |
| 25 | + | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 19 | 26 | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 27 | "github.com/bnema/vev/pkg/vt" | |
| 21 | 28 | "github.com/stretchr/testify/require" | |
| @@ -220,21 +227,21 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) { | |||
| 220 | 227 | reset: true, | |
| 221 | 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 | 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 | 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 | 233 | cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, | |
| 227 | 234 | styles: resolveStyles(nil), | |
| 228 | 235 | } | |
| 229 | 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 | 238 | require.True(t, composed.cursor.hidden, "overlay owns cursor visibility") | |
| 232 | 239 | ||
| 233 | 240 | stream := newOutputStateStream() | |
| 234 | 241 | prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset) | |
| 235 | 242 | require.NoError(t, err) | |
| 236 | 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 | 245 | client := vt.NewScreen(composed.frame.Width, composed.frame.Height) | |
| 239 | 246 | client.Write(terminalBytes) | |
| 240 | 247 | require.Equal(t, frameRows(composed.frame), frameRows(client.Frame)) | |
| @@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||
| 298 | 305 | active := renderer.Style{Foreground: 3, Background: 4} | |
| 299 | 306 | interior := renderer.Style{Foreground: 5, Background: 6} | |
| 300 | 307 | modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"} | |
| 308 | + | presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8}) | |
| 301 | 309 | ||
| 302 | 310 | for _, tt := range []struct { | |
| 303 | 311 | name string | |
| @@ -310,16 +318,254 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) { | |||
| 310 | 318 | t.Run(tt.name, func(t *testing.T) { | |
| 311 | 319 | state := capturedRenderState{ | |
| 312 | 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{}) | |
| 316 | - | bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8}) | |
| 323 | + | frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil) | |
| 324 | + | bounds := presentation.Bounds | |
| 317 | 325 | require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role") | |
| 318 | 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 | 569 | func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) { | |
| 324 | 570 | terminal := renderer.DefaultStyle() | |
| 325 | 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 | 48 | dimmed := client.Frame.At(0, 1) | |
| 49 | 49 | require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content") | |
| 50 | 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 | 55 | paletteVisible := false | |
| 53 | 56 | for y := range client.Frame.Height { | |
| 54 | 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 | 100 | p, release := newBlockingPTY(t) | |
| 98 | 101 | defer release() | |
| 99 | 102 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| @@ -110,7 +113,9 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) { | |||
| 110 | 113 | ||
| 111 | 114 | d.enterPalette(sess, ac) | |
| 112 | 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 | 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 | 138 | dimmed := client.Frame.At(0, 1) | |
| 134 | 139 | require.Equal(t, 'X', dimmed.Rune) | |
| 135 | 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") | |
| 137 | - | require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp") | |
| 141 | + | dimmedTopBar := topBar | |
| 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 | 148 | d.handleInput(sess, ac, []byte("\x1b")) | |
| 140 | 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 | 35 | func resizeUserError(err error) error { | |
| 84 | 36 | switch { | |
| 85 | 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 | 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 | 41 | if r.publishErr != nil { | |
| 42 | 42 | return r.publishErr | |
| 43 | 43 | } | |
| 44 | - | objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects)) | |
| 45 | - | for _, object := range p.Objects { | |
| 46 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 47 | - | } | |
| 48 | - | r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects} | |
| 44 | + | r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p)) | |
| 49 | 45 | found := false | |
| 50 | 46 | for _, name := range r.names { | |
| 51 | 47 | found = found || name == p.Name | |
| @@ -183,11 +179,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u | |||
| 183 | 179 | manifest.Generation = generation | |
| 184 | 180 | encoded, err := snapcodec.MarshalManifest(manifest) | |
| 185 | 181 | require.NoError(t, err) | |
| 186 | - | objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects)) | |
| 187 | - | for _, object := range publication.Objects { | |
| 188 | - | objects[object.Digest] = append([]byte(nil), object.Data...) | |
| 189 | - | } | |
| 190 | - | return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects} | |
| 182 | + | publication.Generation = generation | |
| 183 | + | publication.Manifest = encoded | |
| 184 | + | return cloneAcceptanceGeneration(snapshotGeneration(publication)) | |
| 191 | 185 | } | |
| 192 | 186 | ||
| 193 | 187 | func TestValidateRestoreSessionSnapshot(t *testing.T) { | |
| @@ -267,6 +261,156 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { | |||
| 267 | 261 | require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") | |
| 268 | 262 | } | |
| 269 | 263 | ||
| 264 | + | type paneLayoutSnapshotFixture struct { | |
| 265 | + | snapshot snapcodec.Session | |
| 266 | + | tree *layout.Tree | |
| 267 | + | tabStableID string | |
| 268 | + | paneStableIDs map[layout.PaneID]string | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | type restoredPaneLayout struct { | |
| 272 | + | tree *layout.Tree | |
| 273 | + | tabStableID string | |
| 274 | + | paneStableIDs map[layout.PaneID]string | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |
| 278 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |
| 279 | + | require.Len(t, fixture.snapshot.Tabs, 1) | |
| 280 | + | decodedTab := fixture.snapshot.Tabs[0] | |
| 281 | + | ||
| 282 | + | require.Equal(t, fixture.tabStableID, decodedTab.StableID) | |
| 283 | + | require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved") | |
| 284 | + | assertConsumedAndExpelledColumns(t, decodedTab.Tree) | |
| 285 | + | require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") | |
| 286 | + | require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID") | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { | |
| 290 | + | fixture := captureConsumedAndExpelledPaneLayout(t) | |
| 291 | + | restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{}) | |
| 292 | + | restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | defer closeRestoredTabs(restoredTabs) | |
| 295 | + | require.Len(t, restoredTabs, 1) | |
| 296 | + | ||
| 297 | + | restored := captureRestoredPaneLayout(restoredTabs[0]) | |
| 298 | + | require.Equal(t, fixture.tabStableID, restored.tabStableID) | |
| 299 | + | assertConsumedAndExpelledColumns(t, restored.tree) | |
| 300 | + | require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree") | |
| 301 | + | require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { | |
| 305 | + | t.Helper() | |
| 306 | + | h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) | |
| 307 | + | h.session.createdAt = 77 | |
| 308 | + | h.session.incarnation = domain.IncarnationID{7} | |
| 309 | + | tabStableID := "tab-stable-rearranged" | |
| 310 | + | paneStableIDs := map[layout.PaneID]string{ | |
| 311 | + | "pane-1": "pane-stable-1", | |
| 312 | + | "pane-2": "pane-stable-2", | |
| 313 | + | "pane-3": "pane-stable-3", | |
| 314 | + | "pane-4": "pane-stable-4", | |
| 315 | + | "pane-5": "pane-stable-5", | |
| 316 | + | } | |
| 317 | + | setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs) | |
| 318 | + | applyPaneLayoutSnapshotOperations(t, h) | |
| 319 | + | ||
| 320 | + | h.tab.mu.Lock() | |
| 321 | + | wantTree := h.tab.tree.Clone() | |
| 322 | + | h.tab.mu.Unlock() | |
| 323 | + | capture, ok := h.daemon.captureSnapshotState(h.session, 7) | |
| 324 | + | require.True(t, ok) | |
| 325 | + | publication, err := h.daemon.incrementalPublication(capture) | |
| 326 | + | require.NoError(t, err) | |
| 327 | + | decoded, err := sessionFromGeneration(snapshotGeneration(publication)) | |
| 328 | + | require.NoError(t, err) | |
| 329 | + | ||
| 330 | + | return paneLayoutSnapshotFixture{ | |
| 331 | + | snapshot: decoded, | |
| 332 | + | tree: wantTree, | |
| 333 | + | tabStableID: tabStableID, | |
| 334 | + | paneStableIDs: paneStableIDs, | |
| 335 | + | } | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func paneLayoutSnapshotTree() *layout.Tree { | |
| 339 | + | return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ | |
| 340 | + | {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, | |
| 341 | + | {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ | |
| 342 | + | {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, | |
| 343 | + | {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20}, | |
| 344 | + | }}, | |
| 345 | + | {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{ | |
| 346 | + | {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7}, | |
| 347 | + | {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, | |
| 348 | + | }}, | |
| 349 | + | }}, Focus: "pane-4"} | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) { | |
| 353 | + | tb.mu.Lock() | |
| 354 | + | defer tb.mu.Unlock() | |
| 355 | + | tb.stableID = tabStableID | |
| 356 | + | tb.nextPaneID = 6 | |
| 357 | + | for id, stableID := range paneStableIDs { | |
| 358 | + | tb.panes[id].mu.Lock() | |
| 359 | + | tb.panes[id].stableID = stableID | |
| 360 | + | tb.panes[id].mu.Unlock() | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) { | |
| 365 | + | t.Helper() | |
| 366 | + | for _, operation := range []struct { | |
| 367 | + | name string | |
| 368 | + | pane layout.PaneID | |
| 369 | + | direction layout.Direction | |
| 370 | + | }{ | |
| 371 | + | {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, | |
| 372 | + | {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, | |
| 373 | + | } { | |
| 374 | + | err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ | |
| 375 | + | kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, | |
| 376 | + | }) | |
| 377 | + | require.NoError(t, err, operation.name) | |
| 378 | + | } | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) { | |
| 382 | + | t.Helper() | |
| 383 | + | require.NotNil(t, tree) | |
| 384 | + | require.NotNil(t, tree.Root) | |
| 385 | + | require.Equal(t, layout.Horizontal, tree.Root.Dir) | |
| 386 | + | require.Len(t, tree.Root.Children, 3) | |
| 387 | + | require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column") | |
| 388 | + | require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column") | |
| 389 | + | require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present") | |
| 390 | + | require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved") | |
| 391 | + | require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved") | |
| 392 | + | } | |
| 393 | + | ||
| 394 | + | func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string { | |
| 395 | + | stableIDs := make(map[layout.PaneID]string, len(panes)) | |
| 396 | + | for _, p := range panes { | |
| 397 | + | stableIDs[p.ID] = p.StableID | |
| 398 | + | } | |
| 399 | + | return stableIDs | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func captureRestoredPaneLayout(tb *tab) restoredPaneLayout { | |
| 403 | + | tb.mu.Lock() | |
| 404 | + | defer tb.mu.Unlock() | |
| 405 | + | stableIDs := make(map[layout.PaneID]string, len(tb.panes)) | |
| 406 | + | for id, p := range tb.panes { | |
| 407 | + | p.mu.Lock() | |
| 408 | + | stableIDs[id] = p.stableID | |
| 409 | + | p.mu.Unlock() | |
| 410 | + | } | |
| 411 | + | return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs} | |
| 412 | + | } | |
| 413 | + | ||
| 270 | 414 | func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { | |
| 271 | 415 | snapshot := restoreAcceptanceSession(t, "restored") | |
| 272 | 416 | generation := acceptanceGeneration(t, snapshot, 9) | |
M internal/usecase/daemon/snatched_render.go +2 -1
| @@ -23,7 +23,8 @@ func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string | |||
| 23 | 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 | 28 | message := "This session is now active elsewhere." | |
| 28 | 29 | if feedback != "" { | |
| 29 | 30 | message = feedback | |
M internal/usecase/daemon/snatched_render_test.go +1 -1
| @@ -92,7 +92,7 @@ func TestSnatchedPanelFrame(t *testing.T) { | |||
| 92 | 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 | 96 | topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y) | |
| 97 | 97 | bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1) | |
| 98 | 98 | require.Equal(t, '┌', topLeft.Rune) | |
M internal/usecase/daemon/transactional_resize.go +295 -111
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 4 | 6 | "github.com/bnema/vev/internal/domain" | |
| 5 | 7 | "github.com/bnema/vev/internal/ports" | |
| 6 | 8 | "github.com/bnema/vev/internal/usecase/layout" | |
| @@ -9,13 +11,13 @@ import ( | |||
| 9 | 11 | type resizeOwnerPostEffect uint8 | |
| 10 | 12 | ||
| 11 | 13 | const ( | |
| 12 | - | resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota + 1 | |
| 14 | + | resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota | |
| 13 | 15 | resizeOwnerPostRetrySchedule | |
| 14 | 16 | resizeOwnerPostRenderInvalidation | |
| 15 | 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 | 21 | // state; apply performs the external PTY call; commit is the sole publisher. | |
| 20 | 22 | type resizeMember struct { | |
| 21 | 23 | session *session | |
| @@ -43,11 +45,6 @@ type preparedTabLayout struct { | |||
| 43 | 45 | members []resizeMember | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | - | type resizeApplyResult struct { | |
| 47 | - | members []resizeMember | |
| 48 | - | failed []resizeMember | |
| 49 | - | } | |
| 50 | - | ||
| 51 | 48 | func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout { | |
| 52 | 49 | return prepareTabLayoutForSizeLocked(sess, tb, tb.size) | |
| 53 | 50 | } | |
| @@ -138,9 +135,6 @@ func resizeMemberOwnerCurrent(member *resizeMember) bool { | |||
| 138 | 135 | if member == nil || member.pane == nil { | |
| 139 | 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 | 138 | if member.owner.owner == nil { | |
| 145 | 139 | return true | |
| 146 | 140 | } | |
| @@ -170,33 +164,6 @@ func resizeMembersOwnerCurrent(members []resizeMember) bool { | |||
| 170 | 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 | 167 | func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool { | |
| 201 | 168 | for i := range plan.members { | |
| 202 | 169 | member := &plan.members[i] | |
| @@ -270,22 +237,22 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool | |||
| 270 | 237 | // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns | |
| 271 | 238 | // one per-tab single-writer loop, but never holds tab or pane state locks while | |
| 272 | 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 | 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 | 245 | if tb == nil { | |
| 279 | - | return resizeApplyResult{}, false | |
| 246 | + | return nil, false | |
| 280 | 247 | } | |
| 281 | 248 | tb.layoutApplyMu.Lock() | |
| 282 | 249 | defer tb.layoutApplyMu.Unlock() | |
| 283 | 250 | for { | |
| 284 | 251 | if tb.ctx != nil && tb.ctx.Err() != nil { | |
| 285 | - | return resizeApplyResult{}, false | |
| 252 | + | return nil, false | |
| 286 | 253 | } | |
| 287 | 254 | if len(current) != 0 && !current[0]() { | |
| 288 | - | return resizeApplyResult{}, false | |
| 255 | + | return nil, false | |
| 289 | 256 | } | |
| 290 | 257 | tb.mu.Lock() | |
| 291 | 258 | plan := prepareTabLayoutLocked(sess, tb) | |
| @@ -304,10 +271,6 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 304 | 271 | d.finishPreparedTabMembers(&plan, accepted) | |
| 305 | 272 | if !accepted { | |
| 306 | 273 | d.cancelStalePreparedGates(sess, tb, &plan) | |
| 307 | - | if preparedTabOwnerStale(&plan) { | |
| 308 | - | d.releasePreparedSessionGates([]*preparedTabLayout{&plan}) | |
| 309 | - | return resizeApplyResult{}, false | |
| 310 | - | } | |
| 311 | 274 | if len(current) != 0 && !current[0]() { | |
| 312 | 275 | for i := range plan.members { | |
| 313 | 276 | member := &plan.members[i] | |
| @@ -318,7 +281,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 318 | 281 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |
| 319 | 282 | member.pane.resizeMu.Unlock() | |
| 320 | 283 | } | |
| 321 | - | return resizeApplyResult{}, false | |
| 284 | + | return nil, false | |
| 322 | 285 | } | |
| 323 | 286 | continue | |
| 324 | 287 | } | |
| @@ -328,32 +291,87 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep | |||
| 328 | 291 | failed = append(failed, member) | |
| 329 | 292 | } | |
| 330 | 293 | } | |
| 331 | - | if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) { | |
| 294 | + | if reportFailure && len(failed) != 0 { | |
| 332 | 295 | d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed, | |
| 333 | 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 | 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 | 304 | if !ok { | |
| 342 | 305 | return false | |
| 343 | 306 | } | |
| 344 | - | if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |
| 345 | - | markSnapshotDirty(sess) | |
| 346 | - | }) { | |
| 347 | - | return false | |
| 348 | - | } | |
| 349 | - | if len(result.failed) != 0 { | |
| 350 | - | if !d.scheduleAcceptedTabLayoutRetry(sess, tb, result.failed) { | |
| 351 | - | return false | |
| 352 | - | } | |
| 307 | + | markSnapshotDirty(sess) | |
| 308 | + | if len(failed) != 0 { | |
| 309 | + | d.scheduleAcceptedTabLayoutRetry(sess, tb) | |
| 353 | 310 | } | |
| 354 | 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 | 375 | // replayResizePending completes a pane's apply epoch. It keeps the gate set | |
| 358 | 376 | // while each buffered batch goes through the normal VT path, so reads arriving | |
| 359 | 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 | 408 | // used for retryable PTY work. Floating state is not part of a tiled layout | |
| 391 | 409 | // generation, so its slot generation and exact pane identity validate its | |
| 392 | 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 | 412 | return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil) | |
| 395 | 413 | } | |
| 396 | 414 | ||
| 397 | 415 | // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the | |
| 398 | 416 | // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes, | |
| 399 | 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 | 419 | if tb == nil { | |
| 402 | - | return resizeApplyResult{}, true | |
| 420 | + | return nil, true | |
| 403 | 421 | } | |
| 404 | 422 | tb.mu.Lock() | |
| 405 | 423 | if tb.floating.state != floatingVisible || tb.floating.pane == nil || | |
| 406 | 424 | (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) { | |
| 407 | 425 | tb.mu.Unlock() | |
| 408 | - | return resizeApplyResult{}, true | |
| 426 | + | return nil, true | |
| 409 | 427 | } | |
| 410 | 428 | p := tb.floating.pane | |
| 411 | 429 | generation := tb.floating.generation | |
| 412 | 430 | size := tb.size | |
| 413 | 431 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | |
| 414 | 432 | tb.mu.Unlock() | |
| 415 | - | if !geometry.valid() { | |
| 416 | - | return resizeApplyResult{}, true | |
| 433 | + | if !geometry.committable() { | |
| 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 | 460 | // This keeps successful PTY resizes gated until the | |
| @@ -423,34 +464,30 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 423 | 464 | owner := p.effectLeaseLocked() | |
| 424 | 465 | p.mu.Unlock() | |
| 425 | 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 | 469 | d.applyPreparedTabMembers(&plan) | |
| 429 | 470 | ||
| 430 | 471 | // The PTY may have accepted an intermediate size, but a hidden, replaced, | |
| 431 | 472 | // relaunched, or resized slot must never receive this attempt's geometry. | |
| 432 | 473 | tb.mu.Lock() | |
| 433 | - | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && | |
| 434 | - | resizeMemberOwnerCurrent(&plan.members[0]) | |
| 474 | + | currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && resizeMemberOwnerCurrent(&plan.members[0]) | |
| 435 | 475 | if current != nil && !current() { | |
| 436 | 476 | currentSlot = false | |
| 437 | 477 | } | |
| 438 | - | if currentSlot { | |
| 478 | + | if currentSlot && plan.members[0].ok { | |
| 439 | 479 | p.mu.Lock() | |
| 440 | 480 | currentSlot = resizeMemberOwnerCurrent(&plan.members[0]) | |
| 441 | 481 | if currentSlot { | |
| 442 | - | p.rect = geometry.Inner | |
| 482 | + | p.rect = plan.members[0].rect | |
| 443 | 483 | p.popupGeometry = geometry | |
| 444 | - | if plan.members[0].ok { | |
| 445 | - | p.resizeRetry = false | |
| 446 | - | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |
| 447 | - | } | |
| 484 | + | p.resizeRetry = false | |
| 485 | + | p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height) | |
| 448 | 486 | } | |
| 449 | 487 | p.mu.Unlock() | |
| 450 | 488 | } | |
| 451 | 489 | tb.mu.Unlock() | |
| 452 | 490 | if !currentSlot { | |
| 453 | - | ownerCurrent := resizeMemberOwnerCurrent(&plan.members[0]) | |
| 454 | 491 | // This attempt has no tiled transaction loop to retain the gate for, so | |
| 455 | 492 | // discard its buffered bytes at the old screen size before returning. | |
| 456 | 493 | for i := range plan.members { | |
| @@ -462,17 +499,16 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 462 | 499 | d.replayResizePending(member.session, member.tab, member.pane, false, member.rect) | |
| 463 | 500 | member.pane.resizeMu.Unlock() | |
| 464 | 501 | } | |
| 465 | - | if !ownerCurrent || (current != nil && !current()) { | |
| 466 | - | return resizeApplyResult{}, false | |
| 502 | + | if current != nil && !current() { | |
| 503 | + | return nil, false | |
| 467 | 504 | } | |
| 468 | - | return resizeApplyResult{}, true | |
| 505 | + | return nil, true | |
| 469 | 506 | } | |
| 470 | 507 | if plan.members[0].ok { | |
| 471 | 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...) | |
| 475 | - | return resizeApplyResult{members: members, failed: members}, true | |
| 511 | + | return plan.members, true | |
| 476 | 512 | } | |
| 477 | 513 | ||
| 478 | 514 | // releasePreparedSessionGates abandons a session attempt which cannot be | |
| @@ -501,9 +537,9 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||
| 501 | 537 | // transaction: all PTYs are applied and plans validated first, then the | |
| 502 | 538 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | |
| 503 | 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 | 541 | if sess == nil { | |
| 506 | - | return resizeApplyResult{}, false | |
| 542 | + | return nil, false | |
| 507 | 543 | } | |
| 508 | 544 | sess.layoutApplyMu.Lock() | |
| 509 | 545 | defer sess.layoutApplyMu.Unlock() | |
| @@ -511,10 +547,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 511 | 547 | target := tabSize(size) | |
| 512 | 548 | for { | |
| 513 | 549 | if sess.ctx != nil && sess.ctx.Err() != nil { | |
| 514 | - | return resizeApplyResult{}, false | |
| 550 | + | return nil, false | |
| 515 | 551 | } | |
| 516 | 552 | if current != nil && !current() { | |
| 517 | - | return resizeApplyResult{}, false | |
| 553 | + | return nil, false | |
| 518 | 554 | } | |
| 519 | 555 | sess.mu.Lock() | |
| 520 | 556 | tabs := append([]*tab(nil), sess.tabs...) | |
| @@ -565,11 +601,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 565 | 601 | valid = false | |
| 566 | 602 | break | |
| 567 | 603 | } | |
| 568 | - | } | |
| 569 | - | if valid { | |
| 570 | - | for _, plan := range plans { | |
| 571 | - | plan.tab.size = plan.size | |
| 572 | - | } | |
| 604 | + | plan.tab.size = plan.size | |
| 573 | 605 | } | |
| 574 | 606 | } | |
| 575 | 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 | 610 | if !valid { | |
| 579 | 611 | d.releasePreparedSessionGates(plans) | |
| 580 | - | for _, plan := range plans { | |
| 581 | - | if preparedTabOwnerStale(plan) { | |
| 582 | - | return resizeApplyResult{}, false | |
| 583 | - | } | |
| 584 | - | } | |
| 585 | 612 | // Headless requests have no attachment epoch. A live tab mutation | |
| 586 | 613 | // still invalidates their plan, but must trigger a fresh prepare rather | |
| 587 | 614 | // than being mistaken for a canceled request. | |
| 588 | 615 | if current != nil && !current() { | |
| 589 | - | return resizeApplyResult{}, false | |
| 616 | + | return nil, false | |
| 590 | 617 | } | |
| 591 | 618 | // A layout mutation invalidated the plans while this epoch remains | |
| 592 | 619 | // current. Reapply the fresh session geometry before admitting it. | |
| 593 | 620 | continue | |
| 594 | 621 | } | |
| 595 | 622 | ||
| 596 | - | members := make([]resizeMember, 0) | |
| 597 | 623 | failed := make([]resizeMember, 0) | |
| 598 | 624 | for _, plan := range plans { | |
| 599 | 625 | d.finishPreparedTabMembers(plan, true) | |
| 600 | - | members = append(members, plan.members...) | |
| 601 | 626 | for _, member := range plan.members { | |
| 602 | 627 | if !member.ok { | |
| 603 | 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 | 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 | 634 | if !ok { | |
| 610 | - | return resizeApplyResult{}, false | |
| 635 | + | return nil, false | |
| 611 | 636 | } | |
| 612 | - | members = append(members, floatingResult.members...) | |
| 613 | - | failed = append(failed, floatingResult.failed...) | |
| 637 | + | failed = append(failed, floatingFailed...) | |
| 614 | 638 | } | |
| 615 | 639 | failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed) | |
| 616 | 640 | if len(failed) != 0 && failedCurrent { | |
| @@ -618,9 +642,174 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad | |||
| 618 | 642 | "pane resize failed; retrying in background", failed[len(failed)-1].err) | |
| 619 | 643 | } | |
| 620 | 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 | 835 | if ac == nil { | |
| 647 | 836 | // Headless geometry has no coordinator/transport to coalesce, but keeps | |
| 648 | 837 | // the same prepare/apply/commit ordering. | |
| 649 | - | result, ok := d.applySessionLayout(sess, size, nil, nil) | |
| 650 | - | if !ok || !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() { | |
| 651 | - | markSnapshotDirty(sess) | |
| 652 | - | }) { | |
| 653 | - | return false | |
| 654 | - | } | |
| 655 | - | return d.publishResizeCommit(result.members, sess, nil, nil, 0, nil, size) | |
| 838 | + | _, ok := d.applySessionLayout(sess, size, nil, nil) | |
| 839 | + | return ok | |
| 656 | 840 | } | |
| 657 | 841 | rc := sess.renderCoordinator() | |
| 658 | 842 | if rc == nil || !rc.leaseCurrent(lease, true) { | |
M internal/usecase/daemon/transactional_resize_test.go +190 -399
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "io" | |
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | - | "sync/atomic" | |
| 9 | 8 | "testing" | |
| 10 | 9 | "time" | |
| 11 | 10 | ||
| @@ -14,6 +13,7 @@ import ( | |||
| 14 | 13 | "github.com/bnema/vev/internal/domain" | |
| 15 | 14 | "github.com/bnema/vev/internal/ports" | |
| 16 | 15 | "github.com/bnema/vev/internal/usecase/layout" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | 17 | "github.com/bnema/vev/internal/usecase/visualsearch" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| @@ -272,10 +272,10 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) { | |||
| 272 | 272 | } | |
| 273 | 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 | 277 | require.Equal(t, tc.wantOK, ok) | |
| 278 | - | require.Len(t, result.failed, tc.wantFailed) | |
| 278 | + | require.Len(t, failed, tc.wantFailed) | |
| 279 | 279 | count := 0 | |
| 280 | 280 | for _, notification := range d.notices.history() { | |
| 281 | 281 | if notification.Code == domain.NoticeResizeFailed { | |
| @@ -407,402 +407,6 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) { | |||
| 407 | 407 | // stopped. Only the newest prepared epoch may apply, commit, and emit one full | |
| 408 | 408 | // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its | |
| 409 | 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 | 410 | func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) { | |
| 807 | 411 | first, second := &transactionalResizePTY{}, &transactionalResizePTY{} | |
| 808 | 412 | d, sess, ac, _ := newManualSessionWithPTYs(t, first, second) | |
| @@ -941,6 +545,193 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) { | |||
| 941 | 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 | 735 | func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) { | |
| 945 | 736 | popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}} | |
| 946 | 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 | 51 | var actionNames = map[Action]string{ | |
| 52 | - | ActionOpenPalette: "open-palette", | |
| 53 | - | ActionToggleFloatingPane: "toggle-floating-pane", | |
| 54 | - | ActionJumpAttention: "jump-attention", | |
| 55 | - | ActionFocusPaneLeft: "focus-pane-left", | |
| 56 | - | ActionFocusPaneRight: "focus-pane-right", | |
| 57 | - | ActionFocusPaneUp: "focus-pane-up", | |
| 58 | - | ActionFocusPaneDown: "focus-pane-down", | |
| 59 | - | ActionSwitchTab1: "switch-tab-1", | |
| 60 | - | ActionSwitchTab2: "switch-tab-2", | |
| 61 | - | ActionSwitchTab3: "switch-tab-3", | |
| 62 | - | ActionSwitchTab4: "switch-tab-4", | |
| 63 | - | ActionSwitchTab5: "switch-tab-5", | |
| 64 | - | ActionSwitchTab6: "switch-tab-6", | |
| 65 | - | ActionSwitchTab7: "switch-tab-7", | |
| 66 | - | ActionSwitchTab8: "switch-tab-8", | |
| 67 | - | ActionSwitchTab9: "switch-tab-9", | |
| 68 | - | ActionGrowPaneWidth: "grow-pane-width", | |
| 69 | - | ActionShrinkPaneWidth: "shrink-pane-width", | |
| 70 | - | ActionGrowPaneHeight: "grow-pane-height", | |
| 71 | - | ActionShrinkPaneHeight: "shrink-pane-height", | |
| 72 | - | ActionEqualizePanes: "equalize-panes", | |
| 52 | + | ActionOpenPalette: "open-palette", | |
| 53 | + | ActionToggleFloatingPane: "toggle-floating-pane", | |
| 54 | + | ActionJumpAttention: "jump-attention", | |
| 55 | + | ActionFocusPaneLeft: "focus-pane-left", | |
| 56 | + | ActionFocusPaneRight: "focus-pane-right", | |
| 57 | + | ActionFocusPaneUp: "focus-pane-up", | |
| 58 | + | ActionFocusPaneDown: "focus-pane-down", | |
| 59 | + | ActionSwitchTab1: "switch-tab-1", | |
| 60 | + | ActionSwitchTab2: "switch-tab-2", | |
| 61 | + | ActionSwitchTab3: "switch-tab-3", | |
| 62 | + | ActionSwitchTab4: "switch-tab-4", | |
| 63 | + | ActionSwitchTab5: "switch-tab-5", | |
| 64 | + | ActionSwitchTab6: "switch-tab-6", | |
| 65 | + | ActionSwitchTab7: "switch-tab-7", | |
| 66 | + | ActionSwitchTab8: "switch-tab-8", | |
| 67 | + | ActionSwitchTab9: "switch-tab-9", | |
| 68 | + | ActionGrowPaneWidth: "grow-pane-width", | |
| 69 | + | ActionShrinkPaneWidth: "shrink-pane-width", | |
| 70 | + | ActionGrowPaneHeight: "grow-pane-height", | |
| 71 | + | ActionShrinkPaneHeight: "shrink-pane-height", | |
| 72 | + | ActionEqualizePanes: "equalize-panes", | |
| 73 | + | ActionConsumeOrExpelPaneLeft: "consume-or-expel-pane-left", | |
| 74 | + | ActionConsumeOrExpelPaneRight: "consume-or-expel-pane-right", | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 75 | 77 | var actionsByName = func() map[string]Action { | |
M internal/usecase/keys/keyspec_test.go +2 -0
| @@ -84,6 +84,8 @@ func TestActionNamesAreCanonical(t *testing.T) { | |||
| 84 | 84 | {ActionGrowPaneHeight, "grow-pane-height"}, | |
| 85 | 85 | {ActionShrinkPaneHeight, "shrink-pane-height"}, | |
| 86 | 86 | {ActionEqualizePanes, "equalize-panes"}, | |
| 87 | + | {ActionConsumeOrExpelPaneLeft, "consume-or-expel-pane-left"}, | |
| 88 | + | {ActionConsumeOrExpelPaneRight, "consume-or-expel-pane-right"}, | |
| 87 | 89 | } | |
| 88 | 90 | ||
| 89 | 91 | for _, tc := range cases { | |
M internal/usecase/keys/router.go +2 -0
| @@ -43,6 +43,8 @@ const ( | |||
| 43 | 43 | ActionGrowPaneHeight | |
| 44 | 44 | ActionShrinkPaneHeight | |
| 45 | 45 | ActionEqualizePanes | |
| 46 | + | ActionConsumeOrExpelPaneLeft | |
| 47 | + | ActionConsumeOrExpelPaneRight | |
| 46 | 48 | ) | |
| 47 | 49 | ||
| 48 | 50 | // Handler receives router outputs. Forward is called only for bytes that should | |
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 | 492 | func TestWeightCloneCopiesWeightIndependently(t *testing.T) { | |
| 327 | 493 | t.Parallel() | |
| 328 | 494 | ||
M internal/usecase/ui/modal.go +65 -39
| @@ -18,8 +18,7 @@ type Modal struct { | |||
| 18 | 18 | FixedHeight int | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | - | // Bounds returns the modal rectangle positioned within base and clamped to base. | |
| 22 | - | func (m Modal) Bounds(base domain.Size) domain.Rect { | |
| 21 | + | func (m Modal) preferredBounds(base domain.Size) domain.Rect { | |
| 23 | 22 | width := percentOf(base.Cols, m.WidthPct) | |
| 24 | 23 | height := percentOf(base.Rows, m.HeightPct) | |
| 25 | 24 | if m.FixedWidth > 0 { | |
| @@ -37,38 +36,41 @@ func (m Modal) Bounds(base domain.Size) domain.Rect { | |||
| 37 | 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. | |
| 41 | - | func (m Modal) Inner(base domain.Size) domain.Rect { | |
| 42 | - | bounds := m.Bounds(base) | |
| 43 | - | return domain.Rect{ | |
| 44 | - | X: bounds.X + 1, | |
| 45 | - | Y: bounds.Y + 1, | |
| 46 | - | Width: max(0, bounds.Width-2), | |
| 47 | - | Height: max(0, bounds.Height-2), | |
| 39 | + | // Resolve computes the modal's responsive presentation from one preferred | |
| 40 | + | // bounds and inner pair. | |
| 41 | + | func (m Modal) Resolve(base domain.Size) Presentation { | |
| 42 | + | bounds := m.preferredBounds(base) | |
| 43 | + | return ResolvePresentation(base, bounds, modalInner(bounds)) | |
| 44 | + | } | |
| 45 | + | ||
| 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 | |
| 52 | - | // the inner content rectangle. Border and interior styles are deliberately | |
| 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{ | |
| 67 | + | func modalInner(bounds domain.Rect) domain.Rect { | |
| 68 | + | return domain.Rect{ | |
| 58 | 69 | X: bounds.X + 1, | |
| 59 | 70 | Y: bounds.Y + 1, | |
| 60 | 71 | Width: max(0, bounds.Width-2), | |
| 61 | 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 | 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 | 89 | // DrawBox draws a single-cell box clipped to the frame bounds. | |
| 88 | 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 | 96 | return | |
| 91 | 97 | } | |
| 92 | 98 | left := rect.X | |
| @@ -94,18 +100,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) { | |||
| 94 | 100 | right := rect.X + rect.Width - 1 | |
| 95 | 101 | bottom := rect.Y + rect.Height - 1 | |
| 96 | 102 | ||
| 97 | - | for x := left; x <= right; x++ { | |
| 98 | - | setCell(f, x, top, '─', style) | |
| 99 | - | setCell(f, x, bottom, '─', style) | |
| 100 | - | } | |
| 101 | - | for y := top; y <= bottom; y++ { | |
| 102 | - | setCell(f, left, y, '│', style) | |
| 103 | - | setCell(f, right, y, '│', style) | |
| 104 | - | } | |
| 105 | - | setCell(f, left, top, '┌', style) | |
| 106 | - | setCell(f, right, top, '┐', style) | |
| 107 | - | setCell(f, left, bottom, '└', style) | |
| 108 | - | setCell(f, right, bottom, '┘', style) | |
| 103 | + | if edges&BorderTop != 0 { | |
| 104 | + | for x := left; x <= right; x++ { | |
| 105 | + | setCell(f, x, top, '─', style) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | if edges&BorderBottom != 0 { | |
| 109 | + | for x := left; x <= right; x++ { | |
| 110 | + | setCell(f, x, bottom, '─', style) | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | if edges&BorderLeft != 0 { | |
| 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 | 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 | 8 | "github.com/stretchr/testify/require" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | - | func TestModalBounds(t *testing.T) { | |
| 11 | + | func TestModalResolveBounds(t *testing.T) { | |
| 12 | 12 | tests := []struct { | |
| 13 | 13 | name string | |
| 14 | 14 | base domain.Size | |
| @@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) { | |||
| 22 | 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 | 26 | base: domain.Size{Cols: 20, Rows: 6}, | |
| 27 | 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 | 32 | base: domain.Size{Cols: 11, Rows: 7}, | |
| 33 | 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 | 38 | base: domain.Size{Cols: 12, Rows: 8}, | |
| 39 | 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 | 43 | name: "bottom anchor honors bottom margin", | |
| @@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) { | |||
| 63 | 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 | 67 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 68 | 68 | m: Modal{ | |
| 69 | 69 | FixedWidth: 8, | |
| @@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) { | |||
| 71 | 71 | Anchor: domain.AnchorRight, | |
| 72 | 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 | 78 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 79 | 79 | m: Modal{ | |
| 80 | 80 | FixedWidth: 30, | |
| @@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) { | |||
| 82 | 82 | Anchor: domain.AnchorRight, | |
| 83 | 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 | 89 | base: domain.Size{Cols: 10, Rows: 4}, | |
| 90 | 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 | 95 | base: domain.Size{Cols: 20, Rows: 10}, | |
| 96 | 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 | 100 | name: "bottom right anchor honors combined margins", | |
| @@ -110,58 +110,171 @@ func TestModalBounds(t *testing.T) { | |||
| 110 | 110 | } | |
| 111 | 111 | for _, tt := range tests { | |
| 112 | 112 | t.Run(tt.name, func(t *testing.T) { | |
| 113 | - | if got := tt.m.Bounds(tt.base); got != tt.want { | |
| 114 | - | t.Fatalf("Bounds() = %+v, want %+v", got, tt.want) | |
| 113 | + | if got := tt.m.Resolve(tt.base).Bounds; 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) { | |
| 121 | - | m := Modal{WidthPct: 100, HeightPct: 100} | |
| 122 | - | got := m.Inner(domain.Size{Cols: 2, Rows: 2}) | |
| 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 | - | } | |
| 120 | + | func TestModalResolveInnerDegenerate(t *testing.T) { | |
| 121 | + | got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner | |
| 122 | + | require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got) | |
| 127 | 123 | } | |
| 128 | 124 | ||
| 129 | - | func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) { | |
| 130 | - | f := renderer.NewFrame(10, 6) | |
| 131 | - | exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 132 | - | FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior) | |
| 125 | + | func TestModalResolve(t *testing.T) { | |
| 126 | + | tests := []struct { | |
| 127 | + | name string | |
| 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 | 167 | border := renderer.Style{Bold: true, Foreground: 2, Background: -1} | |
| 135 | 168 | interior := renderer.Style{Foreground: 3, Background: 4} | |
| 136 | - | m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"} | |
| 137 | - | inner := m.Composite(f, border, interior) | |
| 138 | - | wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2} | |
| 139 | - | if inner != wantInner { | |
| 140 | - | t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner) | |
| 169 | + | ||
| 170 | + | inner := modal.CompositePresentation(frame, presentation, border, interior) | |
| 171 | + | require.Equal(t, presentation.Inner, inner) | |
| 172 | + | require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune) | |
| 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, '┌') | |
| 144 | - | assertRune(t, f, 7, 1, '┐') | |
| 145 | - | assertRune(t, f, 2, 4, '└') | |
| 146 | - | assertRune(t, f, 7, 4, '┘') | |
| 147 | - | assertRune(t, f, 3, 1, '─') | |
| 148 | - | assertRune(t, f, 2, 2, '│') | |
| 149 | - | assertRune(t, f, 4, 1, 'H') | |
| 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 | - | } | |
| 230 | + | for _, tt := range tests { | |
| 231 | + | t.Run(tt.name, func(t *testing.T) { | |
| 232 | + | frame := renderer.NewFrame(10, 6) | |
| 233 | + | FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior) | |
| 234 | + | (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle()) | |
| 235 | + | tt.assert(t, frame) | |
| 236 | + | }) | |
| 159 | 237 | } | |
| 160 | - | if got := f.At(2, 1).Style; !got.Equal(border) { | |
| 161 | - | t.Fatalf("border style = %+v, want %+v", got, border) | |
| 238 | + | } | |
| 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) | |
| 164 | - | assertCell(t, f, 9, 5, exterior) | |
| 264 | + | } | |
| 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 | 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 | 338 | border := renderer.Style{Foreground: 1, Background: 2} | |
| 226 | 339 | interior := renderer.Style{Foreground: 3, Background: 4} | |
| 227 | - | for _, tt := range []struct { | |
| 228 | - | name string | |
| 229 | - | size domain.Size | |
| 230 | - | want domain.Rect | |
| 231 | - | }{ | |
| 232 | - | {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}}, | |
| 233 | - | {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}}, | |
| 234 | - | } { | |
| 235 | - | t.Run(tt.name, func(t *testing.T) { | |
| 236 | - | frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows) | |
| 237 | - | got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior) | |
| 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 | - | }) | |
| 340 | + | frame := renderer.NewFrame(2, 2) | |
| 341 | + | modal := Modal{WidthPct: 100, HeightPct: 100} | |
| 342 | + | presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2}) | |
| 343 | + | ||
| 344 | + | got := modal.CompositePresentation(frame, presentation, border, interior) | |
| 345 | + | ||
| 346 | + | require.Equal(t, presentation.Inner, got) | |
| 347 | + | for y := range frame.Height { | |
| 348 | + | for x := range frame.Width { | |
| 349 | + | require.Equal(t, renderer.BlankCell(), frame.At(x, y)) | |
| 350 | + | } | |
| 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 | + | } | |