detached 52e1724

merge(main): integrate latest main changes

commit
52e17248b5bf86c8a82b994555f4f413a311e91a
author
brice · · 2026-07-28 16:19 UTC
merge of
afa2cd4 + 43eb2f4 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
53 files · +4093 -1343
M README.md +1 -1
@@ -52,7 +52,7 @@ No prefix key; everything is Alt.
52 | Alt+h/j/k/l, Alt+Arrow | focus pane |52 | Alt+h/j/k/l, Alt+Arrow | focus pane |
53 | Alt+a | jump to a session needing attention |53 | Alt+a | jump to a session needing attention |
54 54
55-The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. `MPN` (`Move pane to tab`) and `MTB` (`Move tab to session`) open live-destination pickers. Named active and stopped sessions are fuzzy-searchable for navigation, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.55+The palette does the rest: type a short code (`SPR` split right, `CNT` new tab, `SSP` session picker, ...) or fuzzy-search the command list. `MPN` (`Move pane to tab`) and `MTB` (`Move tab to session`) open live-destination pickers; the unbound pane consume/expel actions are discoverable there as `CEL` and `CER`. Named active and stopped sessions are fuzzy-searchable for navigation, and selecting a stopped session resumes it. Scroll up with the mouse to enter scrollback; vim keys move, `v` selects, `y` copies via OSC 52.
56 56
57 ## Remote attach57 ## Remote attach
58 58
M docs/configuration.md +21 -2
@@ -34,6 +34,9 @@ focus-pane-left = alt+h
34 focus-pane-right = alt+l34 focus-pane-right = alt+l
35 focus-pane-up = alt+k35 focus-pane-up = alt+k
36 focus-pane-down = alt+j36 focus-pane-down = alt+j
37+# Optional pane rearrangement actions are unbound by default.
38+# consume-or-expel-pane-left = alt+H
39+# consume-or-expel-pane-right = alt+L
37 switch-tab-1 = alt+140 switch-tab-1 = alt+1
38 # ... through switch-tab-9 = alt+941 # ... through switch-tab-9 = alt+9
39 42
@@ -53,6 +56,8 @@ code.split-right = SPR
53 code.split-left = SPL56 code.split-left = SPL
54 code.split-up = SPU57 code.split-up = SPU
55 code.split-down = SPD58 code.split-down = SPD
59+code.consume-or-expel-pane-left = CEL
60+code.consume-or-expel-pane-right = CER
56 code.stack-pane = STP61 code.stack-pane = STP
57 code.toggle-stack = TST62 code.toggle-stack = TST
58 code.close-pane = CLP63 code.close-pane = CLP
@@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b
96 101
97 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.102 Key specs: `alt+<char>`, `alt+space`, `alt+left/right/up/down`, `alt+1` through `alt+9`. Configuring an action replaces all of its built-in aliases (set `focus-pane-left` and the Alt+Arrow alias is gone). Tab switching also accepts the top-row symbols of non-QWERTY layouts, so AZERTY works without extra config.
98 103
104+`alt+[` is unsupported because terminals frame it as the CSI prefix `ESC [`, which vev passes through as terminal input. Cmd/Super is not a vev key-spec modifier; map a physical Cmd/Super chord in the terminal emulator to an unused, safe `ESC` + character sequence (not `ESC [`), then configure the matching `alt+<char>` in vev.
105+
106+## Pane consume or expel
107+
108+The `consume-or-expel-pane-left` and `consume-or-expel-pane-right` actions are unbound by default; the commented Alt+H/Alt+L bindings above are optional examples. Use palette codes `CEL` and `CER`, or script them as `vev cmd consume-or-expel-pane-left` and `vev cmd consume-or-expel-pane-right`.
109+
110+A singleton pane moves into the immediate column on the requested side; at the outer edge, nothing changes. A pane in a multi-member vertical or stack column moves out as an adjacent singleton column. This works only with canonical column layouts: one column, or a top-level horizontal split of columns, where each column is a singleton pane, a vertical split of panes, or a pane stack. Nested mixed splits are unsupported.
111+
99 ## Navigation overflow112 ## Navigation overflow
100 113
101 Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.114 Both settings are independent and default to `off`. With `nav.overflow-tabs = on`, left/right keyboard focus (`Alt+H`/`Alt+L` by default) continues from a pane edge to the adjacent tab. With `nav.overflow-sessions = on`, up/down keyboard focus (`Alt+K`/`Alt+J` by default) continues to the adjacent live session in alphabetical order. Neither setting wraps at the first or last destination.
@@ -116,13 +129,19 @@ copy.word-separators = " -_@"
116 129
117 Set `copy.word-separators = ""` to use only Unicode whitespace as a separator.130 Set `copy.word-separators = ""` to use only Unicode whitespace as a separator.
118 131
132+## Responsive overlays
133+
134+On complete frames below 80 columns, interactive overlays use full-width bottom drawers immediately above the bottom bar. This applies to the floating terminal, command palette, session picker, notification history, prompts, and copy search. A drawer keeps its overlay's preferred outer height, capped at the frame height minus four rows, so frame rows 0–2 remain visible. Drawers use only a top border.
135+
136+Opening an interactive overlay dims the complete underlying frame, including panes, both bars, notices, and any lower-priority overlay. Notice toasts remain compact rather than becoming drawers. Copy mode remains full-screen; only its search prompt uses the responsive drawer.
137+
119 ## Palette anchor138 ## Palette anchor
120 139
121-The command palette is centered by default. Set `palette.anchor` to another anchor to reposition it, or use `auto` for a bottom shelf on narrow terminals and a bottom-right rail from 96 columns up. Reload moves an open palette without losing your query.140+The command palette is centered by default. With `palette.anchor = auto`, it uses a full-width bottom shelf from 80 through 95 columns and a 64-column bottom-right rail from 96 columns up. Set an explicit anchor to position the shelf or rail. Below 80 columns, the palette is always a bottom drawer, so an explicit anchor does not override the responsive layout. Reload moves an open palette without losing your query.
122 141
123 ## Floating terminal142 ## Floating terminal
124 143
125-The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Floating state is not restored across daemon restarts.144+The command runs through your shell. A changed command applies on the next launch; changed dimensions on the next show or resize. Below 80 columns, `floating.height` continues to determine the drawer's outer height, capped to preserve the first three frame rows and the bottom bar; `floating.width` is replaced by the full frame width. Floating state is not restored across daemon restarts.
126 145
127 ## Bar anchors146 ## Bar anchors
128 147
M internal/app/cmd_test.go +8 -3
@@ -26,6 +26,8 @@ func TestParseCmdArgs(t *testing.T) {
26 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},26 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
27 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},27 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
28 {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},28 {"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
29+ {"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false},
30+ {"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false},
29 {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},31 {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
30 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},32 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
31 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},33 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
@@ -94,7 +96,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
94 if called {96 if called {
95 t.Fatal("help dialed daemon")97 t.Fatal("help dialed daemon")
96 }98 }
97- for _, want := range []string{"usage: vev cmd", "split-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {99+ for _, want := range []string{"usage: vev cmd", "split-right", "consume-or-expel-pane-left", "consume-or-expel-pane-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {
98 if !strings.Contains(out.String(), want) {100 if !strings.Contains(out.String(), want) {
99 t.Fatalf("help %q missing %q", out.String(), want)101 t.Fatalf("help %q missing %q", out.String(), want)
100 }102 }
@@ -173,8 +175,11 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
173 }175 }
174 }176 }
175 177
176-func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) {178+func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
177- for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {179+ for _, slug := range []string{
180+ "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
181+ "consume-or-expel-pane-left", "consume-or-expel-pane-right",
182+ } {
178 t.Run(slug, func(t *testing.T) {183 t.Run(slug, func(t *testing.T) {
179 invocation, err := parseArgs([]string{"cmd", "--self", slug})184 invocation, err := parseArgs([]string{"cmd", "--self", slug})
180 require.NoError(t, err)185 require.NoError(t, err)
M internal/usecase/command/command.go +4 -0
@@ -44,6 +44,8 @@ type Context interface {
44 SplitLeft() error44 SplitLeft() error
45 SplitUp() error45 SplitUp() error
46 SplitDown() error46 SplitDown() error
47+ ConsumeOrExpelPaneLeft() error
48+ ConsumeOrExpelPaneRight() error
47 StackPane() error49 StackPane() error
48 ToggleStack() error50 ToggleStack() error
49 ToggleFloatingPane() error51 ToggleFloatingPane() error
@@ -131,6 +133,8 @@ type ControlContext interface {
131 SplitLeft() error133 SplitLeft() error
132 SplitUp() error134 SplitUp() error
133 SplitDown() error135 SplitDown() error
136+ ConsumeOrExpelPaneLeft() error
137+ ConsumeOrExpelPaneRight() error
134 StackPane() error138 StackPane() error
135 ToggleStack() error139 ToggleStack() error
136 GrowPaneWidth() error140 GrowPaneWidth() error
M internal/usecase/command/context_mock_test.go +88 -0
@@ -167,6 +167,94 @@ func (_c *MockContext_CloseTab_Call) RunAndReturn(run func() error) *MockContext
167 return _c167 return _c
168 }168 }
169 169
170+// ConsumeOrExpelPaneLeft provides a mock function for the type MockContext
171+func (_mock *MockContext) ConsumeOrExpelPaneLeft() error {
172+ ret := _mock.Called()
173+
174+ if len(ret) == 0 {
175+ panic("no return value specified for ConsumeOrExpelPaneLeft")
176+ }
177+
178+ var r0 error
179+ if returnFunc, ok := ret.Get(0).(func() error); ok {
180+ r0 = returnFunc()
181+ } else {
182+ r0 = ret.Error(0)
183+ }
184+ return r0
185+}
186+
187+// MockContext_ConsumeOrExpelPaneLeft_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneLeft'
188+type MockContext_ConsumeOrExpelPaneLeft_Call struct {
189+ *mock.Call
190+}
191+
192+// ConsumeOrExpelPaneLeft is a helper method to define mock.On call
193+func (_e *MockContext_Expecter) ConsumeOrExpelPaneLeft() *MockContext_ConsumeOrExpelPaneLeft_Call {
194+ return &MockContext_ConsumeOrExpelPaneLeft_Call{Call: _e.mock.On("ConsumeOrExpelPaneLeft")}
195+}
196+
197+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneLeft_Call {
198+ _c.Call.Run(func(args mock.Arguments) {
199+ run()
200+ })
201+ return _c
202+}
203+
204+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) Return(err error) *MockContext_ConsumeOrExpelPaneLeft_Call {
205+ _c.Call.Return(err)
206+ return _c
207+}
208+
209+func (_c *MockContext_ConsumeOrExpelPaneLeft_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneLeft_Call {
210+ _c.Call.Return(run)
211+ return _c
212+}
213+
214+// ConsumeOrExpelPaneRight provides a mock function for the type MockContext
215+func (_mock *MockContext) ConsumeOrExpelPaneRight() error {
216+ ret := _mock.Called()
217+
218+ if len(ret) == 0 {
219+ panic("no return value specified for ConsumeOrExpelPaneRight")
220+ }
221+
222+ var r0 error
223+ if returnFunc, ok := ret.Get(0).(func() error); ok {
224+ r0 = returnFunc()
225+ } else {
226+ r0 = ret.Error(0)
227+ }
228+ return r0
229+}
230+
231+// MockContext_ConsumeOrExpelPaneRight_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ConsumeOrExpelPaneRight'
232+type MockContext_ConsumeOrExpelPaneRight_Call struct {
233+ *mock.Call
234+}
235+
236+// ConsumeOrExpelPaneRight is a helper method to define mock.On call
237+func (_e *MockContext_Expecter) ConsumeOrExpelPaneRight() *MockContext_ConsumeOrExpelPaneRight_Call {
238+ return &MockContext_ConsumeOrExpelPaneRight_Call{Call: _e.mock.On("ConsumeOrExpelPaneRight")}
239+}
240+
241+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Run(run func()) *MockContext_ConsumeOrExpelPaneRight_Call {
242+ _c.Call.Run(func(args mock.Arguments) {
243+ run()
244+ })
245+ return _c
246+}
247+
248+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) Return(err error) *MockContext_ConsumeOrExpelPaneRight_Call {
249+ _c.Call.Return(err)
250+ return _c
251+}
252+
253+func (_c *MockContext_ConsumeOrExpelPaneRight_Call) RunAndReturn(run func() error) *MockContext_ConsumeOrExpelPaneRight_Call {
254+ _c.Call.Return(run)
255+ return _c
256+}
257+
170 // CreateSession provides a mock function for the type MockContext258 // CreateSession provides a mock function for the type MockContext
171 func (_mock *MockContext) CreateSession() error {259 func (_mock *MockContext) CreateSession() error {
172 ret := _mock.Called()260 ret := _mock.Called()
M internal/usecase/command/registry.go +2 -0
@@ -12,6 +12,8 @@ func Registry() []Command {
12 paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),12 paletteControl("split-left", "SPL", "Split left", "Split the focused pane to the left", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitLeft() }, func(ctx ControlContext) error { return ctx.SplitLeft() }),
13 paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }),13 paletteControl("split-up", "SPU", "Split up", "Split the focused pane upward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitUp() }, func(ctx ControlContext) error { return ctx.SplitUp() }),
14 paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }),14 paletteControl("split-down", "SPD", "Split down", "Split the focused pane downward", TargetPane, func(ctx Context, _ []string) error { return ctx.SplitDown() }, func(ctx ControlContext) error { return ctx.SplitDown() }),
15+ paletteControl("consume-or-expel-pane-left", "CEL", "Consume or expel pane left", "Move the focused pane into or out of the column on the left", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneLeft() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneLeft() }),
16+ paletteControl("consume-or-expel-pane-right", "CER", "Consume or expel pane right", "Move the focused pane into or out of the column on the right", TargetPane, func(ctx Context, _ []string) error { return ctx.ConsumeOrExpelPaneRight() }, func(ctx ControlContext) error { return ctx.ConsumeOrExpelPaneRight() }),
15 paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }),17 paletteControl("stack-pane", "STP", "Stack pane", "Create a new pane in a stack", TargetPane, func(ctx Context, _ []string) error { return ctx.StackPane() }, func(ctx ControlContext) error { return ctx.StackPane() }),
16 paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }),18 paletteControl("toggle-stack", "TST", "Toggle stack", "Toggle the focused pane stack", TargetPane, func(ctx Context, _ []string) error { return ctx.ToggleStack() }, func(ctx ControlContext) error { return ctx.ToggleStack() }),
17 paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),19 paletteOnly("toggle-floating-pane", "FLT", "Toggle floating pane", "Toggle the floating pane", func(ctx Context, _ []string) error { return ctx.ToggleFloatingPane() }),
M internal/usecase/command/registry_test.go +11 -2
@@ -9,7 +9,7 @@ import (
9 9
10 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {10 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
11 commands := PaletteRegistry()11 commands := PaletteRegistry()
12- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}12+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "NXT", "PVT", "BSK", "JRS", "SSP", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}
13 13
14 if len(commands) != len(wantCodes) {14 if len(commands) != len(wantCodes) {
15 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))15 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -115,6 +115,8 @@ func TestBySlugExactMatch(t *testing.T) {
115 ok bool115 ok bool
116 }{116 }{
117 {slug: "split-right", ok: true},117 {slug: "split-right", ok: true},
118+ {slug: "consume-or-expel-pane-left", ok: true},
119+ {slug: "consume-or-expel-pane-right", ok: true},
118 {slug: "SPLIT-RIGHT", ok: false},120 {slug: "SPLIT-RIGHT", ok: false},
119 {slug: "toast", ok: true},121 {slug: "toast", ok: true},
120 {slug: "list-panes", ok: true},122 {slug: "list-panes", ok: true},
@@ -132,7 +134,8 @@ func TestBySlugExactMatch(t *testing.T) {
132 func TestRegistryControlMetadata(t *testing.T) {134 func TestRegistryControlMetadata(t *testing.T) {
133 scriptable := map[string]TargetKind{135 scriptable := map[string]TargetKind{
134 "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane,136 "split-right": TargetPane, "split-left": TargetPane, "split-up": TargetPane,
135- "split-down": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,137+ "split-down": TargetPane, "consume-or-expel-pane-left": TargetPane,
138+ "consume-or-expel-pane-right": TargetPane, "stack-pane": TargetPane, "toggle-stack": TargetPane,
136 "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,139 "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,
137 "focus-pane-up": TargetPane, "focus-pane-down": TargetPane,140 "focus-pane-up": TargetPane, "focus-pane-down": TargetPane,
138 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,141 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
@@ -217,6 +220,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
217 }{220 }{
218 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},221 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
219 {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},222 {name: "zero argument mutation rejects args", slug: "split-right", args: []string{"extra"}, wantErr: ErrInvalidArguments},
223+ {name: "consume or expel left delegates", slug: "consume-or-expel-pane-left", wantCall: "consume-or-expel-pane-left"},
224+ {name: "consume or expel right delegates", slug: "consume-or-expel-pane-right", wantCall: "consume-or-expel-pane-right"},
220 {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},225 {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},
221 {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"},226 {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"},
222 {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"},227 {name: "grow height delegates", slug: "grow-pane-height", wantCall: "grow-pane-height"},
@@ -267,6 +272,8 @@ func (s *controlSpy) SplitRight() error { return s.record("split-ri
267 func (s *controlSpy) SplitLeft() error { return s.record("split-left") }272 func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
268 func (s *controlSpy) SplitUp() error { return s.record("split-up") }273 func (s *controlSpy) SplitUp() error { return s.record("split-up") }
269 func (s *controlSpy) SplitDown() error { return s.record("split-down") }274 func (s *controlSpy) SplitDown() error { return s.record("split-down") }
275+func (s *controlSpy) ConsumeOrExpelPaneLeft() error { return s.record("consume-or-expel-pane-left") }
276+func (s *controlSpy) ConsumeOrExpelPaneRight() error { return s.record("consume-or-expel-pane-right") }
270 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }277 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
271 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }278 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
272 func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }279 func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
@@ -360,6 +367,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
360 {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},367 {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},
361 {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},368 {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},
362 {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},369 {code: "SPD", expect: func(ctx *MockContext) { ctx.EXPECT().SplitDown().Return(nil).Once() }},
370+ {code: "CEL", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneLeft().Return(nil).Once() }},
371+ {code: "CER", expect: func(ctx *MockContext) { ctx.EXPECT().ConsumeOrExpelPaneRight().Return(nil).Once() }},
363 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},372 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
364 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},373 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
365 {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},374 {code: "FLT", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleFloatingPane().Return(nil).Once() }},
M internal/usecase/daemon/control.go +20 -2
@@ -226,6 +226,7 @@ const (
226 daemonActionRenameTab226 daemonActionRenameTab
227 daemonActionResizePane227 daemonActionResizePane
228 daemonActionEqualizePanes228 daemonActionEqualizePanes
229+ daemonActionConsumeOrExpelPane
229 )230 )
230 231
231 type daemonActionTarget struct {232 type daemonActionTarget struct {
@@ -309,6 +310,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
309 return a.d.resizePane(target, request.axis, request.delta)310 return a.d.resizePane(target, request.axis, request.delta)
310 case daemonActionEqualizePanes:311 case daemonActionEqualizePanes:
311 return a.d.equalizePanes(target)312 return a.d.equalizePanes(target)
313+ case daemonActionConsumeOrExpelPane:
314+ return a.d.consumeOrExpelPane(target, request.direction)
312 default:315 default:
313 return errors.New("daemon: unknown action")316 return errors.New("daemon: unknown action")
314 }317 }
@@ -365,13 +368,19 @@ func (e controlExec) runAction(request daemonActionRequest) error {
365 runner = daemonActions{d: e.d}368 runner = daemonActions{d: e.d}
366 }369 }
367 err := runner.Run(request)370 err := runner.Run(request)
368- if err == nil && e.actions == nil {371+ if errors.Is(err, errDaemonActionNoChange) {
372+ return nil
373+ }
374+ if err != nil {
375+ return err
376+ }
377+ if e.actions == nil {
369 e.sess.mu.Lock()378 e.sess.mu.Lock()
370 ac := e.sess.client379 ac := e.sess.client
371 e.sess.mu.Unlock()380 e.sess.mu.Unlock()
372 finishDaemonActionForClient(e.d, request, ac, "control.go")381 finishDaemonActionForClient(e.d, request, ac, "control.go")
373 }382 }
374- return err383+ return nil
375 }384 }
376 385
377 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {386 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
@@ -465,6 +474,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) }
465 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }474 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
466 func (e controlExec) SplitUp() error { return e.split(layout.Up) }475 func (e controlExec) SplitUp() error { return e.split(layout.Up) }
467 func (e controlExec) SplitDown() error { return e.split(layout.Down) }476 func (e controlExec) SplitDown() error { return e.split(layout.Down) }
477+func (e controlExec) consumeOrExpelPane(direction layout.Direction) error {
478+ return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
479+}
480+func (e controlExec) ConsumeOrExpelPaneLeft() error {
481+ return e.consumeOrExpelPane(layout.Left)
482+}
483+func (e controlExec) ConsumeOrExpelPaneRight() error {
484+ return e.consumeOrExpelPane(layout.Right)
485+}
468 func (e controlExec) StackPane() error {486 func (e controlExec) StackPane() error {
469 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})487 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
470 }488 }
M internal/usecase/daemon/control_test.go +92 -21
@@ -16,28 +16,99 @@ import (
16 )16 )
17 17
18 func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {18 func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
19- d := newTestDaemon(t, nil, stubClock{})19+ tests := []struct {
20- sess := addControlSession(d, "work", "t_work", "p_work")20+ name string
21- tb := sess.activeTab()21+ palette func(paletteExec) error
22- tb.mu.Lock()22+ control func(controlExec) error
23- pane := tb.focusedPane()23+ kind daemonActionKind
24- tb.mu.Unlock()24+ direction layout.Direction
25- spy := &actionRunnerSpy{}25+ }{
26-26+ {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right},
27- require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight())27+ {name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left},
28- require.NoError(t, (controlExec{28+ {name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right},
29- d: d, sess: sess, tab: tb, actions: spy,
30- target: daemonActionTarget{session: sess, tab: tb, pane: pane},
31- }).SplitRight())
32-
33- require.Len(t, spy.requests, 2)
34- for _, request := range spy.requests {
35- require.Equal(t, daemonActionSplitPane, request.kind)
36- require.Same(t, sess, request.target.session)
37- require.Same(t, tb, request.target.tab)
38- require.Same(t, pane, request.target.pane)
39- require.Equal(t, layout.Right, request.direction)
40 }29 }
30+
31+ for _, tt := range tests {
32+ t.Run(tt.name, func(t *testing.T) {
33+ d := newTestDaemon(t, nil, stubClock{})
34+ sess := addControlSession(d, "work", "t_work", "p_work")
35+ tb := sess.activeTab()
36+ tb.mu.Lock()
37+ pane := tb.focusedPane()
38+ tb.mu.Unlock()
39+ spy := &actionRunnerSpy{}
40+ target := daemonActionTarget{session: sess, tab: tb, pane: pane}
41+
42+ require.NoError(t, tt.palette(paletteExec{d: d, sess: sess, actions: spy}))
43+ require.NoError(t, tt.control(controlExec{d: d, sess: sess, tab: tb, actions: spy, target: target}))
44+
45+ require.Len(t, spy.requests, 2)
46+ for _, request := range spy.requests {
47+ require.Equal(t, tt.kind, request.kind)
48+ require.Same(t, sess, request.target.session)
49+ require.Same(t, tb, request.target.tab)
50+ require.Same(t, pane, request.target.pane)
51+ require.Equal(t, tt.direction, request.direction)
52+ }
53+ })
54+ }
55+}
56+
57+func TestConsumeOrExpelControlSelfTargetsNonFocusedPane(t *testing.T) {
58+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
59+ h.tab.mu.Lock()
60+ h.tab.tree.Focus = "pane-1"
61+ beforeGeneration := h.tab.layoutGeneration
62+ tabStableID := h.tab.stableID
63+ targetStableID := h.panes["pane-3"].stableID
64+ h.tab.mu.Unlock()
65+
66+ result := sendCommand(t, h.daemon, ports.CommandRequest{
67+ Slug: "consume-or-expel-pane-left",
68+ Self: true,
69+ TargetSession: "work",
70+ TargetTab: tabStableID,
71+ TargetPane: targetStableID,
72+ })
73+
74+ require.True(t, result.OK, result.Text)
75+ require.Empty(t, result.Output)
76+ h.session.mu.Lock()
77+ require.Zero(t, h.session.active, "--self must not change the active tab")
78+ h.session.mu.Unlock()
79+ h.tab.mu.Lock()
80+ require.Equal(t, layout.PaneID("pane-3"), h.tab.tree.Focus, "a moved explicit target becomes focused")
81+ require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
82+ require.Len(t, h.tab.tree.Root.Children, 2)
83+ h.tab.mu.Unlock()
84+}
85+
86+func TestConsumeOrExpelControlEdgeNoopReturnsOKAndPreservesFocus(t *testing.T) {
87+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
88+ ac := &attachedClient{}
89+ ac.setSession(h.session)
90+ h.session.mu.Lock()
91+ h.session.client = ac
92+ h.session.mu.Unlock()
93+ invalidations := make(chan renderInvalidation, 1)
94+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
95+ rc.attach(ac)
96+ h.session.installRenderCoordinator(rc)
97+ before := h.snapshot()
98+
99+ result := sendCommand(t, h.daemon, ports.CommandRequest{
100+ Slug: "consume-or-expel-pane-left",
101+ Self: true,
102+ TargetSession: "work",
103+ TargetTab: h.tab.stableID,
104+ TargetPane: h.panes["pane-1"].stableID,
105+ })
106+
107+ require.True(t, result.OK, result.Text)
108+ require.Empty(t, result.Output)
109+ require.Equal(t, before, h.snapshot())
110+ require.Empty(t, h.daemon.notices.history())
111+ requireNoInvalidation(t, invalidations)
41 }112 }
42 113
43 type actionRunnerSpy struct {114 type actionRunnerSpy struct {
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
100 func TestCopySearchModalGeometry(t *testing.T) {100 func TestCopySearchModalGeometry(t *testing.T) {
101 base := domain.Size{Cols: 100, Rows: 40}101 base := domain.Size{Cols: 100, Rows: 40}
102 102
103- require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base))103+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Resolve(base).Bounds)
104 require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)104 require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106 }106 }
M internal/usecase/daemon/floating.go +3 -7
@@ -144,7 +144,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane,
144 p.mu.Lock()144 p.mu.Lock()
145 geometry := p.committedFloatingGeometryLocked(desired)145 geometry := p.committedFloatingGeometryLocked(desired)
146 p.mu.Unlock()146 p.mu.Unlock()
147- if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {147+ if !geometry.committable() {
148 return nil, floatingGeometry{}, false148 return nil, floatingGeometry{}, false
149 }149 }
150 return p, geometry, true150 return p, geometry, true
@@ -345,11 +345,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
345 sess.mu.Unlock()345 sess.mu.Unlock()
346 tb.mu.Lock()346 tb.mu.Lock()
347 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)347 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)
348- size := rectSize(geometry.Inner)348+ size := rectSize(geometry.ptyRect())
349- if !size.Valid() {
350- geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}}
351- size = rectSize(geometry.Inner)
352- }
353 focused := tb.focusedPane()349 focused := tb.focusedPane()
354 tabStableID, tabCtx := tb.stableID, tb.ctx350 tabStableID, tabCtx := tb.stableID, tb.ctx
355 tb.mu.Unlock()351 tb.mu.Unlock()
@@ -419,7 +415,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
419 return415 return
420 }416 }
421 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)417 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
422- p.rect = spec.geometry.Inner418+ p.rect = spec.geometry.ptyRect()
423 p.popupGeometry = spec.geometry419 p.popupGeometry = spec.geometry
424 p.title.displayFallback = spec.fallback420 p.title.displayFallback = spec.fallback
425 if !lifetime.publish(p) {421 if !lifetime.publish(p) {
M internal/usecase/daemon/floating_render.go +191 -27
@@ -13,6 +13,7 @@ import (
13 // translates both fields and returns/caches frame-absolute geometry. Callers13 // translates both fields and returns/caches frame-absolute geometry. Callers
14 // must not translate a composed or cached value again.14 // must not translate a composed or cached value again.
15 type floatingGeometry struct {15 type floatingGeometry struct {
16+ Mode ui.PresentationMode
16 Bounds domain.Rect17 Bounds domain.Rect
17 Inner domain.Rect18 Inner domain.Rect
18 }19 }
@@ -21,6 +22,22 @@ func (g floatingGeometry) valid() bool {
21 return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 022 return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
22 }23 }
23 24
25+// committable accepts a drawer with no terminal content. Its presentation must
26+// remain canonical even though the PTY and VT screen use a 1x1 fallback.
27+func (g floatingGeometry) committable() bool {
28+ if g.Mode == ui.PresentationDrawer {
29+ return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0
30+ }
31+ return g.valid()
32+}
33+
34+func (g floatingGeometry) ptyRect() domain.Rect {
35+ if g.Inner.Width > 0 && g.Inner.Height > 0 {
36+ return g.Inner
37+ }
38+ return domain.Rect{Width: 1, Height: 1}
39+}
40+
24 func (g floatingGeometry) translate(dx, dy int) floatingGeometry {41 func (g floatingGeometry) translate(dx, dy int) floatingGeometry {
25 g.Bounds.X += dx42 g.Bounds.X += dx
26 g.Bounds.Y += dy43 g.Bounds.Y += dy
@@ -33,7 +50,7 @@ func (g floatingGeometry) translate(dx, dy int) floatingGeometry {
33 // last committed. Newly constructed test panes fall back to the requested50 // last committed. Newly constructed test panes fall back to the requested
34 // geometry until their first resize. The caller holds p.mu.51 // geometry until their first resize. The caller holds p.mu.
35 func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {52 func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {
36- if p.popupGeometry.valid() {53+ if p.popupGeometry.committable() {
37 return p.popupGeometry54 return p.popupGeometry
38 }55 }
39 return fallback56 return fallback
@@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry
58 return axis75 return axis
59 }76 }
60 77
61-// calculateContentFloatingGeometry returns origin-zero, tab-content-relative78+// calculatePreferredFloatingGeometry retains the canonical percentage and
62-// coordinates for a percentage-sized popup. Launch sizing derives from Inner79+// tiny-axis rules used by wide floating terminals.
63-// too, so rendering and PTY geometry share the same per-axis percentage and80+func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
64-// tiny-border rules.
65-func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
66 if !content.Valid() {81 if !content.Valid() {
67 return floatingGeometry{}82 return floatingGeometry{}
68 }83 }
@@ -75,6 +90,7 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
75 Height: y.BoundsSize,90 Height: y.BoundsSize,
76 }91 }
77 return floatingGeometry{92 return floatingGeometry{
93+ Mode: ui.PresentationFloating,
78 Bounds: bounds,94 Bounds: bounds,
79 Inner: domain.Rect{95 Inner: domain.Rect{
80 X: bounds.X + x.BorderOffset,96 X: bounds.X + x.BorderOffset,
@@ -85,12 +101,33 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
85 }101 }
86 }102 }
87 103
104+// calculateContentFloatingGeometry returns canonical tab-content-relative
105+// coordinates. Presentation resolution consumes complete-frame coordinates,
106+// so the preferred geometry crosses the top-bar boundary exactly once in each
107+// direction.
108+func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
109+ preferred := calculatePreferredFloatingGeometry(content, cfg)
110+ if !preferred.valid() {
111+ return floatingGeometry{}
112+ }
113+ framePreferred := preferred.translate(0, 1)
114+ presentation := ui.ResolvePresentation(
115+ domain.Size{Cols: content.Cols, Rows: content.Rows + 2},
116+ framePreferred.Bounds,
117+ framePreferred.Inner,
118+ )
119+ return floatingGeometry{
120+ Mode: presentation.Mode,
121+ Bounds: presentation.Bounds,
122+ Inner: presentation.Inner,
123+ }.translate(0, -1)
124+}
125+
88 type floatingComposeInput struct {126 type floatingComposeInput struct {
89 baseFrame renderer.Frame127 baseFrame renderer.Frame
90 baseDamage []renderer.Damage128 baseDamage []renderer.Damage
91 floating capturedFloatingRenderState129 floating capturedFloatingRenderState
92 content domain.Rect130 content domain.Rect
93- layout capturedTabLayout
94 theme themeui.Theme131 theme themeui.Theme
95 borderMuted renderer.Style132 borderMuted renderer.Style
96 borderActive renderer.Style133 borderActive renderer.Style
@@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
103 baseDamage := input.baseDamage140 baseDamage := input.baseDamage
104 floating := input.floating141 floating := input.floating
105 content := input.content142 content := input.content
106- layoutSnap := input.layout
107 theme := input.theme143 theme := input.theme
108 borderMuted := input.borderMuted144 borderMuted := input.borderMuted
109 borderActive := input.borderActive145 borderActive := input.borderActive
@@ -111,10 +147,9 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
111 full := input.full147 full := input.full
112 148
113 frame := base.Clone()149 frame := base.Clone()
114- layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}150+ applyOverlayBackdrop(frame, theme)
115- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme)
116 geometry := floating.geometry.translate(content.X, content.Y)151 geometry := floating.geometry.translate(content.X, content.Y)
117- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))152+ blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
118 damage := append([]renderer.Damage(nil), baseDamage...)153 damage := append([]renderer.Damage(nil), baseDamage...)
119 for _, d := range floating.pane.damage {154 for _, d := range floating.pane.damage {
120 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)155 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
@@ -125,39 +160,168 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
125 if floating.focused {160 if floating.focused {
126 border = borderActive161 border = borderActive
127 }162 }
128- drawFloatingBorder(frame, geometry.Bounds, floating.title, border)163+ drawFloatingBorder(frame, geometry, floating.title, border)
129 if full || popupChanged {164 if full || popupChanged {
130 return frame, []renderer.Damage{renderer.FullRedraw()}165 return frame, []renderer.Damage{renderer.FullRedraw()}
131 }166 }
132- if titleChanged && geometry.Bounds.Height >= 3 {167+ hasTitleBorder := geometry.Bounds.Height >= 3
133- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})168+ if geometry.Mode == ui.PresentationDrawer {
169+ hasTitleBorder = geometry.Bounds.Height > 0
170+ }
171+ if titleChanged && hasTitleBorder {
172+ if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
173+ damage = append(damage, titleDamage)
174+ }
134 }175 }
135 return frame, damage176 return frame, damage
136 }177 }
137 178
138-func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {179+type floatingSpan struct {
180+ source, destination, length int
181+}
182+
183+// clippedFloatingSpan intersects a source span placed at origin with the
184+// destination interval [0, limit). The source offset preserves pane cell
185+// mapping when the retained committed geometry starts above or left of frame.
186+func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) {
187+ if length <= 0 || limit <= 0 {
188+ return floatingSpan{}, false
189+ }
190+ if origin < 0 {
191+ if origin <= -length {
192+ return floatingSpan{}, false
193+ }
194+ trim := -origin
195+ length -= trim
196+ origin = 0
197+ if length <= 0 {
198+ return floatingSpan{}, false
199+ }
200+ return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true
201+ }
202+ if origin >= limit {
203+ return floatingSpan{}, false
204+ }
205+ length = min(length, limit-origin)
206+ return floatingSpan{destination: origin, length: length}, length > 0
207+}
208+
209+func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) {
210+ x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width)
211+ if !ok {
212+ return
213+ }
214+ y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height)
215+ if !ok {
216+ return
217+ }
218+ for dy := range y.length {
219+ for dx := range x.length {
220+ dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy))
221+ }
222+ }
223+}
224+
225+func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) {
226+ if bounds.Y < 0 || bounds.Y >= frame.Height {
227+ return renderer.Damage{}, false
228+ }
229+ x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width)
230+ if !ok {
231+ return renderer.Damage{}, false
232+ }
233+ return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true
234+}
235+
236+func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
237+ bounds := geometry.Bounds
139 if bounds.Width <= 0 || bounds.Height <= 0 {238 if bounds.Width <= 0 || bounds.Height <= 0 {
140 return239 return
141 }240 }
241+ if geometry.Mode == ui.PresentationDrawer {
242+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
243+ drawFloatingTitle(frame, bounds, title, style)
244+ return
245+ }
142 // Each axis omits its borders independently for tiny popups, matching246 // Each axis omits its borders independently for tiny popups, matching
143 // floatingInnerSize and leaving every cell available to the terminal.247 // floatingInnerSize and leaving every cell available to the terminal.
248+ right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width)
249+ bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height)
144 if bounds.Height >= 3 {250 if bounds.Height >= 3 {
145- for x := bounds.X; x < bounds.X+bounds.Width; x++ {251+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
146- frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})252+ if hasBottom {
147- frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style})253+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
148 }254 }
149 }255 }
150 if bounds.Width >= 3 {256 if bounds.Width >= 3 {
151- for y := bounds.Y; y < bounds.Y+bounds.Height; y++ {257+ drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
152- frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style})258+ if hasRight {
153- frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style})259+ drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
154 }260 }
155 }261 }
156- if bounds.Width >= 3 && bounds.Height >= 3 {262+ if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom {
157- frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style})263+ setFloatingCell(frame, bounds.X, bounds.Y, '┌', style)
158- frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style})264+ setFloatingCell(frame, right, bounds.Y, '┐', style)
159- frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style})265+ setFloatingCell(frame, bounds.X, bottom, '└', style)
160- frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style})266+ setFloatingCell(frame, right, bottom, '┘', style)
161- ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)267+ drawFloatingTitle(frame, bounds, title, style)
268+ }
269+}
270+
271+func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) {
272+ if y < 0 || y >= frame.Height {
273+ return
274+ }
275+ span, ok := clippedFloatingSpan(x, width, frame.Width)
276+ if !ok {
277+ return
278+ }
279+ for dx := range span.length {
280+ frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style})
281+ }
282+}
283+
284+func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) {
285+ if x < 0 || x >= frame.Width {
286+ return
287+ }
288+ span, ok := clippedFloatingSpan(y, height, frame.Height)
289+ if !ok {
290+ return
291+ }
292+ for dy := range span.length {
293+ frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style})
294+ }
295+}
296+
297+func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) {
298+ if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height {
299+ return
300+ }
301+ frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
302+}
303+
304+func floatingLastCoordinate(origin, size int) (int, bool) {
305+ if size <= 0 {
306+ return 0, false
307+ }
308+ last := origin + size - 1
309+ if last < origin {
310+ return 0, false
311+ }
312+ return last, true
313+}
314+
315+func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
316+ const inset = 2
317+ titleWidth := bounds.Width - 2*inset
318+ if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset {
319+ return
320+ }
321+ titleX := bounds.X + inset
322+ span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width)
323+ if !ok {
324+ return
162 }325 }
326+ ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
163 }327 }
M internal/usecase/daemon/floating_render_test.go +284 -13
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
14 themeui "github.com/bnema/vev/internal/usecase/theme"14 themeui "github.com/bnema/vev/internal/usecase/theme"
15+ "github.com/bnema/vev/internal/usecase/ui"
15 "github.com/bnema/vev/pkg/renderer"16 "github.com/bnema/vev/pkg/renderer"
16 )17 )
17 18
@@ -39,21 +40,66 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr
39 40
40 func TestFloatingGeometryTranslate(t *testing.T) {41 func TestFloatingGeometryTranslate(t *testing.T) {
41 geometry := floatingGeometry{42 geometry := floatingGeometry{
43+ Mode: ui.PresentationDrawer,
42 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},44 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
43 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},45 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},
44 }46 }
45 47
46 translated := geometry.translate(11, 13)48 translated := geometry.translate(11, 13)
47 require.Equal(t, floatingGeometry{49 require.Equal(t, floatingGeometry{
50+ Mode: ui.PresentationDrawer,
48 Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8},51 Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8},
49 Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6},52 Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6},
50 }, translated)53 }, translated)
51 require.Equal(t, floatingGeometry{54 require.Equal(t, floatingGeometry{
55+ Mode: ui.PresentationDrawer,
52 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},56 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
53 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},57 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},
54 }, geometry)58 }, geometry)
55 }59 }
56 60
61+func TestResponsiveFloatingGeometry(t *testing.T) {
62+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
63+ tests := []struct {
64+ name string
65+ content domain.Size
66+ want floatingGeometry
67+ }{
68+ {
69+ name: "narrow full height becomes content-relative drawer",
70+ content: domain.Size{Cols: 79, Rows: 22},
71+ want: floatingGeometry{
72+ Mode: ui.PresentationDrawer,
73+ Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 20},
74+ Inner: domain.Rect{X: 0, Y: 3, Width: 79, Height: 19},
75+ },
76+ },
77+ {
78+ name: "breakpoint preserves centered floating geometry",
79+ content: domain.Size{Cols: 80, Rows: 22},
80+ want: floatingGeometry{
81+ Mode: ui.PresentationFloating,
82+ Bounds: domain.Rect{Width: 80, Height: 22},
83+ Inner: domain.Rect{X: 1, Y: 1, Width: 78, Height: 20},
84+ },
85+ },
86+ {
87+ name: "tiny narrow frame retains zero-content drawer",
88+ content: domain.Size{Cols: 79, Rows: 2},
89+ want: floatingGeometry{
90+ Mode: ui.PresentationDrawer,
91+ Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0},
92+ Inner: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0},
93+ },
94+ },
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, cfg))
99+ })
100+ }
101+}
102+
57 func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {103 func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {
58 const frameX, frameY = 7, 3104 const frameX, frameY = 7, 3
59 tests := []struct {105 tests := []struct {
@@ -93,26 +139,77 @@ func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {
93 }139 }
94 }140 }
95 141
96-func TestCalculateFloatingGeometry(t *testing.T) {142+func TestCalculatePreferredFloatingGeometry(t *testing.T) {
97 tests := []struct {143 tests := []struct {
98 name string144 name string
99 content domain.Size145 content domain.Size
100 cfg domain.FloatingConfig146 cfg domain.FloatingConfig
101 want floatingGeometry147 want floatingGeometry
102 }{148 }{
103- {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}},149+ {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}},
104- {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}},150+ {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}},
105- {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}},151+ {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}},
106- {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}},152+ {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}},
107- {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},153+ {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},
108 }154 }
109 for _, tt := range tests {155 for _, tt := range tests {
110 t.Run(tt.name, func(t *testing.T) {156 t.Run(tt.name, func(t *testing.T) {
111- require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, tt.cfg))157+ require.Equal(t, tt.want, calculatePreferredFloatingGeometry(tt.content, tt.cfg))
112 })158 })
113 }159 }
114 }160 }
115 161
162+func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testing.T) {
163+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
164+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)
165+ require.False(t, geometry.valid())
166+ require.True(t, geometry.committable())
167+ require.Equal(t, domain.Rect{Width: 1, Height: 1}, geometry.ptyRect())
168+
169+ d := newTestDaemon(t, nil, stubClock{})
170+ d.ApplyConfig(domain.Config{Floating: cfg})
171+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
172+ sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()}
173+ spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true)
174+ require.NoError(t, err)
175+ require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation")
176+ require.Equal(t, domain.Size{Cols: 1, Rows: 1}, spec.size)
177+
178+ floating := newPane("floating", nil, spec.size)
179+ floating.popupGeometry = spec.geometry
180+ installTestFloating(tb, floating, true)
181+ tb.mu.Lock()
182+ gotPane, gotGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
183+ require.Same(t, floating, tb.terminalTargetLocked())
184+ tb.mu.Unlock()
185+ require.True(t, visible)
186+ require.Same(t, floating, gotPane)
187+ require.Equal(t, geometry, gotGeometry)
188+}
189+
190+func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) {
191+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
192+ initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
193+ requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)
194+ pty := &resizePTY{}
195+ floating := newPane("floating", pty, rectSize(initial.Inner))
196+ floating.rect = initial.Inner
197+ floating.popupGeometry = initial
198+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
199+ installTestFloating(tb, floating, true)
200+ d := newTestDaemon(t, nil, stubClock{})
201+ d.ApplyConfig(domain.Config{Floating: cfg})
202+
203+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
204+ require.True(t, ok)
205+ require.Empty(t, failed)
206+ require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize")
207+ require.Equal(t, requested, floating.popupGeometry)
208+ require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size")
209+ require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width)
210+ require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height)
211+}
212+
116 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {213 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
117 tests := []struct {214 tests := []struct {
118 name string215 name string
@@ -133,9 +230,9 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
133 }230 }
134 231
135 func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {232 func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
136- base := renderer.NewFrame(40, 12)233+ base := renderer.NewFrame(80, 12)
137 base.Set(2, 2, renderer.Cell{Rune: 'B'})234 base.Set(2, 2, renderer.Cell{Rune: 'B'})
138- content := domain.Rect{Y: 1, Width: 40, Height: 10}235+ content := domain.Rect{Y: 1, Width: 80, Height: 10}
139 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})236 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
140 paneFrame := renderer.NewFrame(6, 3)237 paneFrame := renderer.NewFrame(6, 3)
141 paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})238 paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})
@@ -181,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi
181 require.Equal(t, 1, damage[0].Height)278 require.Equal(t, 1, damage[0].Height)
182 }279 }
183 280
281+func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *testing.T) {
282+ base := renderer.NewFrame(15, 7)
283+ for y := range base.Height {
284+ for x := range base.Width {
285+ base.Set(x, y, renderer.Cell{Rune: '·'})
286+ }
287+ }
288+ content := domain.Rect{Y: 1, Width: 15, Height: 5}
289+ retained := calculateContentFloatingGeometry(
290+ domain.Size{Cols: 100, Rows: 30},
291+ domain.FloatingConfig{Width: 80, Height: 80},
292+ )
293+ pty := &resizePTY{err: errors.New("resize failed")}
294+ floatingPane := newPane("floating", pty, rectSize(retained.Inner))
295+ floatingPane.rect = retained.Inner
296+ floatingPane.popupGeometry = retained
297+ for y := range floatingPane.screen.Frame.Height {
298+ for x := range floatingPane.screen.Frame.Width {
299+ floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})
300+ }
301+ }
302+ requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80})
303+ d := newTestDaemon(t, nil, stubClock{})
304+ require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested))
305+ require.Equal(t, retained, floatingPane.popupGeometry, "failed resize must retain the committed geometry")
306+ floatingPane.mu.Lock()
307+ captured := capturePaneRenderStateLocked(floatingPane, retained.Inner)
308+ floatingPane.mu.Unlock()
309+
310+ var frame renderer.Frame
311+ require.NotPanics(t, func() {
312+ frame, _ = composeCapturedFloatingFrame(floatingComposeInput{
313+ baseFrame: base,
314+ floating: capturedFloatingRenderState{
315+ visible: true,
316+ pane: captured,
317+ geometry: retained,
318+ generation: 1,
319+ },
320+ content: content,
321+ full: true,
322+ })
323+ })
324+
325+ for y := range 4 {
326+ require.Equal(t, "···············", rowText(frame.Row(y)), "preserved frame row %d must remain outside the retained popup", y)
327+ }
328+ require.Equal(t, "··········┌────", rowText(frame.Row(4)), "only the visible top-border intersection is painted")
329+ require.Equal(t, "··········│abcd", rowText(frame.Row(5)), "visible pane cells retain their source coordinates")
330+ require.Equal(t, "··········│klmn", rowText(frame.Row(6)), "bottom clipping must not shift the retained source")
331+}
332+
333+func TestComposeCapturedFloatingFrameOffsetsSourceForClippedTopLeft(t *testing.T) {
334+ base := renderer.NewFrame(4, 4)
335+ pane := renderer.NewFrame(5, 5)
336+ for y, text := range []string{"ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"} {
337+ for x, r := range text {
338+ pane.Set(x, y, renderer.Cell{Rune: r})
339+ }
340+ }
341+ geometry := floatingGeometry{
342+ Mode: ui.PresentationFloating,
343+ Bounds: domain.Rect{X: -2, Y: -2, Width: 7, Height: 7},
344+ Inner: domain.Rect{X: -1, Y: -1, Width: 5, Height: 5},
345+ }
346+
347+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
348+ baseFrame: base,
349+ floating: capturedFloatingRenderState{
350+ visible: true,
351+ pane: capturedPaneRenderState{frame: pane},
352+ geometry: geometry,
353+ generation: 1,
354+ },
355+ full: true,
356+ })
357+
358+ require.Equal(t, []string{"GHIJ", "LMNO", "QRST", "VWXY"}, []string{
359+ rowText(frame.Row(0)), rowText(frame.Row(1)), rowText(frame.Row(2)), rowText(frame.Row(3)),
360+ })
361+}
362+
363+func TestDrawFloatingBorderClipsEveryEdge(t *testing.T) {
364+ tests := []struct {
365+ name string
366+ geometry floatingGeometry
367+ want []string
368+ }{
369+ {
370+ name: "negative top and left retain bottom and right edges",
371+ geometry: floatingGeometry{
372+ Mode: ui.PresentationFloating,
373+ Bounds: domain.Rect{X: -2, Y: -1, Width: 6, Height: 5},
374+ },
375+ want: []string{"···│", "···│", "···│", "───┘"},
376+ },
377+ {
378+ name: "overflowing bottom and right retain top and left edges",
379+ geometry: floatingGeometry{
380+ Mode: ui.PresentationFloating,
381+ Bounds: domain.Rect{X: 2, Y: 2, Width: 5, Height: 4},
382+ },
383+ want: []string{"····", "····", "··┌─", "··│·"},
384+ },
385+ {
386+ name: "drawer clips its separator",
387+ geometry: floatingGeometry{
388+ Mode: ui.PresentationDrawer,
389+ Bounds: domain.Rect{X: -2, Y: 2, Width: 6, Height: 3},
390+ },
391+ want: []string{"····", "····", "xy──", "····"},
392+ },
393+ }
394+ for _, tt := range tests {
395+ t.Run(tt.name, func(t *testing.T) {
396+ frame := renderer.NewFrame(4, 4)
397+ for y := range frame.Height {
398+ for x := range frame.Width {
399+ frame.Set(x, y, renderer.Cell{Rune: '·'})
400+ }
401+ }
402+
403+ require.NotPanics(t, func() {
404+ drawFloatingBorder(frame, tt.geometry, "xy", renderer.Style{})
405+ })
406+ for y, want := range tt.want {
407+ require.Equal(t, want, rowText(frame.Row(y)), "row %d", y)
408+ }
409+ })
410+ }
411+}
412+
184 func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {413 func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
185 base := renderer.NewFrame(20, 9)414 base := renderer.NewFrame(20, 9)
186 content := domain.Rect{Y: 1, Width: 20, Height: 7}415 content := domain.Rect{Y: 1, Width: 20, Height: 7}
@@ -401,12 +630,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
401 630
402 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {631 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
403 frame := renderer.NewFrame(2, 1)632 frame := renderer.NewFrame(2, 1)
404- drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{})633+ drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{})
405 for _, cell := range frame.Cells {634 for _, cell := range frame.Cells {
406 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)635 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
407 }636 }
408 }637 }
409 638
639+func TestResponsiveFloatingDrawerDrawsOnlyTopSeparator(t *testing.T) {
640+ frame := renderer.NewFrame(12, 6)
641+ geometry := floatingGeometry{
642+ Mode: ui.PresentationDrawer,
643+ Bounds: domain.Rect{Y: 2, Width: 12, Height: 4},
644+ Inner: domain.Rect{Y: 3, Width: 12, Height: 3},
645+ }
646+ drawFloatingBorder(frame, geometry, "float", renderer.Style{})
647+
648+ require.Equal(t, "──float─────", rowText(frame.Row(2)))
649+ for y := 3; y < frame.Height; y++ {
650+ require.Equal(t, " ", rowText(frame.Row(y)), "drawer must not draw side or bottom borders")
651+ }
652+}
653+
654+func TestResponsiveFloatingPresentationModeInvalidatesCache(t *testing.T) {
655+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
656+ bounds := domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}
657+ inner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}
658+ floating := floatingGeometry{Mode: ui.PresentationDrawer, Bounds: bounds, Inner: inner}
659+ _, damage := composeCapturedFloatingFrame(floatingComposeInput{
660+ baseFrame: renderer.NewFrame(12, 7),
661+ floating: capturedFloatingRenderState{
662+ visible: true,
663+ pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)},
664+ geometry: floating,
665+ generation: 1,
666+ },
667+ content: content,
668+ cache: composeCacheInput{
669+ valid: true,
670+ floatingGeneration: 1,
671+ floatingGeometry: floatingGeometry{
672+ Mode: ui.PresentationFloating,
673+ Bounds: bounds,
674+ Inner: inner,
675+ }.translate(content.X, content.Y),
676+ },
677+ })
678+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
679+}
680+
410 func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {681 func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
411 base := renderer.NewFrame(80, 24)682 base := renderer.NewFrame(80, 24)
412 content := domain.Rect{Y: 1, Width: 80, Height: 22}683 content := domain.Rect{Y: 1, Width: 80, Height: 22}
@@ -574,13 +845,13 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
574 require.Empty(t, pty.sizes())845 require.Empty(t, pty.sizes())
575 846
576 // Showing attempts the current size before paint. A failed resize retains847 // Showing attempts the current size before paint. A failed resize retains
577- // the old screen, but commits the new popup rect so capture clips/pads it.848+ // the complete previously committed render and input geometry.
578 require.NoError(t, d.toggleFloating(sess, nil))849 require.NoError(t, d.toggleFloating(sess, nil))
579 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())850 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
580- require.Equal(t, current, floating.rect)851+ require.Equal(t, initial, floating.rect)
581 require.Equal(t, initial.Width, floating.screen.Frame.Width)852 require.Equal(t, initial.Width, floating.screen.Frame.Width)
582 require.Equal(t, initial.Height, floating.screen.Frame.Height)853 require.Equal(t, initial.Height, floating.screen.Frame.Height)
583- require.Equal(t, currentGeometry, floating.popupGeometry)854+ require.Equal(t, initialGeometry, floating.popupGeometry)
584 855
585 require.NoError(t, d.toggleFloating(sess, nil)) // hide856 require.NoError(t, d.toggleFloating(sess, nil)) // hide
586 require.NoError(t, d.toggleFloating(sess, nil)) // retry show857 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
M internal/usecase/daemon/floating_test.go +65 -0
@@ -208,6 +208,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
208 d.sessWg.Wait()208 d.sessWg.Wait()
209 }209 }
210 210
211+func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
212+ initial := domain.Size{Cols: 80, Rows: 25}
213+ latest := domain.Size{Cols: 79, Rows: 25}
214+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
215+ latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
216+
217+ readerRelease := make(chan struct{})
218+ readerStarted := make(chan struct{})
219+ resized := make(chan struct{})
220+ floatingPTY := portsmocks.NewMockPTY(t)
221+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
222+ close(readerStarted)
223+ <-readerRelease
224+ return 0, io.EOF
225+ }).Once()
226+ floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()
227+ floatingPTY.EXPECT().Close().RunAndReturn(func() error {
228+ select {
229+ case <-readerRelease:
230+ default:
231+ close(readerRelease)
232+ }
233+ return nil
234+ }).Once()
235+ factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
236+ d := newTestDaemon(t, factory, stubClock{})
237+ d.ApplyConfig(domain.Config{Floating: cfg})
238+ tabCtx, cancelTab := context.WithCancel(t.Context())
239+ t.Cleanup(cancelTab)
240+ tb := newTab(&transactionalResizePTY{}, tabSize(initial))
241+ tb.ctx, tb.cancel = tabCtx, cancelTab
242+ sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()}
243+
244+ d.startFloating(sess, tb, true)
245+ openCall := <-opened
246+ require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
247+ require.True(t, d.requestTransactionalResize(sess, nil, latest, true))
248+ allowOpen()
249+ select {
250+ case <-resized:
251+ case <-time.After(time.Second):
252+ t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
253+ }
254+ select {
255+ case <-readerStarted:
256+ case <-time.After(time.Second):
257+ t.Fatal("floating reader did not start")
258+ }
259+
260+ require.Eventually(t, func() bool {
261+ tb.mu.Lock()
262+ p := tb.floating.pane
263+ visible := tb.floating.state == floatingVisible
264+ tb.mu.Unlock()
265+ if !visible || p == nil {
266+ return false
267+ }
268+ p.mu.Lock()
269+ defer p.mu.Unlock()
270+ return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
271+ }, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
272+ d.teardownFloating(tb, nil)
273+ d.sessWg.Wait()
274+}
275+
211 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {276 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
212 floatingPTY := portsmocks.NewMockPTY(t)277 floatingPTY := portsmocks.NewMockPTY(t)
213 readerStarted := make(chan struct{})278 readerStarted := make(chan struct{})
M internal/usecase/daemon/input.go +20 -9
@@ -92,7 +92,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
92 }92 }
93 93
94 tb.mu.Lock()94 tb.mu.Lock()
95- contentRow := ev.Row - 195+ contentRow := ev.Row - clientTopBarRows
96 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())96 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
97 if floatingVisible {97 if floatingVisible {
98 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {98 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
@@ -201,7 +201,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
201 if translated {201 if translated {
202 d.writeToPane(sess, p, ev.Raw)202 d.writeToPane(sess, p, ev.Raw)
203 } else {203 } else {
204- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))204+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
205 }205 }
206 return206 return
207 }207 }
@@ -359,14 +359,21 @@ func (h daemonKeyHandler) Action(action keys.Action) {
359 if owned {359 if owned {
360 defer effect.End()360 defer effect.End()
361 }361 }
362- runResizeAction := func(request daemonActionRequest) {362+ runAction := func(request daemonActionRequest) {
363 request.target = resolveDaemonActionTarget(sess)363 request.target = resolveDaemonActionTarget(sess)
364 runner := h.actions364 runner := h.actions
365 if runner == nil {365 if runner == nil {
366 runner = daemonActions{d: h.d}366 runner = daemonActions{d: h.d}
367 }367 }
368 if err := runner.Run(request); err != nil {368 if err := runner.Run(request); err != nil {
369- h.d.reportError(sess, resizeUserError(err))369+ if errors.Is(err, errDaemonActionNoChange) {
370+ return
371+ }
372+ var userErr *domain.UserError
373+ if !errors.As(err, &userErr) {
374+ err = resizeUserError(err)
375+ }
376+ h.d.reportError(sess, err)
370 return377 return
371 }378 }
372 if h.actions == nil {379 if h.actions == nil {
@@ -405,15 +412,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
405 h.d.reportError(sess, err)412 h.d.reportError(sess, err)
406 }413 }
407 case keys.ActionGrowPaneWidth:414 case keys.ActionGrowPaneWidth:
408- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})415+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
409 case keys.ActionShrinkPaneWidth:416 case keys.ActionShrinkPaneWidth:
410- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})417+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
411 case keys.ActionGrowPaneHeight:418 case keys.ActionGrowPaneHeight:
412- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})419+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
413 case keys.ActionShrinkPaneHeight:420 case keys.ActionShrinkPaneHeight:
414- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})421+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
415 case keys.ActionEqualizePanes:422 case keys.ActionEqualizePanes:
416- runResizeAction(daemonActionRequest{kind: daemonActionEqualizePanes})423+ runAction(daemonActionRequest{kind: daemonActionEqualizePanes})
424+ case keys.ActionConsumeOrExpelPaneLeft:
425+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Left})
426+ case keys.ActionConsumeOrExpelPaneRight:
427+ runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: layout.Right})
417 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,428 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
418 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,429 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
419 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:430 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +177 -2
@@ -21,11 +21,114 @@ import (
21 "github.com/bnema/vev/internal/usecase/layout"21 "github.com/bnema/vev/internal/usecase/layout"
22 "github.com/bnema/vev/internal/usecase/mouse"22 "github.com/bnema/vev/internal/usecase/mouse"
23 "github.com/bnema/vev/internal/usecase/picker"23 "github.com/bnema/vev/internal/usecase/picker"
24+ "github.com/bnema/vev/internal/usecase/ui"
24 "github.com/bnema/vev/pkg/vt"25 "github.com/bnema/vev/pkg/vt"
25 )26 )
26 27
27 // --- test doubles -----------------------------------------------------------28 // --- test doubles -----------------------------------------------------------
28 29
30+func TestConfiguredConsumeOrExpelActionsRouteThroughDaemonInput(t *testing.T) {
31+ tests := []struct {
32+ name string
33+ binding string
34+ key byte
35+ focus layout.PaneID
36+ wantTarget layout.PaneID
37+ }{
38+ {name: "left", binding: "consume-or-expel-pane-left", key: 'H', focus: "pane-3", wantTarget: "pane-3"},
39+ {name: "right", binding: "consume-or-expel-pane-right", key: 'L', focus: "pane-1", wantTarget: "pane-1"},
40+ }
41+
42+ for _, tt := range tests {
43+ t.Run(tt.name, func(t *testing.T) {
44+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
45+ h.tab.mu.Lock()
46+ h.tab.tree.Focus = tt.focus
47+ beforeGeneration := h.tab.layoutGeneration
48+ h.tab.mu.Unlock()
49+ bindings, warnings := keys.BuildBindings(map[string]string{tt.binding: "alt+" + string(tt.key)})
50+ require.Empty(t, warnings)
51+ h.daemon.bindings.Store(bindings)
52+ ac := &attachedClient{}
53+ ac.setSession(h.session)
54+ ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
55+ h.session.mu.Lock()
56+ h.session.client = ac
57+ h.session.mu.Unlock()
58+ invalidations := make(chan renderInvalidation, 1)
59+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
60+ rc.attach(ac)
61+ h.session.installRenderCoordinator(rc)
62+
63+ h.daemon.handleInput(h.session, ac, []byte{keys.ESC, tt.key})
64+
65+ h.tab.mu.Lock()
66+ require.Equal(t, tt.wantTarget, h.tab.tree.Focus)
67+ require.Equal(t, beforeGeneration+1, h.tab.layoutGeneration)
68+ require.Len(t, h.tab.tree.Root.Children, 2)
69+ h.tab.mu.Unlock()
70+ awaitInvalidation(t, invalidations)
71+ requireNoInvalidation(t, invalidations)
72+ })
73+ }
74+}
75+
76+func TestConfiguredConsumeOrExpelEdgeActionIsSilent(t *testing.T) {
77+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
78+ bindings, warnings := keys.BuildBindings(map[string]string{"consume-or-expel-pane-left": "alt+H"})
79+ require.Empty(t, warnings)
80+ h.daemon.bindings.Store(bindings)
81+ ac := &attachedClient{}
82+ ac.setSession(h.session)
83+ ac.keys = keys.NewRouter(h.daemon.clock, daemonKeyHandler{d: h.daemon, ac: ac}, &h.daemon.bindings)
84+ h.session.mu.Lock()
85+ h.session.client = ac
86+ h.session.mu.Unlock()
87+ invalidations := make(chan renderInvalidation, 1)
88+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
89+ rc.attach(ac)
90+ h.session.installRenderCoordinator(rc)
91+ before := h.snapshot()
92+
93+ h.daemon.handleInput(h.session, ac, []byte{keys.ESC, 'H'})
94+
95+ require.Equal(t, before, h.snapshot())
96+ require.Empty(t, h.daemon.notices.history())
97+ requireNoInvalidation(t, invalidations)
98+}
99+
100+func TestConsumeOrExpelKeyActionPreservesRearrangementWarning(t *testing.T) {
101+ tests := []struct {
102+ name string
103+ action keys.Action
104+ }{
105+ {name: "left", action: keys.ActionConsumeOrExpelPaneLeft},
106+ {name: "right", action: keys.ActionConsumeOrExpelPaneRight},
107+ }
108+
109+ for _, tt := range tests {
110+ t.Run(tt.name, func(t *testing.T) {
111+ d := newTestDaemon(t, nil, stubClock{})
112+ sess := addControlSession(d, "work", "t_work", "p_work")
113+ ac := &attachedClient{}
114+ ac.setSession(sess)
115+ runner := &actionRunnerSpy{err: domain.UserWarn(
116+ domain.NoticeLayoutTooSmall,
117+ "not enough space to rearrange pane",
118+ layout.ErrTooSmall,
119+ )}
120+
121+ daemonKeyHandler{d: d, ac: ac, actions: runner}.Action(tt.action)
122+
123+ history := d.notices.history()
124+ require.Len(t, history, 1)
125+ require.Equal(t, domain.NoticeLayoutTooSmall, history[0].Code)
126+ require.Equal(t, domain.NoticeWarn, history[0].Severity)
127+ require.Equal(t, "not enough space to rearrange pane", history[0].Message)
128+ })
129+ }
130+}
131+
29 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {132 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30 tests := []struct {133 tests := []struct {
31 name string134 name string
@@ -602,7 +705,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
602 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))705 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
603 installTestFloating(tb, floating, true)706 installTestFloating(tb, floating, true)
604 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())707 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
605- raw := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+2))708+ raw := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
606 709
607 d.handleInput(sess, ac, raw)710 d.handleInput(sess, ac, raw)
608 }711 }
@@ -620,12 +723,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
620 installTestFloating(tb, floating, true)723 installTestFloating(tb, floating, true)
621 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())724 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
622 725
623- border := []byte(fmt.Sprintf("\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+2))726+ border := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Bounds.X+1, geometry.Bounds.Y+clientTopBarRows+1)
624 outside := []byte("\x1b[<0;1;2M")727 outside := []byte("\x1b[<0;1;2M")
625 d.handleInput(sess, ac, border)728 d.handleInput(sess, ac, border)
626 d.handleInput(sess, ac, outside)729 d.handleInput(sess, ac, outside)
627 }730 }
628 731
732+func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) {
733+ normal := portsmocks.NewMockPTY(t)
734+ floatingPTY := portsmocks.NewMockPTY(t)
735+ floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
736+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
737+ tb := sess.activeTab()
738+ complete := domain.Size{Cols: 79, Rows: 25}
739+ content := tabSize(complete)
740+ geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
741+ require.Equal(t, ui.PresentationDrawer, geometry.Mode)
742+ tb.mu.Lock()
743+ tb.size = content
744+ tb.mu.Unlock()
745+ floating := newPane("floating", floatingPTY, rectSize(geometry.Inner))
746+ floating.popupGeometry = geometry
747+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
748+ installTestFloating(tb, floating, true)
749+
750+ firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
751+ d.handleInput(sess, ac, firstInner)
752+ for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
753+ d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
754+ }
755+}
756+
757+func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) {
758+ p, release := newBlockingPTY(t)
759+ defer release()
760+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
761+ tb := sess.activeTab()
762+ complete := domain.Size{Cols: 79, Rows: 25}
763+ content := tabSize(complete)
764+ geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
765+ tb.mu.Lock()
766+ tb.size = content
767+ tb.mu.Unlock()
768+ floating := newPane("floating", nil, rectSize(geometry.Inner))
769+ floating.popupGeometry = geometry
770+ floating.screen.Write([]byte("drawer row zero\r\nsecond row"))
771+ installTestFloating(tb, floating, true)
772+
773+ for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
774+ d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
775+ ac.overlays.copyMu.Lock()
776+ require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow)
777+ ac.overlays.copyMu.Unlock()
778+ }
779+
780+ firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
781+ d.handleInput(sess, ac, firstInner)
782+ ac.overlays.copyMu.Lock()
783+ pointer := ac.overlays.copyPointer
784+ ac.overlays.copyMu.Unlock()
785+ require.True(t, pointer.valid)
786+ require.Same(t, floating, pointer.pane)
787+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press)
788+ require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content)
789+
790+ // Motion onto the protected top row clamps against the press-owned drawer
791+ // geometry instead of retargeting the underlying tiled terminal.
792+ d.handleInput(sess, ac, []byte("\x1b[<32;79;1M"))
793+ ac.overlays.copyMu.Lock()
794+ require.NotNil(t, ac.overlays.copyMode)
795+ require.Same(t, floating, ac.overlays.copyPane)
796+ require.Same(t, floating, ac.overlays.copyPointer.pane)
797+ require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry)
798+ selection := ac.overlays.copyMode.Selection()
799+ ac.overlays.copyMu.Unlock()
800+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
801+ require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row")
802+}
803+
629 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {804 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
630 writes := make(chan []byte, 1)805 writes := make(chan []byte, 1)
631 p, releasePTY := newBlockingPTYWithWrites(t, writes)806 p, releasePTY := newBlockingPTYWithWrites(t, writes)
A internal/usecase/daemon/layout_mutation.go +80 -0
@@ -0,0 +1,80 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+ "github.com/bnema/vev/internal/usecase/layout"
6+)
7+
8+// mutateTargetLayout preserves the publication semantics used by resize and
9+// equalize mutations, which always publish a successful candidate.
10+func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
11+ _, err := d.mutateTargetLayoutChanged(target, requirePane, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
12+ if err := mutate(candidate, area); err != nil {
13+ return false, err
14+ }
15+ return true, nil
16+ })
17+ return err
18+}
19+
20+// mutateTargetLayoutChanged validates an explicit target and publishes the
21+// candidate only when mutate reports a change. State locks are released before
22+// applying pane geometry so PTY.Resize never runs under daemon state locks.
23+func (d *Daemon) mutateTargetLayoutChanged(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) (bool, error)) (bool, error) {
24+ if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
25+ return false, layout.ErrNotFound
26+ }
27+
28+ d.mu.Lock()
29+ if d.sessions[target.session.id] != target.session {
30+ d.mu.Unlock()
31+ return false, layout.ErrNotFound
32+ }
33+ target.session.mu.Lock()
34+ foundTab := false
35+ for _, tb := range target.session.tabs {
36+ if tb == target.tab {
37+ foundTab = true
38+ break
39+ }
40+ }
41+ if !foundTab {
42+ target.session.mu.Unlock()
43+ d.mu.Unlock()
44+ return false, layout.ErrNotFound
45+ }
46+ target.tab.mu.Lock()
47+ if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
48+ target.tab.mu.Unlock()
49+ target.session.mu.Unlock()
50+ d.mu.Unlock()
51+ return false, layout.ErrNotFound
52+ }
53+
54+ candidate := target.tab.tree.Clone()
55+ area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
56+ changed, err := mutate(candidate, area)
57+ if err != nil {
58+ target.tab.mu.Unlock()
59+ target.session.mu.Unlock()
60+ d.mu.Unlock()
61+ return false, err
62+ }
63+ if !changed {
64+ target.tab.mu.Unlock()
65+ target.session.mu.Unlock()
66+ d.mu.Unlock()
67+ return false, nil
68+ }
69+
70+ target.tab.tree = candidate
71+ target.tab.bumpLayoutGenerationLocked()
72+ target.tab.mu.Unlock()
73+ target.session.mu.Unlock()
74+ d.mu.Unlock()
75+
76+ if !d.applyTabLayout(target.session, target.tab) {
77+ return false, layout.ErrNotFound
78+ }
79+ return true, nil
80+}
M internal/usecase/daemon/modal_compose.go +4 -7
@@ -8,12 +8,9 @@ import (
8 )8 )
9 9
10 func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {10 func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11- inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase)11+ presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height})
12- modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height})12+ modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase)
13- for y := range min(inner.Height, modalFrame.Height) {13+ modalFrame := renderInner(rectSize(presentation.Inner))
14- for x := range min(inner.Width, modalFrame.Width) {14+ copyModalInner(base, presentation.Inner, modalFrame)
15- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
16- }
17- }
18 return base, []renderer.Damage{renderer.FullRedraw()}15 return base, []renderer.Damage{renderer.FullRedraw()}
19 }16 }
M internal/usecase/daemon/mouse_panes.go +1 -1
@@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
187 func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {187 func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {
188 ev.Col -= colOffset188 ev.Col -= colOffset
189 ev.Row -= contentYOffset189 ev.Row -= contentYOffset
190- ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1)190+ ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows)
191 return ev191 return ev
192 }192 }
193 193
M internal/usecase/daemon/overlay_backdrop.go +5 -27
@@ -6,33 +6,11 @@ import (
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9-// overlayBackdrop controls optional dimming of pane content behind an overlay.9+// applyOverlayBackdrop dims every cell already composed into the complete
10-// Chrome (bars, dividers, and pane titles) is deliberately not part of the10+// renderer frame. Modal composition calls it immediately before painting each
11-// backdrop.11+// active layer so bars, panes, toasts, and lower overlays share one backdrop.
12-type overlayBackdrop struct {12+func applyOverlayBackdrop(frame renderer.Frame, theme themeui.Theme) {
13- DimPaneContents bool13+ dimFrameRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, theme)
14-}
15-
16-// apply dims the content rectangles from a tab-content-relative layout
17-// snapshot. A failed solve follows the tab renderer's focused-pane fallback by
18-// dimming the complete content area.
19-func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) {
20- if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 {
21- return
22- }
23- if !layoutSnap.ok {
24- dimFrameRect(frame, contentArea, theme)
25- return
26- }
27- for _, placement := range layoutSnap.placements {
28- if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
29- continue
30- }
31- r := placement.Content
32- r.X += contentArea.X
33- r.Y += contentArea.Y
34- dimFrameRect(frame, r, theme)
35- }
36 }14 }
37 15
38 func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {16 func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
M internal/usecase/daemon/overlay_backdrop_test.go +27 -71
@@ -6,7 +6,6 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
10 themeui "github.com/bnema/vev/internal/usecase/theme"9 themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
12 )11 )
@@ -15,82 +14,39 @@ func backdropTheme() themeui.Theme {
15 return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}14 return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
16 }15 }
17 16
18-func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) {17+func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) {
19- frame := renderer.NewFrame(8, 6)18+ frame := renderer.NewFrame(4, 3)
20 original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}19 original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}
21- frame.Set(2, 2, original)20+ for y := range frame.Height {
22- frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})21+ for x := range frame.Width {
23- frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title22+ frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*frame.Width + x), Style: renderer.DefaultStyle()})
24- frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar23+ }
25- frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar
26- frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed
27-
28- snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{
29- {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}},
30- {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}},
31- }}
32- (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme())
33-
34- require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin")
35- require.True(t, frame.At(2, 2).Style.HasForegroundRGB)
36- require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming")
37- require.Zero(t, frame.At(3, 2).Rune)
38- require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp")
39- require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp")
40- require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp")
41- require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed")
42-}
43-
44-func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
45- tests := []struct {
46- name string
47- content domain.Rect
48- affected [][2]int
49- unaffected [][2]int
50- }{
51- {
52- name: "negative origin",
53- content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3},
54- affected: [][2]int{{0, 0}, {1, 1}},
55- unaffected: [][2]int{{2, 2}, {3, 3}},
56- },
57- {
58- name: "right and bottom overflow",
59- content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4},
60- affected: [][2]int{{2, 2}, {3, 3}},
61- unaffected: [][2]int{{0, 0}, {1, 1}},
62- },
63 }24 }
64-25+ frame.Set(1, 1, original)
65- for _, tt := range tests {26+ frame.Set(2, 1, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})
66- t.Run(tt.name, func(t *testing.T) {27+
67- frame := renderer.NewFrame(4, 4)28+ applyOverlayBackdrop(frame, backdropTheme())
68- original := renderer.DefaultStyle()29+ dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
69- snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}}30+ for y := range frame.Height {
70-31+ for x := range frame.Width {
71- (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())32+ require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y)
72-33+ }
73- for _, point := range tt.affected {
74- require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), frame.At(point[0], point[1]).Style)
75- }
76- for _, point := range tt.unaffected {
77- require.Equal(t, original, frame.At(point[0], point[1]).Style)
78- }
79- })
80 }34 }
35+ require.Equal(t, '界', frame.At(1, 1).Rune)
36+ require.True(t, frame.At(2, 1).Continuation)
37+ require.Zero(t, frame.At(2, 1).Rune)
81 }38 }
82 39
83-func TestOverlayBackdropDisabledAndFallback(t *testing.T) {40+func TestDimFrameRectClipsToFrame(t *testing.T) {
84 frame := renderer.NewFrame(4, 4)41 frame := renderer.NewFrame(4, 4)
85- frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
86 before := frame.Clone()42 before := frame.Clone()
87- area := domain.Rect{Y: 1, Width: 4, Height: 2}43+ dimFrameRect(frame, domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, backdropTheme())
88- (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())44+ dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
89- require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default")
90 45
91- (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())46+ for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} {
92- require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area")47+ require.Equal(t, dimmed, frame.At(point[0], point[1]).Style)
93- require.True(t, frame.At(3, 2).Style.HasForegroundRGB)48+ }
94- require.False(t, frame.At(0, 0).Style.HasForegroundRGB)49+ for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} {
95- require.False(t, frame.At(0, 3).Style.HasForegroundRGB)50+ require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style)
51+ }
96 }52 }
M internal/usecase/daemon/palette.go +28 -9
@@ -322,7 +322,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
322 return322 return
323 }323 }
324 sess.dispatchMu.Lock()324 sess.dispatchMu.Lock()
325- err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect}, args)325+ err := cmd.Run(paletteExec{d: d, sess: sess, ac: ac, effect: effect, redrawClosedPalette: true}, args)
326 sess.dispatchMu.Unlock()326 sess.dispatchMu.Unlock()
327 if roleHandoff {327 if roleHandoff {
328 if current := ac.currentSession(); current != nil {328 if current := ac.currentSession(); current != nil {
@@ -393,12 +393,13 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
393 }393 }
394 394
395 type paletteExec struct {395 type paletteExec struct {
396- d *Daemon396+ d *Daemon
397- sess *session397+ sess *session
398- ac *attachedClient398+ ac *attachedClient
399- recent []recentSession399+ recent []recentSession
400- actions daemonActionRunner400+ actions daemonActionRunner
401- effect *roleEffectTicket401+ effect *roleEffectTicket
402+ redrawClosedPalette bool
402 }403 }
403 404
404 func (e paletteExec) runAction(request daemonActionRequest) error {405 func (e paletteExec) runAction(request daemonActionRequest) error {
@@ -410,10 +411,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error {
410 runner = daemonActions{d: e.d}411 runner = daemonActions{d: e.d}
411 }412 }
412 err := runner.Run(request)413 err := runner.Run(request)
413- if err == nil && e.actions == nil {414+ if errors.Is(err, errDaemonActionNoChange) {
415+ if e.redrawClosedPalette {
416+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
417+ }
418+ return nil
419+ }
420+ if err != nil {
421+ return err
422+ }
423+ if e.actions == nil {
414 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")424 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
415 }425 }
416- return err426+ return nil
417 }427 }
418 428
419 func (e paletteExec) CreateTab() error {429 func (e paletteExec) CreateTab() error {
@@ -442,6 +452,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
442 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }452 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
443 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }453 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
444 func (e paletteExec) SplitDown() error { return e.split(layout.Down) }454 func (e paletteExec) SplitDown() error { return e.split(layout.Down) }
455+func (e paletteExec) consumeOrExpelPane(direction layout.Direction) error {
456+ return e.runAction(daemonActionRequest{kind: daemonActionConsumeOrExpelPane, direction: direction})
457+}
458+func (e paletteExec) ConsumeOrExpelPaneLeft() error {
459+ return e.consumeOrExpelPane(layout.Left)
460+}
461+func (e paletteExec) ConsumeOrExpelPaneRight() error {
462+ return e.consumeOrExpelPane(layout.Right)
463+}
445 464
446 func (e paletteExec) StackPane() error {465 func (e paletteExec) StackPane() error {
447 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})466 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
M internal/usecase/daemon/palette_test.go +45 -7
@@ -520,14 +520,22 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
520 }520 }
521 d.handleInput(sess, ac, []byte("\x1b "))521 d.handleInput(sess, ac, []byte("\x1b "))
522 awaitFrame(t, sends, ports.MsgOutput)522 awaitFrame(t, sends, ports.MsgOutput)
523- go d.handleInput(sess, ac, []byte("JRS 1\r"))523+ inputHandled := make(chan struct{})
524- <-validated524+ go func() {
525+ d.handleInput(sess, ac, []byte("JRS 1\r"))
526+ close(inputHandled)
527+ }()
528+ awaitTestCompletion(t, validated, "JRS did not validate its captured target")
525 d.mu.Lock()529 d.mu.Lock()
526 delete(d.sessions, target.id)530 delete(d.sessions, target.id)
527 d.mu.Unlock()531 d.mu.Unlock()
528 close(releaseHandoff)532 close(releaseHandoff)
529 533
530- awaitFrame(t, sends, ports.MsgOutput)534+ // switchToTarget repaints its failed hand-off before handlePaletteInput
535+ // records the generation-safe feedback and schedules the feedback repaint.
536+ // Wait for the input transaction, rather than mistaking that intermediate
537+ // repaint for publication of the final palette state.
538+ awaitTestCompletion(t, inputHandled, "JRS displaced-target input did not complete")
531 require.True(t, ac.overlays.paletteActive())539 require.True(t, ac.overlays.paletteActive())
532 ac.overlays.paletteMu.Lock()540 ac.overlays.paletteMu.Lock()
533 require.Equal(t, "JRS 1", ac.overlays.palette.Query())541 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
@@ -734,7 +742,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
734 for i, cmd := range commands {742 for i, cmd := range commands {
735 codes[i] = cmd.Code743 codes[i] = cmd.Code
736 }744 }
737- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)745+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "CEL", "CER", "STP", "TST", "FLT", "CLP", "MPN", "MTB", "FPL", "FPR", "FPU", "FPD", "RSZ", "GPW", "SPW", "GPH", "SPH", "EQP", "PVT", "BSK", "JRS", "NTC", "YLN", "VIS", "RNS", "RNT", "DET"}, codes)
738 }746 }
739 747
740 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {748 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
@@ -852,10 +860,12 @@ func TestPaletteModalGeometry(t *testing.T) {
852 860
853 auto := domain.PaletteConfig{}861 auto := domain.PaletteConfig{}
854 tests := []testCase{862 tests := []testCase{
863+ {name: "auto 79 column drawer", size: domain.Size{Cols: 79, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 79, Height: 11}},
864+ {name: "auto 80 column shelf", size: domain.Size{Cols: 80, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 80, Height: 11}},
855 {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},865 {name: "auto 95 column shelf", size: domain.Size{Cols: 95, Rows: 40}, cfg: auto, want: domain.Rect{X: 0, Y: 28, Width: 95, Height: 11}},
856 {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},866 {name: "auto 96 column rail", size: domain.Size{Cols: 96, Rows: 40}, cfg: auto, want: domain.Rect{X: 31, Y: 28, Width: 64, Height: 11}},
857 {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},867 {name: "auto 120 column rail", size: domain.Size{Cols: 120, Rows: 40}, cfg: auto, want: domain.Rect{X: 55, Y: 28, Width: 64, Height: 11}},
858- {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6}},868+ {name: "auto tiny terminal clamps", size: domain.Size{Cols: 20, Rows: 6}, cfg: auto, want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2}},
859 }869 }
860 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {870 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
861 wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]871 wantX := map[domain.Anchor]int{domain.AnchorTopLeft: 1, domain.AnchorTop: 28, domain.AnchorTopRight: 55, domain.AnchorLeft: 1, domain.AnchorCenter: 28, domain.AnchorRight: 55, domain.AnchorBottomLeft: 1, domain.AnchorBottom: 28, domain.AnchorBottomRight: 55}[anchor]
@@ -869,7 +879,15 @@ func TestPaletteModalGeometry(t *testing.T) {
869 for _, tt := range tests {879 for _, tt := range tests {
870 t.Run(tt.name, func(t *testing.T) {880 t.Run(tt.name, func(t *testing.T) {
871 modal := paletteModalFor(tt.size, tt.cfg)881 modal := paletteModalFor(tt.size, tt.cfg)
872- require.Equal(t, tt.want, modal.Bounds(tt.size))882+ presentation := modal.Resolve(tt.size)
883+ require.Equal(t, tt.want, presentation.Bounds)
884+ if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
885+ require.Equal(t, ui.PresentationDrawer, presentation.Mode)
886+ require.Equal(t, ui.BorderTop, presentation.Borders)
887+ } else {
888+ require.Equal(t, ui.PresentationFloating, presentation.Mode)
889+ require.Equal(t, ui.BorderAll, presentation.Borders)
890+ }
873 require.Equal(t, " Commands ", modal.Title)891 require.Equal(t, " Commands ", modal.Title)
874 require.Equal(t, 11, modal.FixedHeight)892 require.Equal(t, 11, modal.FixedHeight)
875 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)893 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
@@ -877,6 +895,26 @@ func TestPaletteModalGeometry(t *testing.T) {
877 }895 }
878 }896 }
879 897
898+func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
899+ model := palette.New(nil)
900+ for _, tt := range []struct {
901+ name string
902+ width int
903+ topLeft rune
904+ innerLeft rune
905+ }{
906+ {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'},
907+ {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'},
908+ } {
909+ t.Run(tt.name, func(t *testing.T) {
910+ frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
911+
912+ require.Equal(t, tt.topLeft, frame.At(0, 28).Rune)
913+ require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune)
914+ })
915+ }
916+}
917+
880 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {918 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
881 model := palette.New(nil)919 model := palette.New(nil)
882 base := renderer.NewFrame(120, 40)920 base := renderer.NewFrame(120, 40)
@@ -902,7 +940,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
902 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles940 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
903 941
904 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)942 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
905- inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size)943+ inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner
906 944
907 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))945 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
908 }946 }
A internal/usecase/daemon/pane_rearrange.go +34 -0
@@ -0,0 +1,34 @@
1+package daemon
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/usecase/layout"
8+)
9+
10+var errDaemonActionNoChange = errors.New("daemon action made no change")
11+
12+func (d *Daemon) consumeOrExpelPane(target daemonActionTarget, direction layout.Direction) error {
13+ changed, err := d.mutateTargetLayoutChanged(target, true, func(candidate *layout.Tree, area domain.Rect) (bool, error) {
14+ return candidate.ConsumeOrExpelPane(target.pane.id, direction, area)
15+ })
16+ if err != nil {
17+ return paneRearrangeUserError(err)
18+ }
19+ if !changed {
20+ return errDaemonActionNoChange
21+ }
22+ return nil
23+}
24+
25+func paneRearrangeUserError(err error) error {
26+ switch {
27+ case errors.Is(err, layout.ErrUnsupportedColumnLayout):
28+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "pane rearrangement requires a column layout", err)
29+ case errors.Is(err, layout.ErrTooSmall):
30+ return domain.UserWarn(domain.NoticeLayoutTooSmall, "not enough space to rearrange pane", err)
31+ default:
32+ return err
33+ }
34+}
A internal/usecase/daemon/pane_rearrange_test.go +604 -0
@@ -0,0 +1,604 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "errors"
6+ "io"
7+ "maps"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
17+ "github.com/bnema/vev/internal/usecase/keys"
18+ "github.com/bnema/vev/internal/usecase/layout"
19+)
20+
21+func TestMutateTargetLayoutPublishesOnlyChangedCandidates(t *testing.T) {
22+ tests := []struct {
23+ name string
24+ mutate func(*layout.Tree, domain.Rect) (bool, error)
25+ wantChanged bool
26+ wantGeneration uint64
27+ wantSnapshotGen uint64
28+ wantResizes bool
29+ }{
30+ {
31+ name: "changed candidate publishes once",
32+ mutate: func(candidate *layout.Tree, area domain.Rect) (bool, error) {
33+ return candidate.ConsumeOrExpelPane("pane-1", layout.Right, area)
34+ },
35+ wantChanged: true,
36+ wantGeneration: 1,
37+ wantSnapshotGen: 1,
38+ wantResizes: true,
39+ },
40+ {
41+ name: "unchanged candidate is discarded",
42+ mutate: func(candidate *layout.Tree, _ domain.Rect) (bool, error) {
43+ candidate.Focus = "pane-2"
44+ return false, nil
45+ },
46+ },
47+ }
48+
49+ for _, tt := range tests {
50+ t.Run(tt.name, func(t *testing.T) {
51+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
52+ before := h.tab.tree.Clone()
53+
54+ changed, err := h.daemon.mutateTargetLayoutChanged(h.target("pane-1"), true, tt.mutate)
55+
56+ require.NoError(t, err)
57+ require.Equal(t, tt.wantChanged, changed)
58+ h.tab.mu.Lock()
59+ require.Equal(t, tt.wantGeneration, h.tab.layoutGeneration)
60+ if tt.wantChanged {
61+ require.NotEqual(t, before, h.tab.tree)
62+ } else {
63+ require.Equal(t, before, h.tab.tree, "changed=false must not publish a mutated candidate")
64+ }
65+ h.tab.mu.Unlock()
66+ h.session.snapshotMu.Lock()
67+ require.Equal(t, tt.wantSnapshotGen, h.session.snapshotGeneration)
68+ h.session.snapshotMu.Unlock()
69+ if tt.wantResizes {
70+ require.NotZero(t, h.totalResizes())
71+ } else {
72+ require.Zero(t, h.totalResizes())
73+ }
74+ })
75+ }
76+}
77+
78+func TestMutateTargetLayoutRejectsStaleTargetsBeforeMutation(t *testing.T) {
79+ tests := []struct {
80+ name string
81+ stale func(*paneRearrangeHarness) daemonActionTarget
82+ }{
83+ {
84+ name: "session",
85+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
86+ target := h.target("pane-1")
87+ h.daemon.sessions[h.session.id] = &session{id: h.session.id}
88+ return target
89+ },
90+ },
91+ {
92+ name: "tab",
93+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
94+ target := h.target("pane-1")
95+ h.session.tabs = []*tab{newTab(nil, h.tab.size)}
96+ return target
97+ },
98+ },
99+ {
100+ name: "pane identity",
101+ stale: func(h *paneRearrangeHarness) daemonActionTarget {
102+ target := h.target("pane-1")
103+ h.tab.panes[target.pane.id] = newPane(target.pane.id, nil, h.tab.size)
104+ return target
105+ },
106+ },
107+ }
108+
109+ for _, tt := range tests {
110+ t.Run(tt.name, func(t *testing.T) {
111+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
112+ before := h.tab.tree.Clone()
113+ called := false
114+
115+ changed, err := h.daemon.mutateTargetLayoutChanged(tt.stale(h), true, func(*layout.Tree, domain.Rect) (bool, error) {
116+ called = true
117+ return true, nil
118+ })
119+
120+ require.False(t, changed)
121+ require.ErrorIs(t, err, layout.ErrNotFound)
122+ require.False(t, called, "stale identity must be rejected before invoking the mutation")
123+ require.Equal(t, before, h.tab.tree)
124+ require.Zero(t, h.tab.layoutGeneration)
125+ require.Zero(t, h.totalResizes())
126+ })
127+ }
128+}
129+
130+func TestPaneRearrangeResizeRunsWithoutDaemonStateLocks(t *testing.T) {
131+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
132+ locksAvailable := make(chan bool, 8)
133+ for _, tracked := range h.ptys {
134+ tracked.onResize = func() { locksAvailable <- h.stateLocksAvailable() }
135+ }
136+
137+ done := make(chan error, 1)
138+ go func() {
139+ done <- (daemonActions{d: h.daemon}).Run(daemonActionRequest{
140+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
141+ })
142+ }()
143+
144+ select {
145+ case err := <-done:
146+ require.NoError(t, err)
147+ case <-time.After(2 * time.Second):
148+ t.Fatal("pane rearrange blocked while PTY.Resize attempted to re-enter daemon state locks")
149+ }
150+ require.NotZero(t, h.totalResizes())
151+ close(locksAvailable)
152+ for available := range locksAvailable {
153+ require.True(t, available, "PTY.Resize ran while a daemon, session, tab, or pane state lock was held")
154+ }
155+}
156+
157+func TestPaneRearrangeUsesExplicitTargetAndPreservesPaneIdentity(t *testing.T) {
158+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
159+ h.tab.mu.Lock()
160+ h.tab.tree.Focus = "pane-3"
161+ before := make(map[layout.PaneID]*pane, len(h.tab.panes))
162+ maps.Copy(before, h.tab.panes)
163+ h.tab.mu.Unlock()
164+
165+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
166+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Right,
167+ })
168+
169+ require.NoError(t, err)
170+ h.tab.mu.Lock()
171+ require.Equal(t, layout.PaneID("pane-1"), h.tab.tree.Focus, "the explicit non-focused target becomes focused")
172+ require.Len(t, h.tab.panes, len(before))
173+ for id, p := range before {
174+ require.Same(t, p, h.tab.panes[id], "pane %s identity changed", id)
175+ }
176+ h.tab.mu.Unlock()
177+ h.factory.AssertNotCalled(t, "Open")
178+ for _, tracked := range h.ptys {
179+ require.Zero(t, tracked.closeCount())
180+ }
181+}
182+
183+func TestPaneRearrangeEdgeNoopHasExactSilentInvariants(t *testing.T) {
184+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
185+ ac := &attachedClient{}
186+ ac.setSession(h.session)
187+ h.session.mu.Lock()
188+ h.session.client = ac
189+ h.session.mu.Unlock()
190+ invalidations := make(chan renderInvalidation, 2)
191+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
192+ rc.attach(ac)
193+ h.session.installRenderCoordinator(rc)
194+ before := h.snapshot()
195+ request := daemonActionRequest{
196+ kind: daemonActionConsumeOrExpelPane, target: h.target("pane-1"), direction: layout.Left,
197+ }
198+
199+ err := (daemonActions{d: h.daemon}).Run(request)
200+ require.ErrorIs(t, err, errDaemonActionNoChange, "the daemon action must retain the package no-change sentinel")
201+
202+ require.Equal(t, before, h.snapshot(), "edge no-op changed tree, generations, snapshot state, pane identity, geometry, or VT size")
203+ require.Zero(t, h.totalResizes())
204+ h.factory.AssertNotCalled(t, "Open")
205+ for _, tracked := range h.ptys {
206+ require.Zero(t, tracked.closeCount())
207+ }
208+ require.Empty(t, h.daemon.notices.history())
209+ requireNoInvalidation(t, invalidations)
210+}
211+
212+func TestPaneRearrangeMapsUnsupportedAndTooSmallToUserNoticesAtomically(t *testing.T) {
213+ tests := []struct {
214+ name string
215+ size domain.Size
216+ tree *layout.Tree
217+ pane layout.PaneID
218+ dir layout.Direction
219+ cause error
220+ wantMsg string
221+ }{
222+ {
223+ name: "unsupported layout",
224+ size: domain.Size{Cols: 80, Rows: 22},
225+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
226+ layout.NewLeaf("pane-1"),
227+ {Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3")}},
228+ }}, Focus: "pane-1"},
229+ pane: "pane-1", dir: layout.Right, cause: layout.ErrUnsupportedColumnLayout,
230+ wantMsg: "pane rearrangement requires a column layout",
231+ },
232+ {
233+ name: "too small",
234+ size: domain.Size{Cols: 40, Rows: 5},
235+ tree: &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{
236+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"),
237+ }}, Focus: "pane-1"},
238+ pane: "pane-1", dir: layout.Right, cause: layout.ErrTooSmall,
239+ wantMsg: "not enough space to rearrange pane",
240+ },
241+ }
242+
243+ for _, tt := range tests {
244+ t.Run(tt.name, func(t *testing.T) {
245+ h := newPaneRearrangeHarness(t, tt.size, tt.tree)
246+ before := h.snapshot()
247+
248+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
249+ kind: daemonActionConsumeOrExpelPane, target: h.target(tt.pane), direction: tt.dir,
250+ })
251+
252+ require.ErrorIs(t, err, tt.cause)
253+ var userErr *domain.UserError
254+ require.ErrorAs(t, err, &userErr)
255+ require.Equal(t, domain.NoticeLayoutTooSmall, userErr.Code)
256+ require.Equal(t, domain.NoticeWarn, userErr.Severity)
257+ require.Equal(t, tt.wantMsg, userErr.Msg)
258+ require.Equal(t, before, h.snapshot(), "failed rearrange must be atomic")
259+ require.Zero(t, h.totalResizes())
260+ h.factory.AssertNotCalled(t, "Open")
261+ for _, tracked := range h.ptys {
262+ require.Zero(t, tracked.closeCount())
263+ }
264+ })
265+ }
266+}
267+
268+func TestPaneRearrangeChangedActionPublishesOnce(t *testing.T) {
269+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
270+ ac := &attachedClient{}
271+ ac.setSession(h.session)
272+ h.session.mu.Lock()
273+ h.session.client = ac
274+ h.session.mu.Unlock()
275+ invalidations := make(chan renderInvalidation, 2)
276+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
277+ rc.attach(ac)
278+ h.session.installRenderCoordinator(rc)
279+
280+ err := (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).runAction(daemonActionRequest{
281+ kind: daemonActionConsumeOrExpelPane, direction: layout.Right,
282+ })
283+
284+ require.NoError(t, err)
285+ h.tab.mu.Lock()
286+ require.Equal(t, uint64(1), h.tab.layoutGeneration)
287+ h.tab.mu.Unlock()
288+ h.session.snapshotMu.Lock()
289+ require.Equal(t, uint64(1), h.session.snapshotGeneration)
290+ h.session.snapshotMu.Unlock()
291+ require.NotZero(t, h.totalResizes())
292+ awaitInvalidation(t, invalidations)
293+ requireNoInvalidation(t, invalidations)
294+}
295+
296+func TestPaneRearrangeAdaptersOwnDirectionalRequestsAndNormalizeOnlyNoChange(t *testing.T) {
297+ genuine := errors.New("genuine rearrange failure")
298+ directions := []struct {
299+ name string
300+ direction layout.Direction
301+ keyAction keys.Action
302+ paletteRun func(paletteExec) error
303+ controlRun func(controlExec) error
304+ }{
305+ {
306+ name: "left", direction: layout.Left, keyAction: keys.ActionConsumeOrExpelPaneLeft,
307+ paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() },
308+ controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() },
309+ },
310+ {
311+ name: "right", direction: layout.Right, keyAction: keys.ActionConsumeOrExpelPaneRight,
312+ paletteRun: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() },
313+ controlRun: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() },
314+ },
315+ }
316+ outcomes := []struct {
317+ name string
318+ err error
319+ wantErr error
320+ wantNotice bool
321+ }{
322+ {name: "silent no-change", err: errDaemonActionNoChange},
323+ {name: "genuine error", err: genuine, wantErr: genuine, wantNotice: true},
324+ }
325+
326+ for _, direction := range directions {
327+ t.Run(direction.name, func(t *testing.T) {
328+ for _, outcome := range outcomes {
329+ t.Run(outcome.name, func(t *testing.T) {
330+ d := newTestDaemon(t, nil, stubClock{})
331+ sess := addControlSession(d, "work", "t_work", "p_work")
332+ ac := &attachedClient{}
333+ ac.setSession(sess)
334+ target := resolveDaemonActionTarget(sess)
335+ paletteSpy := &actionRunnerSpy{err: outcome.err}
336+ controlSpy := &actionRunnerSpy{err: outcome.err}
337+ keySpy := &actionRunnerSpy{err: outcome.err}
338+
339+ paletteErr := direction.paletteRun(paletteExec{d: d, sess: sess, ac: ac, actions: paletteSpy})
340+ controlErr := direction.controlRun(controlExec{d: d, sess: sess, target: target, actions: controlSpy})
341+ daemonKeyHandler{d: d, ac: ac, actions: keySpy}.Action(direction.keyAction)
342+
343+ if outcome.wantErr == nil {
344+ require.NoError(t, paletteErr)
345+ require.NoError(t, controlErr)
346+ } else {
347+ require.ErrorIs(t, paletteErr, outcome.wantErr)
348+ require.ErrorIs(t, controlErr, outcome.wantErr)
349+ }
350+ if outcome.wantNotice {
351+ require.Len(t, d.notices.history(), 1, "the key adapter owns reporting its genuine error")
352+ } else {
353+ require.Empty(t, d.notices.history(), "no-change is silent in the key adapter")
354+ }
355+
356+ for adapter, spy := range map[string]*actionRunnerSpy{
357+ "palette": paletteSpy,
358+ "control": controlSpy,
359+ "key": keySpy,
360+ } {
361+ require.Len(t, spy.requests, 1, "%s adapter request count", adapter)
362+ request := spy.requests[0]
363+ require.Equal(t, daemonActionConsumeOrExpelPane, request.kind, "%s adapter action kind", adapter)
364+ require.Equal(t, direction.direction, request.direction, "%s adapter direction", adapter)
365+ require.Same(t, target.session, request.target.session, "%s adapter target session", adapter)
366+ require.Same(t, target.tab, request.target.tab, "%s adapter target tab", adapter)
367+ require.Same(t, target.pane, request.target.pane, "%s adapter target pane", adapter)
368+ }
369+ })
370+ }
371+ })
372+ }
373+}
374+
375+func TestPaneRearrangeNoChangeAdapterSideEffects(t *testing.T) {
376+ tests := []struct {
377+ name string
378+ invoke func(*paneRearrangeHarness, *attachedClient) error
379+ wantCleanupInvalidation bool
380+ }{
381+ {
382+ name: "palette cleans up its closed overlay",
383+ invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
384+ return (paletteExec{d: h.daemon, sess: h.session, ac: ac, redrawClosedPalette: true}).ConsumeOrExpelPaneLeft()
385+ },
386+ wantCleanupInvalidation: true,
387+ },
388+ {
389+ name: "control remains silent",
390+ invoke: func(h *paneRearrangeHarness, _ *attachedClient) error {
391+ return (controlExec{d: h.daemon, sess: h.session, target: h.target("pane-1")}).ConsumeOrExpelPaneLeft()
392+ },
393+ },
394+ {
395+ name: "key remains silent",
396+ invoke: func(h *paneRearrangeHarness, ac *attachedClient) error {
397+ daemonKeyHandler{d: h.daemon, ac: ac}.Action(keys.ActionConsumeOrExpelPaneLeft)
398+ return nil
399+ },
400+ },
401+ }
402+
403+ for _, tt := range tests {
404+ t.Run(tt.name, func(t *testing.T) {
405+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 80, Rows: 22}, threeColumnTree())
406+ ac := &attachedClient{}
407+ ac.setSession(h.session)
408+ h.session.mu.Lock()
409+ h.session.client = ac
410+ h.session.mu.Unlock()
411+ invalidations := make(chan renderInvalidation, 2)
412+ rc := newRenderCoordinator(renderCoordinatorOptions{onInvalidate: func(inv renderInvalidation) { invalidations <- inv }})
413+ rc.attach(ac)
414+ h.session.installRenderCoordinator(rc)
415+ before := h.snapshot()
416+
417+ require.NoError(t, tt.invoke(h, ac))
418+
419+ require.Equal(t, before, h.snapshot(), "adapter no-change must not publish layout state")
420+ require.Empty(t, h.daemon.notices.history())
421+ if tt.wantCleanupInvalidation {
422+ invalidation := awaitInvalidation(t, invalidations)
423+ require.Equal(t, "palette.go", invalidation.producer)
424+ require.True(t, invalidation.reset)
425+ }
426+ requireNoInvalidation(t, invalidations)
427+ })
428+ }
429+}
430+
431+type paneRearrangeHarness struct {
432+ daemon *Daemon
433+ session *session
434+ tab *tab
435+ panes map[layout.PaneID]*pane
436+ ptys map[layout.PaneID]*paneRearrangePTY
437+ factory *portsmocks.MockPTYFactory
438+}
439+
440+type paneRearrangeSnapshot struct {
441+ tree *layout.Tree
442+ layoutGeneration uint64
443+ snapshotGeneration uint64
444+ snapshotDirty bool
445+ panes map[layout.PaneID]*pane
446+ rects map[layout.PaneID]domain.Rect
447+ screens map[layout.PaneID]domain.Size
448+}
449+
450+func newPaneRearrangeHarness(t *testing.T, size domain.Size, tree *layout.Tree) *paneRearrangeHarness {
451+ t.Helper()
452+ factory := portsmocks.NewMockPTYFactory(t)
453+ d := newTestDaemon(t, factory, stubClock{})
454+ ids := layout.LeafIDs(tree.Root)
455+ require.NotEmpty(t, ids)
456+ ptys := make(map[layout.PaneID]*paneRearrangePTY, len(ids))
457+ panes := make(map[layout.PaneID]*pane, len(ids))
458+ for _, id := range ids {
459+ ptys[id] = &paneRearrangePTY{}
460+ }
461+ tb := newTab(ptys[ids[0]], size)
462+ first := tb.panes["pane-1"]
463+ delete(tb.panes, "pane-1")
464+ first.id = ids[0]
465+ panes[ids[0]] = first
466+ tb.panes[ids[0]] = first
467+ for _, id := range ids[1:] {
468+ p := newPane(id, ptys[id], size)
469+ panes[id] = p
470+ tb.panes[id] = p
471+ }
472+ tb.tree = tree.Clone()
473+ placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: size.Cols, Height: size.Rows})
474+ require.True(t, ok, "test fixture layout must solve")
475+ for _, placement := range placements {
476+ p := tb.panes[placement.ID]
477+ p.rect = placement.Content
478+ p.screen.Resize(placement.Content.Width, placement.Content.Height)
479+ }
480+ tb.ctx, tb.cancel = contextWithTestCleanup(t, d.serveCtx)
481+ for _, p := range tb.panes {
482+ p.ctx, p.cancel = contextWithTestCleanup(t, tb.ctx)
483+ }
484+ sess := &session{id: "s", name: "work", cwd: "/work", tabs: []*tab{tb}, ctx: d.serveCtx, cancel: func() {}}
485+ sess.snapEligible.Store(true)
486+ d.sessions[sess.id] = sess
487+ return &paneRearrangeHarness{daemon: d, session: sess, tab: tb, panes: panes, ptys: ptys, factory: factory}
488+}
489+
490+func contextWithTestCleanup(t *testing.T, parent context.Context) (context.Context, context.CancelFunc) {
491+ t.Helper()
492+ ctx, cancel := context.WithCancel(parent)
493+ t.Cleanup(cancel)
494+ return ctx, cancel
495+}
496+
497+func (h *paneRearrangeHarness) target(id layout.PaneID) daemonActionTarget {
498+ return daemonActionTarget{session: h.session, tab: h.tab, pane: h.panes[id]}
499+}
500+
501+func (h *paneRearrangeHarness) totalResizes() int {
502+ total := 0
503+ for _, tracked := range h.ptys {
504+ total += tracked.resizeCount()
505+ }
506+ return total
507+}
508+
509+func (h *paneRearrangeHarness) snapshot() paneRearrangeSnapshot {
510+ h.tab.mu.Lock()
511+ snapshot := paneRearrangeSnapshot{
512+ tree: h.tab.tree.Clone(),
513+ layoutGeneration: h.tab.layoutGeneration,
514+ panes: make(map[layout.PaneID]*pane, len(h.tab.panes)),
515+ rects: make(map[layout.PaneID]domain.Rect, len(h.tab.panes)),
516+ screens: make(map[layout.PaneID]domain.Size, len(h.tab.panes)),
517+ }
518+ for id, p := range h.tab.panes {
519+ snapshot.panes[id] = p
520+ p.mu.Lock()
521+ snapshot.rects[id] = p.rect
522+ snapshot.screens[id] = domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}
523+ p.mu.Unlock()
524+ }
525+ h.tab.mu.Unlock()
526+ h.session.snapshotMu.Lock()
527+ snapshot.snapshotGeneration = h.session.snapshotGeneration
528+ snapshot.snapshotDirty = h.session.snapDirty.Load()
529+ h.session.snapshotMu.Unlock()
530+ return snapshot
531+}
532+
533+func (h *paneRearrangeHarness) stateLocksAvailable() bool {
534+ done := make(chan struct{})
535+ go func() {
536+ h.daemon.mu.Lock()
537+ h.session.mu.Lock()
538+ h.tab.mu.Lock()
539+ for _, p := range h.panes {
540+ p.mu.Lock()
541+ }
542+ for _, p := range h.panes {
543+ p.mu.Unlock()
544+ }
545+ h.tab.mu.Unlock()
546+ h.session.mu.Unlock()
547+ h.daemon.mu.Unlock()
548+ close(done)
549+ }()
550+ select {
551+ case <-done:
552+ return true
553+ case <-time.After(250 * time.Millisecond):
554+ return false
555+ }
556+}
557+
558+func threeColumnTree() *layout.Tree {
559+ return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{
560+ layout.NewLeaf("pane-1"), layout.NewLeaf("pane-2"), layout.NewLeaf("pane-3"),
561+ }}, Focus: "pane-1"}
562+}
563+
564+type paneRearrangePTY struct {
565+ mu sync.Mutex
566+ resizes []domain.Size
567+ closes int
568+ onResize func()
569+}
570+
571+func (*paneRearrangePTY) Read([]byte) (int, error) { return 0, io.EOF }
572+func (*paneRearrangePTY) Write(b []byte) (int, error) { return len(b), nil }
573+func (p *paneRearrangePTY) Resize(size domain.Size) error {
574+ p.mu.Lock()
575+ p.resizes = append(p.resizes, size)
576+ hook := p.onResize
577+ p.mu.Unlock()
578+ if hook != nil {
579+ hook()
580+ }
581+ return nil
582+}
583+func (p *paneRearrangePTY) Close() error {
584+ p.mu.Lock()
585+ p.closes++
586+ p.mu.Unlock()
587+ return nil
588+}
589+func (*paneRearrangePTY) Pid() int { return 0 }
590+func (*paneRearrangePTY) ForegroundPgid() (int, error) { return 0, nil }
591+
592+func (p *paneRearrangePTY) resizeCount() int {
593+ p.mu.Lock()
594+ defer p.mu.Unlock()
595+ return len(p.resizes)
596+}
597+
598+func (p *paneRearrangePTY) closeCount() int {
599+ p.mu.Lock()
600+ defer p.mu.Unlock()
601+ return p.closes
602+}
603+
604+var _ ports.PTY = (*paneRearrangePTY)(nil)
M internal/usecase/daemon/performance_benchmark_test.go +4 -6
@@ -64,9 +64,9 @@ func TestIncrementalSnapshotMetricsWriteNoUnchangedHistoryBlobsAndBoundCache(t *
64 func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {64 func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {
65 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})65 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
66 66
67- markSnapshotDirty(fixture.sess)67+ // This contract concerns capture and publication bytes, not queue latency.
68- require.True(t, fixture.d.scheduleSnapshot(fixture.sess))68+ // Publish synchronously so the 10k-row setup cannot race the test deadline.
69- awaitSnapshotClean(t, fixture.sess)69+ publishBenchmarkSnapshot(t, fixture, 1)
70 70
71 var copies atomic.Uint6471 var copies atomic.Uint64
72 fixture.sess.snapshotMu.Lock()72 fixture.sess.snapshotMu.Lock()
@@ -83,9 +83,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
83 for i, r := range "changed" {83 for i, r := range "changed" {
84 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)84 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
85 }85 }
86- markSnapshotDirty(fixture.sess)86+ publishBenchmarkSnapshot(t, fixture, 2)
87- require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess))
88- awaitSnapshotClean(t, fixture.sess)
89 87
90 metrics := fixture.snapshots.metrics()88 metrics := fixture.snapshots.metrics()
91 require.Equal(t, uint64(1), metrics.writes)89 require.Equal(t, uint64(1), metrics.writes)
M internal/usecase/daemon/picker_test.go +1 -1
@@ -1344,7 +1344,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
1344 func TestPickerModalGeometry(t *testing.T) {1344 func TestPickerModalGeometry(t *testing.T) {
1345 base := domain.Size{Cols: 100, Rows: 40}1345 base := domain.Size{Cols: 100, Rows: 40}
1346 1346
1347- require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))1347+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds)
1348 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)1348 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
1349 }1349 }
1350 1350
M internal/usecase/daemon/prompt_test.go +1 -1
@@ -20,7 +20,7 @@ func (f failingPTYFactory) Open(context.Context, string, []string, []string, str
20 func TestPromptModalGeometry(t *testing.T) {20 func TestPromptModalGeometry(t *testing.T) {
21 base := domain.Size{Cols: 100, Rows: 40}21 base := domain.Size{Cols: 100, Rows: 40}
22 22
23- require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base))23+ require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds)
24 }24 }
25 25
26 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {26 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
M internal/usecase/daemon/render_capture.go +5 -3
@@ -102,9 +102,11 @@ type capturedOverlayRenderState struct {
102 }102 }
103 103
104 type capturedModal struct {104 type capturedModal struct {
105- modal ui.Modal105+ active bool
106- inner renderer.Frame106+ title string
107- focused bool107+ presentation ui.Presentation
108+ inner renderer.Frame
109+ focused bool
108 }110 }
109 111
110 func (o capturedOverlayRenderState) active() bool {112 func (o capturedOverlayRenderState) active() bool {
M internal/usecase/daemon/render_pipeline.go +91 -69
@@ -126,30 +126,43 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
126 damage = append(damage, d)126 damage = append(damage, d)
127 }127 }
128 }128 }
129- // Keep the committed cache unadorned: floating composition clones this129+ // Keep the committed cache unadorned. Toasts are composed first so floating
130- // base, so closing or moving a popup cannot retain dimmed/bordered cells.130+ // terminals and scoped modals dim them with the rest of the complete frame.
131 baseFrame := frame131 baseFrame := frame
132+ overlaysActive := state.overlays.active()
133+ toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
134+ var toastFootprints []domain.Rect
135+ if toastsVisible {
136+ frame = baseFrame.Clone()
137+ toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
138+ }
132 if state.floating.visible {139 if state.floating.visible {
133 var floatingDamage []renderer.Damage140 var floatingDamage []renderer.Damage
134 frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{141 frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
135- baseFrame: baseFrame,142+ baseFrame: frame,
136 baseDamage: damage,143 baseDamage: damage,
137 floating: state.floating,144 floating: state.floating,
138 content: content,145 content: content,
139- layout: state.layout,
140 theme: state.theme,146 theme: state.theme,
141 borderMuted: styles.BorderMuted,147 borderMuted: styles.BorderMuted,
142 borderActive: styles.BorderActive,148 borderActive: styles.BorderActive,
143 cache: in,149 cache: in,
144- full: full || state.overlays.active(),150+ full: full || overlaysActive,
145 })151 })
146 damage = floatingDamage152 damage = floatingDamage
147 }153 }
154+ if overlaysActive {
155+ if !toastsVisible && !state.floating.visible {
156+ frame = baseFrame.Clone()
157+ }
158+ frame, damage = composeCapturedCopyMode(state, frame, damage, content)
159+ frame, damage = composeCapturedOverlays(state, frame, damage)
160+ }
148 if !full {161 if !full {
149- if !sameCells(in.bars.top, frame.Row(0)) {162+ if !sameCells(in.bars.top, baseFrame.Row(0)) {
150 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})163 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
151 }164 }
152- if !sameCells(in.bars.bottom, frame.Row(rows+1)) {165+ if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) {
153 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})166 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
154 }167 }
155 }168 }
@@ -157,17 +170,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157 // toasts, so every render damages both the last and current toast coverage.170 // toasts, so every render damages both the last and current toast coverage.
158 // This restores cells exposed by dismissal and redraws a stable toast over171 // This restores cells exposed by dismissal and redraws a stable toast over
159 // any underlying pane update without promoting either case to a full frame.172 // any underlying pane update without promoting either case to a full frame.
160- overlaysActive := state.overlays.active()
161- toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
162- if overlaysActive || toastsVisible {
163- // Without a floating frame, overlay composition would otherwise mutate
164- // the base cache in place. Floating composition already owns a clone.
165- if !state.floating.visible {
166- baseFrame = frame.Clone()
167- }
168- frame, damage = composeCapturedOverlays(state, frame, damage, content)
169- }
170- toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles)
171 if full || overlaysActive {173 if full || overlaysActive {
172 damage = []renderer.Damage{renderer.FullRedraw()}174 damage = []renderer.Damage{renderer.FullRedraw()}
173 } else {175 } else {
@@ -222,91 +224,111 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222 }224 }
223 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}225 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
224 if snap.copySearchModel != nil {226 if snap.copySearchModel != nil {
225- o.copySearch.modal = copySearchModal227+ presentation := copySearchModal.Resolve(size)
226- o.copySearch.focused = true228+ o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
227- o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})229+ o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
228 }230 }
229 if snap.pickerActive && snap.pickerModel != nil {231 if snap.pickerActive && snap.pickerModel != nil {
230- o.picker.modal = pickerModal232+ presentation := pickerModal.Resolve(size)
231- o.picker.focused = true233+ o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true}
232 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}234 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}
233- o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)235+ o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
234 }236 }
235 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {237 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
236- o.noticesOverlay.modal = noticesModal238+ presentation := noticesModal.Resolve(size)
237- o.noticesOverlay.focused = true239+ o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
238 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}240 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
239- o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles)241+ o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
240 }242 }
241 if snap.paletteActive && snap.paletteModel != nil {243 if snap.paletteActive && snap.paletteModel != nil {
242- o.palette.modal = paletteModalFor(size, paletteCfg)244+ modal := paletteModalFor(size, paletteCfg)
243- o.palette.focused = true245+ presentation := modal.Resolve(size)
246+ o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
244 guidance := ""247 guidance := ""
245 if snap.paletteHints != nil {248 if snap.paletteHints != nil {
246 guidance = snap.paletteHints.Feedback249 guidance = snap.paletteHints.Feedback
247 }250 }
248 o.paletteGuidance = snap.paletteFeedback251 o.paletteGuidance = snap.paletteFeedback
249- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})252+ o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
250 }253 }
251 if snap.promptActive && snap.promptModel != nil {254 if snap.promptActive && snap.promptModel != nil {
252- o.prompt.modal = promptModalFor(snap.promptModel.Title())255+ modal := promptModalFor(snap.promptModel.Title())
253- o.prompt.focused = true256+ presentation := modal.Resolve(size)
254- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})257+ o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
258+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
255 }259 }
256 state.cursor.hiddenByOverlay = o.active()260 state.cursor.hiddenByOverlay = o.active()
257 }261 }
258 262
259-func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {263+func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
260 o := state.overlays264 o := state.overlays
261- if o.copyActive {265+ if !o.copyActive {
262- target := domain.Rect{}266+ return frame, damage
263- if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {267+ }
264- target = state.floating.geometry.translate(content.X, content.Y).Inner268+ target := domain.Rect{}
269+ if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
270+ target = state.floating.geometry.translate(content.X, content.Y).Inner
271+ }
272+ if target.Width == 0 {
273+ id := o.copyPaneID
274+ if id == "" {
275+ id = state.layout.focus
265 }276 }
266- if target.Width == 0 {277+ for _, p := range state.panes {
267- id := o.copyPaneID278+ if p.id == id && !p.placement.Collapsed {
268- if id == "" {279+ target = p.placement.Content
269- id = state.layout.focus280+ target.Y += content.Y
270- }281+ break
271- for _, p := range state.panes {
272- if p.id == id && !p.placement.Collapsed {
273- target = p.placement.Content
274- target.Y += content.Y
275- break
276- }
277 }282 }
278 }283 }
279- frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles)284+ }
280- }285+ return composeCopyClientFrame(o.copyMode, target, frame, state.styles)
281- layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}286+}
282- if o.paletteActive && !state.floating.visible {287+
283- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)288+func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
284- }289+ o := state.overlays
285- // This paint order intentionally differs from HandleInput's keyboard290+ // Paint in reverse keyboard priority so the same layer that owns input is
286- // priority (prompt > palette > picker > notices > copy, see291+ // visually topmost: prompt > palette > picker > notices > copy search.
287- // overlay_runtime.go), which paints the picker under notices instead of292+ for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} {
288- // over it. Currently unreachable: notices only opens via the palette, and293+ if !modal.active {
289- // HandleInput short-circuits to the first active overlay, so picker and
290- // notices are never simultaneously active. If that ever changes, this
291- // mismatch would let the picker own the keyboard while notices visually
292- // covers it.
293- for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {
294- if modal.inner.Width == 0 && modal.inner.Height == 0 {
295 continue294 continue
296 }295 }
296+ applyOverlayBackdrop(frame, state.theme)
297 border := state.styles.BorderMuted297 border := state.styles.BorderMuted
298 if modal.focused {298 if modal.focused {
299 border = state.styles.BorderActive299 border = state.styles.BorderActive
300 }300 }
301- inner := modal.modal.Composite(frame, border, state.styles.PickerBase)301+ (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
302- for y := range min(inner.Height, modal.inner.Height) {302+ copyModalInner(frame, modal.presentation.Inner, modal.inner)
303- copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
304- }
305 damage = []renderer.Damage{renderer.FullRedraw()}303 damage = []renderer.Damage{renderer.FullRedraw()}
306 }304 }
307 return frame, damage305 return frame, damage
308 }306 }
309 307
308+// copyModalInner copies a captured model frame into its resolved destination.
309+// Both source and destination are clipped so degenerate presentations remain
310+// safe immutable composition inputs.
311+func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
312+ left := max(target.X, 0)
313+ top := max(target.Y, 0)
314+ right := min(target.X+target.Width, dst.Width)
315+ bottom := min(target.Y+target.Height, dst.Height)
316+ if left >= right || top >= bottom {
317+ return
318+ }
319+
320+ sourceX := left - target.X
321+ sourceY := top - target.Y
322+ width := min(right-left, src.Width-sourceX)
323+ height := min(bottom-top, src.Height-sourceY)
324+ if width <= 0 || height <= 0 {
325+ return
326+ }
327+ for y := range height {
328+ copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width])
329+ }
330+}
331+
310 func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {332 func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
311 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {333 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
312 return nil334 return nil
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -1
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/internal/usecase/ui"
13 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
14 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
15 )16 )
@@ -326,7 +327,7 @@ func cacheState(title string, generation uint64) capturedRenderState {
326 reset: false,327 reset: false,
327 layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},328 layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
328 panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},329 panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
329- floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},330+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
330 bars: barState{topRight: title, bottomRight: title},331 bars: barState{topRight: title, bottomRight: title},
331 styles: resolveStyles(nil),332 styles: resolveStyles(nil),
332 }333 }
M internal/usecase/daemon/render_pipeline_test.go +253 -7
@@ -9,13 +9,20 @@ import (
9 "path/filepath"9 "path/filepath"
10 "strings"10 "strings"
11 "testing"11 "testing"
12+ "time"
12 13
13 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/testutil/replaytest"16 "github.com/bnema/vev/internal/testutil/replaytest"
17+ scopy "github.com/bnema/vev/internal/usecase/copy"
16 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/internal/usecase/notices"
20+ "github.com/bnema/vev/internal/usecase/palette"
21+ "github.com/bnema/vev/internal/usecase/picker"
22+ promptui "github.com/bnema/vev/internal/usecase/prompt"
17 themeui "github.com/bnema/vev/internal/usecase/theme"23 themeui "github.com/bnema/vev/internal/usecase/theme"
18 "github.com/bnema/vev/internal/usecase/ui"24 "github.com/bnema/vev/internal/usecase/ui"
25+ "github.com/bnema/vev/internal/usecase/visualsearch"
19 "github.com/bnema/vev/pkg/renderer"26 "github.com/bnema/vev/pkg/renderer"
20 "github.com/bnema/vev/pkg/vt"27 "github.com/bnema/vev/pkg/vt"
21 "github.com/stretchr/testify/require"28 "github.com/stretchr/testify/require"
@@ -220,21 +227,21 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
220 reset: true,227 reset: true,
221 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},228 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
222 panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},229 panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
223- floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},230+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
224 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},231 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
225- overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},232+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},
226 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},233 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
227 styles: resolveStyles(nil),234 styles: resolveStyles(nil),
228 }235 }
229 composed := composeFrame(state, composeCacheInput{})236 composed := composeFrame(state, composeCacheInput{})
230- require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))237+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT ", " ", " sess B"}, frameRows(composed.frame))
231 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")238 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
232 239
233 stream := newOutputStateStream()240 stream := newOutputStateStream()
234 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)241 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
235 require.NoError(t, err)242 require.NoError(t, err)
236 terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)243 terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
237- require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))244+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
238 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)245 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
239 client.Write(terminalBytes)246 client.Write(terminalBytes)
240 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))247 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
@@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
298 active := renderer.Style{Foreground: 3, Background: 4}305 active := renderer.Style{Foreground: 3, Background: 4}
299 interior := renderer.Style{Foreground: 5, Background: 6}306 interior := renderer.Style{Foreground: 5, Background: 6}
300 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}307 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
308+ presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8})
301 309
302 for _, tt := range []struct {310 for _, tt := range []struct {
303 name string311 name string
@@ -310,16 +318,254 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
310 t.Run(tt.name, func(t *testing.T) {318 t.Run(tt.name, func(t *testing.T) {
311 state := capturedRenderState{319 state := capturedRenderState{
312 styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},320 styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
313- overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}},321+ overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
314 }322 }
315- frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})323+ frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil)
316- bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8})324+ bounds := presentation.Bounds
317 require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")325 require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
318 require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")326 require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
319 })327 })
320 }328 }
321 }329 }
322 330
331+func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
332+ const (
333+ width = 100
334+ contentRows = 6
335+ )
336+ styles := resolveStyles(nil)
337+ theme := backdropTheme()
338+ leftPlacement := layout.Placement{
339+ ID: "left",
340+ TitleBar: domain.Rect{Width: 49, Height: 1},
341+ Content: domain.Rect{Y: 1, Width: 49, Height: 5},
342+ }
343+ rightPlacement := layout.Placement{
344+ ID: "right",
345+ TitleBar: domain.Rect{X: 50, Width: 50, Height: 1},
346+ Content: domain.Rect{X: 50, Y: 1, Width: 50, Height: 5},
347+ }
348+ state := capturedRenderState{
349+ reset: true,
350+ layout: capturedTabLayout{
351+ area: domain.Rect{Width: width, Height: contentRows},
352+ focus: "left",
353+ placements: []layout.Placement{leftPlacement, rightPlacement},
354+ dividers: []layout.Divider{{Rect: domain.Rect{X: 49, Width: 1, Height: contentRows}, Dir: layout.Horizontal}},
355+ fingerprint: "modal-backdrop",
356+ valid: true,
357+ },
358+ panes: []capturedPaneRenderState{
359+ {id: "left", title: "left", frame: cachePaneFrame(49, 5, 'L'), placement: leftPlacement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}},
360+ {id: "right", title: "right", frame: cachePaneFrame(50, 5, 'R'), placement: rightPlacement, damage: []renderer.Damage{renderer.FullRedraw()}},
361+ },
362+ overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
363+ styles: styles,
364+ theme: theme,
365+ }
366+ base := composeFrame(state, composeCacheInput{})
367+ require.NotEmpty(t, base.cache.toastFootprints)
368+
369+ state.reset = false
370+ state.panes[0].damage = nil
371+ state.panes[1].damage = nil
372+ state.overlays.promptActive = true
373+ state.overlays.prompt = capturedModal{
374+ active: true,
375+ title: "Prompt",
376+ presentation: ui.Presentation{
377+ Bounds: domain.Rect{X: 80, Y: 5, Width: 10, Height: 2},
378+ Inner: domain.Rect{X: 81, Y: 6, Width: 8, Height: 1},
379+ Borders: ui.BorderAll,
380+ },
381+ inner: renderer.NewFrame(8, 1),
382+ focused: true,
383+ }
384+ composed := composeFrame(state, base.cache)
385+ dimmer := themeui.NewDimmer(theme)
386+ toast := base.cache.toastFootprints[0]
387+
388+ for name, point := range map[string][2]int{
389+ "top bar": {0, 0},
390+ "pane chrome": {0, 1},
391+ "pane content": {0, 2},
392+ "divider": {49, 2},
393+ "bottom bar": {0, contentRows + 1},
394+ "toast": {toast.X, toast.Y},
395+ } {
396+ t.Run(name, func(t *testing.T) {
397+ x, y := point[0], point[1]
398+ require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style)
399+ })
400+ }
401+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
402+ require.False(t, composed.cache.valid)
403+ require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned")
404+}
405+
406+func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
407+ const (
408+ width = 100
409+ contentRows = 6
410+ )
411+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: width, Height: contentRows}}
412+ theme := backdropTheme()
413+ state := capturedRenderState{
414+ reset: true,
415+ layout: capturedTabLayout{area: domain.Rect{Width: width, Height: contentRows}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "floating-toast-backdrop", valid: true},
416+ panes: []capturedPaneRenderState{{
417+ id: "pane", frame: cachePaneFrame(width, contentRows, 'P'), placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
418+ }},
419+ overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
420+ styles: resolveStyles(nil),
421+ theme: theme,
422+ }
423+ toastOnly := composeFrame(state, composeCacheInput{})
424+ require.NotEmpty(t, toastOnly.cache.toastFootprints)
425+ toast := toastOnly.cache.toastFootprints[0]
426+
427+ state.floating = capturedFloatingRenderState{
428+ visible: true,
429+ focused: true,
430+ pane: capturedPaneRenderState{id: "floating", frame: renderer.NewFrame(8, 1)},
431+ geometry: floatingGeometry{
432+ Mode: ui.PresentationFloating,
433+ Bounds: domain.Rect{X: 40, Y: 2, Width: 10, Height: 3},
434+ Inner: domain.Rect{X: 41, Y: 3, Width: 8, Height: 1},
435+ },
436+ generation: 1,
437+ }
438+ composed := composeFrame(state, composeCacheInput{})
439+ dimmer := themeui.NewDimmer(theme)
440+
441+ for name, point := range map[string][2]int{
442+ "top bar": {0, 0},
443+ "bottom bar": {0, contentRows + 1},
444+ "toast": {toast.X, toast.Y},
445+ } {
446+ t.Run(name, func(t *testing.T) {
447+ x, y := point[0], point[1]
448+ require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style)
449+ })
450+ }
451+ require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache")
452+}
453+
454+func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
455+ theme := backdropTheme()
456+ lowerStyle := renderer.Style{Foreground: 1, Background: 2}
457+ higherStyle := renderer.Style{Foreground: 3, Background: 4}
458+ modalAt := func(x int, r rune, style renderer.Style) capturedModal {
459+ inner := renderer.NewFrame(1, 1)
460+ inner.Set(0, 0, renderer.Cell{Rune: r, Style: style})
461+ bounds := domain.Rect{X: x, Y: 1, Width: 1, Height: 1}
462+ return capturedModal{active: true, presentation: ui.Presentation{Bounds: bounds, Inner: bounds}, inner: inner}
463+ }
464+
465+ for _, tt := range []struct {
466+ name string
467+ lower string
468+ higher string
469+ }{
470+ {name: "notices above copy search", lower: "copy search", higher: "notices"},
471+ {name: "picker above notices", lower: "notices", higher: "picker"},
472+ {name: "palette above picker", lower: "picker", higher: "palette"},
473+ {name: "prompt above palette", lower: "palette", higher: "prompt"},
474+ } {
475+ t.Run(tt.name, func(t *testing.T) {
476+ state := capturedRenderState{theme: theme, styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}}
477+ setModal := func(name string, modal capturedModal) {
478+ switch name {
479+ case "copy search":
480+ state.overlays.copySearch = modal
481+ case "notices":
482+ state.overlays.noticesOverlay = modal
483+ case "picker":
484+ state.overlays.picker = modal
485+ case "palette":
486+ state.overlays.palette = modal
487+ case "prompt":
488+ state.overlays.prompt = modal
489+ }
490+ }
491+ setModal(tt.lower, modalAt(1, 'L', lowerStyle))
492+ setModal(tt.higher, modalAt(3, 'H', higherStyle))
493+
494+ frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil)
495+
496+ require.Equal(t, 'L', frame.At(1, 1).Rune)
497+ require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop")
498+ require.Equal(t, 'H', frame.At(3, 1).Rune)
499+ require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost")
500+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
501+ })
502+ }
503+}
504+
505+func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
506+ state := capturedRenderState{
507+ layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}},
508+ styles: fallbackChromeStyles,
509+ }
510+ snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
511+ snap := &overlayRenderSnapshot{
512+ copySearchModel: visualsearch.New(snapshot),
513+ pickerActive: true,
514+ pickerModel: picker.New(nil, picker.SelectionConfig{}),
515+ noticesOverlayActive: true,
516+ noticesOverlayModel: notices.New(nil, time.Time{}),
517+ paletteActive: true,
518+ paletteModel: palette.New(nil),
519+ promptActive: true,
520+ promptModel: promptui.New(" Prompt ", ""),
521+ }
522+
523+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
524+
525+ for name, modal := range map[string]capturedModal{
526+ "copy search": state.overlays.copySearch,
527+ "picker": state.overlays.picker,
528+ "notices": state.overlays.noticesOverlay,
529+ "palette": state.overlays.palette,
530+ "prompt": state.overlays.prompt,
531+ } {
532+ t.Run(name, func(t *testing.T) {
533+ require.True(t, modal.active)
534+ require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode)
535+ require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height})
536+ })
537+ }
538+}
539+
540+func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) {
541+ base := renderer.NewFrame(4, 3)
542+ inner := renderer.NewFrame(4, 2)
543+ for y, text := range []string{"ABCD", "EFGH"} {
544+ for x, r := range text {
545+ inner.Set(x, y, renderer.Cell{Rune: r})
546+ }
547+ }
548+
549+ copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner)
550+
551+ require.Equal(t, "BCD ", rowText(base.Row(2)))
552+}
553+
554+func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) {
555+ presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4})
556+ require.Zero(t, presentation.Bounds.Height)
557+ state := capturedRenderState{
558+ styles: themeui.Styles{PickerBase: renderer.DefaultStyle()},
559+ overlays: capturedOverlayRenderState{
560+ copySearch: capturedModal{active: true, title: "Search", presentation: presentation},
561+ },
562+ }
563+
564+ _, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil)
565+
566+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
567+}
568+
323 func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {569 func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
324 terminal := renderer.DefaultStyle()570 terminal := renderer.DefaultStyle()
325 muted := renderer.Style{Foreground: 1}571 muted := renderer.Style{Foreground: 1}
M internal/usecase/daemon/render_test.go +14 -5
@@ -48,7 +48,10 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
48 dimmed := client.Frame.At(0, 1)48 dimmed := client.Frame.At(0, 1)
49 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")49 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
50 require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")50 require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")
51- require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp")51+ for x, cell := range copyBar {
52+ cell.Style = themeui.NewDimmer(theme).Dim(cell.Style)
53+ require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop")
54+ }
52 paletteVisible := false55 paletteVisible := false
53 for y := range client.Frame.Height {56 for y := range client.Frame.Height {
54 paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")57 paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")
@@ -93,7 +96,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
93 }96 }
94 }97 }
95 98
96-func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {99+func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
97 p, release := newBlockingPTY(t)100 p, release := newBlockingPTY(t)
98 defer release()101 defer release()
99 d, sess, ac, sends := newManualSessionWithPTYs(t, p)102 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -110,7 +113,9 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
110 113
111 d.enterPalette(sess, ac)114 d.enterPalette(sess, ac)
112 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))115 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
113- require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")116+ dimmedPickerTitle := pickerTitle
117+ dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style)
118+ require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop")
114 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")119 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
115 }120 }
116 121
@@ -133,8 +138,12 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
133 dimmed := client.Frame.At(0, 1)138 dimmed := client.Frame.At(0, 1)
134 require.Equal(t, 'X', dimmed.Rune)139 require.Equal(t, 'X', dimmed.Rune)
135 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")140 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")
136- require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")141+ dimmedTopBar := topBar
137- require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")142+ dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style)
143+ dimmedBottomBar := bottomBar
144+ dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style)
145+ require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop")
146+ require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop")
138 147
139 d.handleInput(sess, ac, []byte("\x1b"))148 d.handleInput(sess, ac, []byte("\x1b"))
140 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))149 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
M internal/usecase/daemon/resize_mode.go +0 -48
@@ -32,54 +32,6 @@ func (d *Daemon) equalizePanes(target daemonActionTarget) error {
32 })32 })
33 }33 }
34 34
35-func (d *Daemon) mutateTargetLayout(target daemonActionTarget, requirePane bool, mutate func(*layout.Tree, domain.Rect) error) error {
36- if target.session == nil || target.tab == nil || (requirePane && target.pane == nil) {
37- return layout.ErrNotFound
38- }
39- d.mu.Lock()
40- if d.sessions[target.session.id] != target.session {
41- d.mu.Unlock()
42- return layout.ErrNotFound
43- }
44- target.session.mu.Lock()
45- foundTab := false
46- for _, tb := range target.session.tabs {
47- if tb == target.tab {
48- foundTab = true
49- break
50- }
51- }
52- if !foundTab {
53- target.session.mu.Unlock()
54- d.mu.Unlock()
55- return layout.ErrNotFound
56- }
57- target.tab.mu.Lock()
58- if target.tab.tree == nil || (requirePane && target.tab.panes[target.pane.id] != target.pane) {
59- target.tab.mu.Unlock()
60- target.session.mu.Unlock()
61- d.mu.Unlock()
62- return layout.ErrNotFound
63- }
64- candidate := target.tab.tree.Clone()
65- area := domain.Rect{Width: target.tab.size.Cols, Height: target.tab.size.Rows}
66- if err := mutate(candidate, area); err != nil {
67- target.tab.mu.Unlock()
68- target.session.mu.Unlock()
69- d.mu.Unlock()
70- return err
71- }
72- target.tab.tree = candidate
73- target.tab.bumpLayoutGenerationLocked()
74- target.tab.mu.Unlock()
75- target.session.mu.Unlock()
76- d.mu.Unlock()
77- if !d.applyTabLayout(target.session, target.tab) {
78- return layout.ErrNotFound
79- }
80- return nil
81-}
82-
83 func resizeUserError(err error) error {35 func resizeUserError(err error) error {
84 switch {36 switch {
85 case errors.Is(err, layout.ErrNotInSplit):37 case errors.Is(err, layout.ErrNotInSplit):
M internal/usecase/daemon/resize_publication.go +0 -200
@@ -106,203 +106,3 @@ func (d *Daemon) publishResizeCommit(members []resizeMember, sess *session, ac *
106 }106 }
107 return publication.emit()107 return publication.emit()
108 }108 }
109-
110-func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
111- ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
112- if !admitted {
113- return false
114- }
115- defer ticket.End()
116- rc := sess.renderCoordinator()
117- if rc == nil {
118- return false
119- }
120- snap := rc.resizeSnapshot()
121- current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
122- if !current() {
123- return false
124- }
125- d.exitCopyMode(ac)
126- result, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
127- return rc.resizeCurrentForLease(epoch, ac, lease, true)
128- })
129- if !ok {
130- return false
131- }
132- if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
133- markSnapshotDirty(sess)
134- }) {
135- return false
136- }
137- if !d.publishResizeCommit(result.members, sess, ac, lease, epoch, ticket, snap.size) {
138- return false
139- }
140- if len(result.failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
141- var retry *resizeRetryReservation
142- if !d.publishResizeOwnerPostEffect(result.failed, resizeOwnerPostRetrySchedule, func() {
143- retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
144- d.retryResizeMembers(sess, ac, lease, epoch, result.failed)
145- })
146- }) || retry == nil {
147- return false
148- }
149- retry.finish()
150- }
151- d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
152- if !d.publishResizeOwnerInvalidation(result.members, sess, ac, lease, epoch,
153- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
154- return false
155- }
156- // The resize debounce has already elapsed. Consume this sticky reset now;
157- // fire preserves ACK and synchronized-output gates rather than scheduling a
158- // second urgent deadline.
159- rc.fireCurrent(false)
160- return true
161-}
162-
163-// retryResizeMembers retries failed committed members through a freshly
164-// prepared tab transaction. The captured members identify retry candidates
165-// only: their rectangles must never cross this delayed boundary, because any
166-// later layout mutation may have changed their geometry or removed them.
167-func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
168- ticket, admitted := beginActiveLeaseEffect(sess, ac, lease)
169- if !admitted {
170- return
171- }
172- defer ticket.End()
173- rc := sess.renderCoordinator()
174- if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
175- return
176- }
177-
178- // Retain tab order for deterministic external PTY ordering while collapsing
179- // several failed members from the same tab into one canonical transaction.
180- tabs := make([]*tab, 0, len(members))
181- seen := make(map[*tab]struct{}, len(members))
182- for _, member := range members {
183- if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
184- continue
185- }
186- if _, ok := seen[member.tab]; ok {
187- continue
188- }
189- seen[member.tab] = struct{}{}
190- tabs = append(tabs, member.tab)
191- }
192-
193- failed := make([]resizeMember, 0, len(members))
194- succeeded := false
195- for _, tb := range tabs {
196- if !rc.retryCurrentForLease(epoch, ac, lease) {
197- return
198- }
199- // A later accepted layout transaction clears resizeRetry. In that case
200- // this delayed callback has no remaining work and, importantly, must not
201- // replay the obsolete rectangle it captured before the mutation.
202- retryPending := false
203- tb.mu.Lock()
204- for _, member := range members {
205- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
206- continue
207- }
208- member.pane.mu.Lock()
209- retryPending = retryPending || member.pane.resizeRetry
210- member.pane.mu.Unlock()
211- }
212- tb.mu.Unlock()
213- if !retryPending {
214- continue
215- }
216-
217- // applyTabLayoutTransaction captures the current generation, pane
218- // pointers, and solved rectangles, and validates them again before any
219- // VT/rectangle publication. It also preserves the resize gate and
220- // degradation notice behavior for another failed external attempt.
221- result, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
222- return rc.retryCurrentForLease(epoch, ac, lease)
223- })
224- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
225- return
226- }
227- failed = append(failed, result.failed...)
228- // A retry success is a formerly failed, still-current target whose
229- // canonical apply cleared its retry bit. A collapsed or removed target
230- // is neither a retry completion nor a reason to publish a reset.
231- tb.mu.Lock()
232- for _, member := range members {
233- if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
234- continue
235- }
236- member.pane.mu.Lock()
237- succeeded = succeeded || !member.pane.resizeRetry
238- member.pane.mu.Unlock()
239- }
240- tb.mu.Unlock()
241- }
242- for _, member := range members {
243- if !member.isFloating || member.tab == nil || member.pane == nil || !resizeMemberOwnerCurrent(&member) {
244- continue
245- }
246- if !rc.retryCurrentForLease(epoch, ac, lease) {
247- return
248- }
249- // Floating panes are outside tb.panes. Validate the exact accepted slot
250- // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
251- // the same pointer/generation check around external PTY.Resize.
252- member.tab.mu.Lock()
253- currentSlot := member.tab.floating.state == floatingVisible &&
254- member.tab.floating.generation == member.floatingGeneration &&
255- member.tab.floating.pane == member.pane &&
256- resizeMemberOwnerCurrent(&member)
257- retryPending := false
258- if currentSlot {
259- member.pane.mu.Lock()
260- retryPending = member.pane.resizeRetry
261- member.pane.mu.Unlock()
262- }
263- member.tab.mu.Unlock()
264- if !currentSlot || !retryPending {
265- continue
266- }
267- result, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
268- return rc.retryCurrentForLease(epoch, ac, lease)
269- }, &member)
270- if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
271- return
272- }
273- failed = append(failed, result.failed...)
274- member.tab.mu.Lock()
275- stillCurrent := member.tab.floating.state == floatingVisible &&
276- member.tab.floating.generation == member.floatingGeneration &&
277- member.tab.floating.pane == member.pane &&
278- resizeMemberOwnerCurrent(&member)
279- member.tab.mu.Unlock()
280- if stillCurrent && len(result.failed) == 0 {
281- member.pane.mu.Lock()
282- succeeded = succeeded || !member.pane.resizeRetry
283- member.pane.mu.Unlock()
284- }
285- }
286- if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
287- var retry *resizeRetryReservation
288- if !d.publishResizeOwnerPostEffect(failed, resizeOwnerPostRetrySchedule, func() {
289- retry, _ = rc.reserveResizeRetryForLease(epoch, ac, lease, func() {
290- d.retryResizeMembers(sess, ac, lease, epoch, failed)
291- })
292- }) || retry == nil {
293- return
294- }
295- retry.finish()
296- }
297- if succeeded {
298- // Retry completion changes VT state after the original layout commit.
299- // Keep a named session's eventual snapshot generation aligned with it.
300- if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostSnapshotDirty, func() {
301- markSnapshotDirty(sess)
302- }) {
303- return
304- }
305- d.publishResizeOwnerInvalidation(members, sess, ac, lease, 0,
306- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
307- }
308-}
D internal/usecase/daemon/resize_retry.go +0 -136
@@ -1,136 +0,0 @@
1-package daemon
2-
3-import "context"
4-
5-const maxAcceptedTabLayoutRetries = 3
6-
7-// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
8-// owner generations which accepted the failure. A moved pane cannot lend its
9-// old source worker authority over either the PTY or source publications.
10-type acceptedTabLayoutRetryToken struct {
11- session *session
12- tab *tab
13- members []resizeMember
14-}
15-
16-func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
17- return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
18-}
19-
20-func (t acceptedTabLayoutRetryToken) current() bool {
21- if t.session == nil || t.tab == nil || len(t.members) == 0 {
22- return false
23- }
24- for i := range t.members {
25- member := &t.members[i]
26- if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrent(member) {
27- return false
28- }
29- }
30- return true
31-}
32-
33-// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
34-// worker is deduplicated, derives cancellation from the tab lifecycle, and
35-// suppresses repeat degradation notices after the accepted initial failure.
36-func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab, failed ...[]resizeMember) bool {
37- if d.clock == nil || tb == nil || tb.ctx == nil {
38- return false
39- }
40- var members []resizeMember
41- if len(failed) != 0 {
42- members = failed[0]
43- } else {
44- tb.mu.Lock()
45- plan := prepareTabLayoutLocked(sess, tb)
46- tb.mu.Unlock()
47- for i := range plan.members {
48- member := plan.members[i]
49- member.pane.mu.Lock()
50- pending := member.pane.resizeRetry
51- member.pane.mu.Unlock()
52- if pending {
53- members = append(members, member)
54- }
55- }
56- }
57- token := newAcceptedTabLayoutRetryToken(sess, tb, members)
58- var ctx context.Context
59- var cancel context.CancelFunc
60- start := false
61- if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRetrySchedule, func() {
62- tb.layoutRetryMu.Lock()
63- defer tb.layoutRetryMu.Unlock()
64- if tb.layoutRetryRunning {
65- return
66- }
67- ctx, cancel = context.WithCancel(tb.ctx)
68- tb.layoutRetryRunning = true
69- tb.layoutRetryCancel = cancel
70- start = true
71- }) {
72- return false
73- }
74- if !start {
75- return true
76- }
77-
78- go func() {
79- defer func() {
80- cancel()
81- tb.layoutRetryMu.Lock()
82- tb.layoutRetryRunning = false
83- tb.layoutRetryCancel = nil
84- tb.layoutRetryMu.Unlock()
85- }()
86- for range maxAcceptedTabLayoutRetries {
87- if !token.current() {
88- return
89- }
90- timer := d.clock.NewTimer(minOutputRenderDeadline)
91- if timer == nil {
92- return
93- }
94- timerC := timer.C()
95- if timerC == nil {
96- timer.Stop()
97- return
98- }
99- select {
100- case <-ctx.Done():
101- timer.Stop()
102- return
103- case <-timerC:
104- }
105- if ctx.Err() != nil || !token.current() {
106- return
107- }
108- result, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool {
109- return ctx.Err() == nil && token.current()
110- })
111- if !ok || !token.current() {
112- return
113- }
114- if len(result.failed) == 0 {
115- return
116- }
117- token = newAcceptedTabLayoutRetryToken(sess, tb, result.failed)
118- if !token.current() {
119- return
120- }
121- if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
122- markSnapshotDirty(sess)
123- }) {
124- return
125- }
126- sess.mu.Lock()
127- ac := sess.client
128- sess.mu.Unlock()
129- if ac != nil && !d.publishResizeOwnerInvalidation(result.members, sess, ac, nil, 0,
130- renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
131- return
132- }
133- }
134- }()
135- return true
136-}
A internal/usecase/daemon/resize_retry_token.go +27 -0
@@ -0,0 +1,27 @@
1+package daemon
2+
3+// acceptedTabLayoutRetryToken binds a delayed tiled retry to the exact pane
4+// owner generations which accepted the failure. A moved pane cannot lend its
5+// old source worker authority over either the PTY or source publications.
6+type acceptedTabLayoutRetryToken struct {
7+ session *session
8+ tab *tab
9+ members []resizeMember
10+}
11+
12+func newAcceptedTabLayoutRetryToken(sess *session, tb *tab, members []resizeMember) acceptedTabLayoutRetryToken {
13+ return acceptedTabLayoutRetryToken{session: sess, tab: tb, members: append([]resizeMember(nil), members...)}
14+}
15+
16+func (t acceptedTabLayoutRetryToken) current() bool {
17+ if t.session == nil || t.tab == nil || len(t.members) == 0 {
18+ return false
19+ }
20+ for i := range t.members {
21+ member := &t.members[i]
22+ if member.session != t.session || member.tab != t.tab || !resizeMemberOwnerCurrent(member) {
23+ return false
24+ }
25+ }
26+ return true
27+}
M internal/usecase/daemon/snapshot_restore_test.go +154 -10
@@ -41,11 +41,7 @@ func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.Snap
41 if r.publishErr != nil {41 if r.publishErr != nil {
42 return r.publishErr42 return r.publishErr
43 }43 }
44- objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects))44+ r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p))
45- for _, object := range p.Objects {
46- objects[object.Digest] = append([]byte(nil), object.Data...)
47- }
48- r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
49 found := false45 found := false
50 for _, name := range r.names {46 for _, name := range r.names {
51 found = found || name == p.Name47 found = found || name == p.Name
@@ -183,11 +179,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
183 manifest.Generation = generation179 manifest.Generation = generation
184 encoded, err := snapcodec.MarshalManifest(manifest)180 encoded, err := snapcodec.MarshalManifest(manifest)
185 require.NoError(t, err)181 require.NoError(t, err)
186- objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))182+ publication.Generation = generation
187- for _, object := range publication.Objects {183+ publication.Manifest = encoded
188- objects[object.Digest] = append([]byte(nil), object.Data...)184+ return cloneAcceptanceGeneration(snapshotGeneration(publication))
189- }
190- return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects}
191 }185 }
192 186
193 func TestValidateRestoreSessionSnapshot(t *testing.T) {187 func TestValidateRestoreSessionSnapshot(t *testing.T) {
@@ -267,6 +261,156 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
267 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")261 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
268 }262 }
269 263
264+type paneLayoutSnapshotFixture struct {
265+ snapshot snapcodec.Session
266+ tree *layout.Tree
267+ tabStableID string
268+ paneStableIDs map[layout.PaneID]string
269+}
270+
271+type restoredPaneLayout struct {
272+ tree *layout.Tree
273+ tabStableID string
274+ paneStableIDs map[layout.PaneID]string
275+}
276+
277+func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) {
278+ fixture := captureConsumedAndExpelledPaneLayout(t)
279+ require.Len(t, fixture.snapshot.Tabs, 1)
280+ decodedTab := fixture.snapshot.Tabs[0]
281+
282+ require.Equal(t, fixture.tabStableID, decodedTab.StableID)
283+ require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved")
284+ assertConsumedAndExpelledColumns(t, decodedTab.Tree)
285+ require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree")
286+ require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID")
287+}
288+
289+func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) {
290+ fixture := captureConsumedAndExpelledPaneLayout(t)
291+ restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{})
292+ restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot)
293+ require.NoError(t, err)
294+ defer closeRestoredTabs(restoredTabs)
295+ require.Len(t, restoredTabs, 1)
296+
297+ restored := captureRestoredPaneLayout(restoredTabs[0])
298+ require.Equal(t, fixture.tabStableID, restored.tabStableID)
299+ assertConsumedAndExpelledColumns(t, restored.tree)
300+ require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree")
301+ require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID")
302+}
303+
304+func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture {
305+ t.Helper()
306+ h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree())
307+ h.session.createdAt = 77
308+ h.session.incarnation = domain.IncarnationID{7}
309+ tabStableID := "tab-stable-rearranged"
310+ paneStableIDs := map[layout.PaneID]string{
311+ "pane-1": "pane-stable-1",
312+ "pane-2": "pane-stable-2",
313+ "pane-3": "pane-stable-3",
314+ "pane-4": "pane-stable-4",
315+ "pane-5": "pane-stable-5",
316+ }
317+ setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs)
318+ applyPaneLayoutSnapshotOperations(t, h)
319+
320+ h.tab.mu.Lock()
321+ wantTree := h.tab.tree.Clone()
322+ h.tab.mu.Unlock()
323+ capture, ok := h.daemon.captureSnapshotState(h.session, 7)
324+ require.True(t, ok)
325+ publication, err := h.daemon.incrementalPublication(capture)
326+ require.NoError(t, err)
327+ decoded, err := sessionFromGeneration(snapshotGeneration(publication))
328+ require.NoError(t, err)
329+
330+ return paneLayoutSnapshotFixture{
331+ snapshot: decoded,
332+ tree: wantTree,
333+ tabStableID: tabStableID,
334+ paneStableIDs: paneStableIDs,
335+ }
336+}
337+
338+func paneLayoutSnapshotTree() *layout.Tree {
339+ return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{
340+ {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40},
341+ {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{
342+ {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10},
343+ {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20},
344+ }},
345+ {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{
346+ {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7},
347+ {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3},
348+ }},
349+ }}, Focus: "pane-4"}
350+}
351+
352+func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) {
353+ tb.mu.Lock()
354+ defer tb.mu.Unlock()
355+ tb.stableID = tabStableID
356+ tb.nextPaneID = 6
357+ for id, stableID := range paneStableIDs {
358+ tb.panes[id].mu.Lock()
359+ tb.panes[id].stableID = stableID
360+ tb.panes[id].mu.Unlock()
361+ }
362+}
363+
364+func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) {
365+ t.Helper()
366+ for _, operation := range []struct {
367+ name string
368+ pane layout.PaneID
369+ direction layout.Direction
370+ }{
371+ {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right},
372+ {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right},
373+ } {
374+ err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
375+ kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction,
376+ })
377+ require.NoError(t, err, operation.name)
378+ }
379+}
380+
381+func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) {
382+ t.Helper()
383+ require.NotNil(t, tree)
384+ require.NotNil(t, tree.Root)
385+ require.Equal(t, layout.Horizontal, tree.Root.Dir)
386+ require.Len(t, tree.Root.Children, 3)
387+ require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column")
388+ require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column")
389+ require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present")
390+ require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved")
391+ require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved")
392+}
393+
394+func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string {
395+ stableIDs := make(map[layout.PaneID]string, len(panes))
396+ for _, p := range panes {
397+ stableIDs[p.ID] = p.StableID
398+ }
399+ return stableIDs
400+}
401+
402+func captureRestoredPaneLayout(tb *tab) restoredPaneLayout {
403+ tb.mu.Lock()
404+ defer tb.mu.Unlock()
405+ stableIDs := make(map[layout.PaneID]string, len(tb.panes))
406+ for id, p := range tb.panes {
407+ p.mu.Lock()
408+ stableIDs[id] = p.stableID
409+ p.mu.Unlock()
410+ }
411+ return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs}
412+}
413+
270 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {414 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
271 snapshot := restoreAcceptanceSession(t, "restored")415 snapshot := restoreAcceptanceSession(t, "restored")
272 generation := acceptanceGeneration(t, snapshot, 9)416 generation := acceptanceGeneration(t, snapshot, 9)
M internal/usecase/daemon/snatched_render.go +2 -1
@@ -23,7 +23,8 @@ func snatchedPanelFrame(size domain.Size, styles themeui.Styles, feedback string
23 return frame23 return frame
24 }24 }
25 25
26- inner := snatchedModal.Composite(frame, styles.BorderMuted, styles.PickerBase)26+ presentation := snatchedModal.Resolve(domain.Size{Cols: width, Rows: height})
27+ inner := snatchedModal.CompositePresentation(frame, presentation, styles.BorderMuted, styles.PickerBase)
27 message := "This session is now active elsewhere."28 message := "This session is now active elsewhere."
28 if feedback != "" {29 if feedback != "" {
29 message = feedback30 message = feedback
M internal/usecase/daemon/snatched_render_test.go +1 -1
@@ -92,7 +92,7 @@ func TestSnatchedPanelFrame(t *testing.T) {
92 return92 return
93 }93 }
94 94
95- require.Equal(t, tt.wantBounds, snatchedModal.Bounds(tt.wantSize))95+ require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds)
96 topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y)96 topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y)
97 bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1)97 bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1)
98 require.Equal(t, '┌', topLeft.Rune)98 require.Equal(t, '┌', topLeft.Rune)
M internal/usecase/daemon/transactional_resize.go +295 -111
@@ -1,6 +1,8 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "context"
5+
4 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
6 "github.com/bnema/vev/internal/usecase/layout"8 "github.com/bnema/vev/internal/usecase/layout"
@@ -9,13 +11,13 @@ import (
9 type resizeOwnerPostEffect uint811 type resizeOwnerPostEffect uint8
10 12
11 const (13 const (
12- resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota + 114+ resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota
13 resizeOwnerPostRetrySchedule15 resizeOwnerPostRetrySchedule
14 resizeOwnerPostRenderInvalidation16 resizeOwnerPostRenderInvalidation
15 resizeOwnerPostCommitPublication17 resizeOwnerPostCommitPublication
16 )18 )
17 19
18-// resizeMember is an unpublished layout decision. Prepare only reads guarded20+// resizeMember is an unpublished layout decision. Prepare only reads guarded
19 // state; apply performs the external PTY call; commit is the sole publisher.21 // state; apply performs the external PTY call; commit is the sole publisher.
20 type resizeMember struct {22 type resizeMember struct {
21 session *session23 session *session
@@ -43,11 +45,6 @@ type preparedTabLayout struct {
43 members []resizeMember45 members []resizeMember
44 }46 }
45 47
46-type resizeApplyResult struct {
47- members []resizeMember
48- failed []resizeMember
49-}
50-
51 func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {48 func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
52 return prepareTabLayoutForSizeLocked(sess, tb, tb.size)49 return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
53 }50 }
@@ -138,9 +135,6 @@ func resizeMemberOwnerCurrent(member *resizeMember) bool {
138 if member == nil || member.pane == nil {135 if member == nil || member.pane == nil {
139 return false136 return false
140 }137 }
141- // A zero lease is retained only for focused tests which construct the
142- // transactional primitive directly. Every prepared production member owns
143- // an immutable generation lease.
144 if member.owner.owner == nil {138 if member.owner.owner == nil {
145 return true139 return true
146 }140 }
@@ -170,33 +164,6 @@ func resizeMembersOwnerCurrent(members []resizeMember) bool {
170 return true164 return true
171 }165 }
172 166
173-func (d *Daemon) publishResizeOwnerInvalidation(members []resizeMember, sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, inv renderInvalidation) bool {
174- var reservation *renderInvalidationReservation
175- fallback := false
176- if !d.publishResizeOwnerPostEffect(members, resizeOwnerPostRenderInvalidation, func() {
177- if rc := sess.renderCoordinator(); rc != nil {
178- reservation, _ = rc.reserveInvalidationForLeaseAtResizeEpoch(ac, lease, epoch, inv)
179- return
180- }
181- fallback = ac != nil
182- }) {
183- return false
184- }
185- if reservation != nil {
186- reservation.finish()
187- return true
188- }
189- if fallback {
190- d.paint(sess, ac, inv.reset, nil)
191- return true
192- }
193- return ac == nil
194-}
195-
196-func preparedTabOwnerStale(plan *preparedTabLayout) bool {
197- return plan != nil && !resizeMembersOwnerCurrent(plan.members)
198-}
199-
200 func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {167 func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
201 for i := range plan.members {168 for i := range plan.members {
202 member := &plan.members[i]169 member := &plan.members[i]
@@ -270,22 +237,22 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool
270 // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns237 // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
271 // one per-tab single-writer loop, but never holds tab or pane state locks while238 // one per-tab single-writer loop, but never holds tab or pane state locks while
272 // invoking PTY.Resize.239 // invoking PTY.Resize.
273-func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) (resizeApplyResult, bool) {240+func (d *Daemon) applyTabLayoutTransaction(sess *session, tb *tab, current ...func() bool) ([]resizeMember, bool) {
274 return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)241 return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)
275 }242 }
276 243
277-func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) (resizeApplyResult, bool) {244+func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, reportFailure bool, current ...func() bool) ([]resizeMember, bool) {
278 if tb == nil {245 if tb == nil {
279- return resizeApplyResult{}, false246+ return nil, false
280 }247 }
281 tb.layoutApplyMu.Lock()248 tb.layoutApplyMu.Lock()
282 defer tb.layoutApplyMu.Unlock()249 defer tb.layoutApplyMu.Unlock()
283 for {250 for {
284 if tb.ctx != nil && tb.ctx.Err() != nil {251 if tb.ctx != nil && tb.ctx.Err() != nil {
285- return resizeApplyResult{}, false252+ return nil, false
286 }253 }
287 if len(current) != 0 && !current[0]() {254 if len(current) != 0 && !current[0]() {
288- return resizeApplyResult{}, false255+ return nil, false
289 }256 }
290 tb.mu.Lock()257 tb.mu.Lock()
291 plan := prepareTabLayoutLocked(sess, tb)258 plan := prepareTabLayoutLocked(sess, tb)
@@ -304,10 +271,6 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
304 d.finishPreparedTabMembers(&plan, accepted)271 d.finishPreparedTabMembers(&plan, accepted)
305 if !accepted {272 if !accepted {
306 d.cancelStalePreparedGates(sess, tb, &plan)273 d.cancelStalePreparedGates(sess, tb, &plan)
307- if preparedTabOwnerStale(&plan) {
308- d.releasePreparedSessionGates([]*preparedTabLayout{&plan})
309- return resizeApplyResult{}, false
310- }
311 if len(current) != 0 && !current[0]() {274 if len(current) != 0 && !current[0]() {
312 for i := range plan.members {275 for i := range plan.members {
313 member := &plan.members[i]276 member := &plan.members[i]
@@ -318,7 +281,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
318 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)281 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
319 member.pane.resizeMu.Unlock()282 member.pane.resizeMu.Unlock()
320 }283 }
321- return resizeApplyResult{}, false284+ return nil, false
322 }285 }
323 continue286 continue
324 }287 }
@@ -328,32 +291,87 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
328 failed = append(failed, member)291 failed = append(failed, member)
329 }292 }
330 }293 }
331- if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) {294+ if reportFailure && len(failed) != 0 {
332 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,295 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
333 "pane resize failed; retrying in background", failed[len(failed)-1].err)296 "pane resize failed; retrying in background", failed[len(failed)-1].err)
334 }297 }
335- return resizeApplyResult{members: append([]resizeMember(nil), plan.members...), failed: failed}, true298+ return failed, true
336 }299 }
337 }300 }
338 301
339 func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {302 func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
340- result, ok := d.applyTabLayoutTransaction(sess, tb)303+ failed, ok := d.applyTabLayoutTransaction(sess, tb)
341 if !ok {304 if !ok {
342 return false305 return false
343 }306 }
344- if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {307+ markSnapshotDirty(sess)
345- markSnapshotDirty(sess)308+ if len(failed) != 0 {
346- }) {309+ d.scheduleAcceptedTabLayoutRetry(sess, tb)
347- return false
348- }
349- if len(result.failed) != 0 {
350- if !d.scheduleAcceptedTabLayoutRetry(sess, tb, result.failed) {
351- return false
352- }
353 }310 }
354 return true311 return true
355 }312 }
356 313
314+const maxAcceptedTabLayoutRetries = 3
315+
316+// scheduleAcceptedTabLayoutRetry owns one bounded retry worker per tab. The
317+// worker is deduplicated, derives cancellation from the tab lifecycle, and
318+// suppresses repeat degradation notices after the accepted initial failure.
319+func (d *Daemon) scheduleAcceptedTabLayoutRetry(sess *session, tb *tab) {
320+ if d.clock == nil || tb == nil || tb.ctx == nil {
321+ return
322+ }
323+ tb.layoutRetryMu.Lock()
324+ if tb.layoutRetryRunning {
325+ tb.layoutRetryMu.Unlock()
326+ return
327+ }
328+ ctx, cancel := context.WithCancel(tb.ctx)
329+ tb.layoutRetryRunning = true
330+ tb.layoutRetryCancel = cancel
331+ tb.layoutRetryMu.Unlock()
332+
333+ go func() {
334+ defer func() {
335+ cancel()
336+ tb.layoutRetryMu.Lock()
337+ tb.layoutRetryRunning = false
338+ tb.layoutRetryCancel = nil
339+ tb.layoutRetryMu.Unlock()
340+ }()
341+ for range maxAcceptedTabLayoutRetries {
342+ timer := d.clock.NewTimer(minOutputRenderDeadline)
343+ if timer == nil {
344+ return
345+ }
346+ timerC := timer.C()
347+ if timerC == nil {
348+ timer.Stop()
349+ return
350+ }
351+ select {
352+ case <-ctx.Done():
353+ timer.Stop()
354+ return
355+ case <-timerC:
356+ }
357+ if ctx.Err() != nil {
358+ return
359+ }
360+ failed, ok := d.applyTabLayoutTransactionWithNotice(sess, tb, false, func() bool { return ctx.Err() == nil })
361+ if !ok || len(failed) == 0 {
362+ return
363+ }
364+ markSnapshotDirty(sess)
365+ sess.mu.Lock()
366+ ac := sess.client
367+ sess.mu.Unlock()
368+ if ac != nil {
369+ d.invalidateRender(sess, ac, true, "transactional_resize.go")
370+ }
371+ }
372+ }()
373+}
374+
357 // replayResizePending completes a pane's apply epoch. It keeps the gate set375 // replayResizePending completes a pane's apply epoch. It keeps the gate set
358 // while each buffered batch goes through the normal VT path, so reads arriving376 // while each buffered batch goes through the normal VT path, so reads arriving
359 // during replay join the same ordered stream. Failure deliberately omits the377 // during replay join the same ordered stream. Failure deliberately omits the
@@ -390,30 +408,53 @@ func (d *Daemon) replayResizePending(sess *session, tb *tab, p *pane, resized bo
390 // used for retryable PTY work. Floating state is not part of a tiled layout408 // used for retryable PTY work. Floating state is not part of a tiled layout
391 // generation, so its slot generation and exact pane identity validate its409 // generation, so its slot generation and exact pane identity validate its
392 // publication instead.410 // publication instead.
393-func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) (resizeApplyResult, bool) {411+func (d *Daemon) applyVisibleFloatingLayout(sess *session, tb *tab, current func() bool) ([]resizeMember, bool) {
394 return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)412 return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)
395 }413 }
396 414
397 // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the415 // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
398 // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,416 // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
399 // so accepting a replacement here would silently retry unrelated geometry.417 // so accepting a replacement here would silently retry unrelated geometry.
400-func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) (resizeApplyResult, bool) {418+func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, current func() bool, expected *resizeMember) ([]resizeMember, bool) {
401 if tb == nil {419 if tb == nil {
402- return resizeApplyResult{}, true420+ return nil, true
403 }421 }
404 tb.mu.Lock()422 tb.mu.Lock()
405 if tb.floating.state != floatingVisible || tb.floating.pane == nil ||423 if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
406 (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {424 (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
407 tb.mu.Unlock()425 tb.mu.Unlock()
408- return resizeApplyResult{}, true426+ return nil, true
409 }427 }
410 p := tb.floating.pane428 p := tb.floating.pane
411 generation := tb.floating.generation429 generation := tb.floating.generation
412 size := tb.size430 size := tb.size
413 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())431 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
414 tb.mu.Unlock()432 tb.mu.Unlock()
415- if !geometry.valid() {433+ if !geometry.committable() {
416- return resizeApplyResult{}, true434+ return nil, true
435+ }
436+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
437+ // A drawer can validly reserve only its separator row. Publish that
438+ // presentation without inventing a physical PTY size; the last usable
439+ // rectangle and screen remain committed until a later resize has content.
440+ tb.mu.Lock()
441+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
442+ tb.floating.pane == p && tb.size == size
443+ if current != nil && !current() {
444+ currentSlot = false
445+ }
446+ if currentSlot {
447+ p.mu.Lock()
448+ p.popupGeometry = geometry
449+ p.mu.Unlock()
450+ tb.mu.Unlock()
451+ return nil, true
452+ }
453+ tb.mu.Unlock()
454+ if current != nil && !current() {
455+ return nil, false
456+ }
457+ return nil, true
417 }458 }
418 459
419 // This keeps successful PTY resizes gated until the460 // This keeps successful PTY resizes gated until the
@@ -423,34 +464,30 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
423 owner := p.effectLeaseLocked()464 owner := p.effectLeaseLocked()
424 p.mu.Unlock()465 p.mu.Unlock()
425 plan := preparedTabLayout{members: []resizeMember{{466 plan := preparedTabLayout{members: []resizeMember{{
426- session: sess, tab: tb, pane: p, owner: owner, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,467+ session: sess, tab: tb, pane: p, owner: owner, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,
427 }}}468 }}}
428 d.applyPreparedTabMembers(&plan)469 d.applyPreparedTabMembers(&plan)
429 470
430 // The PTY may have accepted an intermediate size, but a hidden, replaced,471 // The PTY may have accepted an intermediate size, but a hidden, replaced,
431 // relaunched, or resized slot must never receive this attempt's geometry.472 // relaunched, or resized slot must never receive this attempt's geometry.
432 tb.mu.Lock()473 tb.mu.Lock()
433- currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size &&474+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && resizeMemberOwnerCurrent(&plan.members[0])
434- resizeMemberOwnerCurrent(&plan.members[0])
435 if current != nil && !current() {475 if current != nil && !current() {
436 currentSlot = false476 currentSlot = false
437 }477 }
438- if currentSlot {478+ if currentSlot && plan.members[0].ok {
439 p.mu.Lock()479 p.mu.Lock()
440 currentSlot = resizeMemberOwnerCurrent(&plan.members[0])480 currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
441 if currentSlot {481 if currentSlot {
442- p.rect = geometry.Inner482+ p.rect = plan.members[0].rect
443 p.popupGeometry = geometry483 p.popupGeometry = geometry
444- if plan.members[0].ok {484+ p.resizeRetry = false
445- p.resizeRetry = false485+ p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
446- p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
447- }
448 }486 }
449 p.mu.Unlock()487 p.mu.Unlock()
450 }488 }
451 tb.mu.Unlock()489 tb.mu.Unlock()
452 if !currentSlot {490 if !currentSlot {
453- ownerCurrent := resizeMemberOwnerCurrent(&plan.members[0])
454 // This attempt has no tiled transaction loop to retain the gate for, so491 // This attempt has no tiled transaction loop to retain the gate for, so
455 // discard its buffered bytes at the old screen size before returning.492 // discard its buffered bytes at the old screen size before returning.
456 for i := range plan.members {493 for i := range plan.members {
@@ -462,17 +499,16 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
462 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)499 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
463 member.pane.resizeMu.Unlock()500 member.pane.resizeMu.Unlock()
464 }501 }
465- if !ownerCurrent || (current != nil && !current()) {502+ if current != nil && !current() {
466- return resizeApplyResult{}, false503+ return nil, false
467 }504 }
468- return resizeApplyResult{}, true505+ return nil, true
469 }506 }
470 if plan.members[0].ok {507 if plan.members[0].ok {
471 d.finishPreparedTabMembers(&plan, true)508 d.finishPreparedTabMembers(&plan, true)
472- return resizeApplyResult{members: append([]resizeMember(nil), plan.members...)}, true509+ return nil, true
473 }510 }
474- members := append([]resizeMember(nil), plan.members...)511+ return plan.members, true
475- return resizeApplyResult{members: members, failed: members}, true
476 }512 }
477 513
478 // releasePreparedSessionGates abandons a session attempt which cannot be514 // releasePreparedSessionGates abandons a session attempt which cannot be
@@ -501,9 +537,9 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
501 // transaction: all PTYs are applied and plans validated first, then the537 // transaction: all PTYs are applied and plans validated first, then the
502 // coordinator admits the epoch before any tab size, rectangle, VT screen,538 // coordinator admits the epoch before any tab size, rectangle, VT screen,
503 // snapshot dirtiness, or resize telemetry becomes visible.539 // snapshot dirtiness, or resize telemetry becomes visible.
504-func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) (resizeApplyResult, bool) {540+func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
505 if sess == nil {541 if sess == nil {
506- return resizeApplyResult{}, false542+ return nil, false
507 }543 }
508 sess.layoutApplyMu.Lock()544 sess.layoutApplyMu.Lock()
509 defer sess.layoutApplyMu.Unlock()545 defer sess.layoutApplyMu.Unlock()
@@ -511,10 +547,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
511 target := tabSize(size)547 target := tabSize(size)
512 for {548 for {
513 if sess.ctx != nil && sess.ctx.Err() != nil {549 if sess.ctx != nil && sess.ctx.Err() != nil {
514- return resizeApplyResult{}, false550+ return nil, false
515 }551 }
516 if current != nil && !current() {552 if current != nil && !current() {
517- return resizeApplyResult{}, false553+ return nil, false
518 }554 }
519 sess.mu.Lock()555 sess.mu.Lock()
520 tabs := append([]*tab(nil), sess.tabs...)556 tabs := append([]*tab(nil), sess.tabs...)
@@ -565,11 +601,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
565 valid = false601 valid = false
566 break602 break
567 }603 }
568- }604+ plan.tab.size = plan.size
569- if valid {
570- for _, plan := range plans {
571- plan.tab.size = plan.size
572- }
573 }605 }
574 }606 }
575 for i := len(tabs) - 1; i >= 0; i-- {607 for i := len(tabs) - 1; i >= 0; i-- {
@@ -577,27 +609,20 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
577 }609 }
578 if !valid {610 if !valid {
579 d.releasePreparedSessionGates(plans)611 d.releasePreparedSessionGates(plans)
580- for _, plan := range plans {
581- if preparedTabOwnerStale(plan) {
582- return resizeApplyResult{}, false
583- }
584- }
585 // Headless requests have no attachment epoch. A live tab mutation612 // Headless requests have no attachment epoch. A live tab mutation
586 // still invalidates their plan, but must trigger a fresh prepare rather613 // still invalidates their plan, but must trigger a fresh prepare rather
587 // than being mistaken for a canceled request.614 // than being mistaken for a canceled request.
588 if current != nil && !current() {615 if current != nil && !current() {
589- return resizeApplyResult{}, false616+ return nil, false
590 }617 }
591 // A layout mutation invalidated the plans while this epoch remains618 // A layout mutation invalidated the plans while this epoch remains
592 // current. Reapply the fresh session geometry before admitting it.619 // current. Reapply the fresh session geometry before admitting it.
593 continue620 continue
594 }621 }
595 622
596- members := make([]resizeMember, 0)
597 failed := make([]resizeMember, 0)623 failed := make([]resizeMember, 0)
598 for _, plan := range plans {624 for _, plan := range plans {
599 d.finishPreparedTabMembers(plan, true)625 d.finishPreparedTabMembers(plan, true)
600- members = append(members, plan.members...)
601 for _, member := range plan.members {626 for _, member := range plan.members {
602 if !member.ok {627 if !member.ok {
603 failed = append(failed, member)628 failed = append(failed, member)
@@ -605,12 +630,11 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
605 }630 }
606 }631 }
607 if active >= 0 && active < len(tabs) {632 if active >= 0 && active < len(tabs) {
608- floatingResult, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)633+ floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
609 if !ok {634 if !ok {
610- return resizeApplyResult{}, false635+ return nil, false
611 }636 }
612- members = append(members, floatingResult.members...)637+ failed = append(failed, floatingFailed...)
613- failed = append(failed, floatingResult.failed...)
614 }638 }
615 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)639 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
616 if len(failed) != 0 && failedCurrent {640 if len(failed) != 0 && failedCurrent {
@@ -618,9 +642,174 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
618 "pane resize failed; retrying in background", failed[len(failed)-1].err)642 "pane resize failed; retrying in background", failed[len(failed)-1].err)
619 }643 }
620 if len(failed) != 0 && !failedCurrent {644 if len(failed) != 0 && !failedCurrent {
621- return resizeApplyResult{}, false645+ return nil, false
622 }646 }
623- return resizeApplyResult{members: members, failed: failed}, true647+ markSnapshotDirty(sess)
648+ d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
649+ return failed, true
650+ }
651+}
652+
653+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
654+ rc := sess.renderCoordinator()
655+ if rc == nil {
656+ return false
657+ }
658+ snap := rc.resizeSnapshot()
659+ current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
660+ if !current() {
661+ return false
662+ }
663+ d.exitCopyMode(ac)
664+ failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
665+ return rc.resizeCurrentForLease(epoch, ac, lease, true)
666+ })
667+ if !ok {
668+ return false
669+ }
670+ ac.sendMu.Lock()
671+ ac.size = snap.size
672+ ac.sendMu.Unlock()
673+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
674+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
675+ }
676+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
677+ // A successful transaction publishes exactly one full S2 frame. The
678+ // coordinator is the only emission route and stale epochs never reach it.
679+ if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
680+ return false
681+ }
682+ // The resize debounce has already elapsed. Consume this sticky reset now;
683+ // fire preserves ACK and synchronized-output gates rather than scheduling a
684+ // second urgent deadline.
685+ rc.fireCurrent(false)
686+ return true
687+}
688+
689+// retryResizeMembers retries failed committed members through a freshly
690+// prepared tab transaction. The captured members identify retry candidates
691+// only: their rectangles must never cross this delayed boundary, because any
692+// later layout mutation may have changed their geometry or removed them.
693+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
694+ rc := sess.renderCoordinator()
695+ if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
696+ return
697+ }
698+
699+ // Retain tab order for deterministic external PTY ordering while collapsing
700+ // several failed members from the same tab into one canonical transaction.
701+ tabs := make([]*tab, 0, len(members))
702+ seen := make(map[*tab]struct{}, len(members))
703+ for _, member := range members {
704+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
705+ continue
706+ }
707+ if _, ok := seen[member.tab]; ok {
708+ continue
709+ }
710+ seen[member.tab] = struct{}{}
711+ tabs = append(tabs, member.tab)
712+ }
713+
714+ failed := make([]resizeMember, 0, len(members))
715+ succeeded := false
716+ for _, tb := range tabs {
717+ if !rc.retryCurrentForLease(epoch, ac, lease) {
718+ return
719+ }
720+ // A later accepted layout transaction clears resizeRetry. In that case
721+ // this delayed callback has no remaining work and, importantly, must not
722+ // replay the obsolete rectangle it captured before the mutation.
723+ retryPending := false
724+ tb.mu.Lock()
725+ for _, member := range members {
726+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
727+ continue
728+ }
729+ member.pane.mu.Lock()
730+ retryPending = retryPending || member.pane.resizeRetry
731+ member.pane.mu.Unlock()
732+ }
733+ tb.mu.Unlock()
734+ if !retryPending {
735+ continue
736+ }
737+
738+ // applyTabLayoutTransaction captures the current generation, pane
739+ // pointers, and solved rectangles, and validates them again before any
740+ // VT/rectangle publication. It also preserves the resize gate and
741+ // degradation notice behavior for another failed external attempt.
742+ freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
743+ return rc.retryCurrentForLease(epoch, ac, lease)
744+ })
745+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
746+ return
747+ }
748+ failed = append(failed, freshFailed...)
749+ // A retry success is a formerly failed, still-current target whose
750+ // canonical apply cleared its retry bit. A collapsed or removed target
751+ // is neither a retry completion nor a reason to publish a reset.
752+ tb.mu.Lock()
753+ for _, member := range members {
754+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
755+ continue
756+ }
757+ member.pane.mu.Lock()
758+ succeeded = succeeded || !member.pane.resizeRetry
759+ member.pane.mu.Unlock()
760+ }
761+ tb.mu.Unlock()
762+ }
763+ for _, member := range members {
764+ if !member.isFloating || member.tab == nil || member.pane == nil {
765+ continue
766+ }
767+ if !rc.retryCurrentForLease(epoch, ac, lease) {
768+ return
769+ }
770+ // Floating panes are outside tb.panes. Validate the exact accepted slot
771+ // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
772+ // the same pointer/generation check around external PTY.Resize.
773+ member.tab.mu.Lock()
774+ currentSlot := member.tab.floating.state == floatingVisible &&
775+ member.tab.floating.generation == member.floatingGeneration &&
776+ member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
777+ retryPending := false
778+ if currentSlot {
779+ member.pane.mu.Lock()
780+ retryPending = member.pane.resizeRetry
781+ member.pane.mu.Unlock()
782+ }
783+ member.tab.mu.Unlock()
784+ if !currentSlot || !retryPending {
785+ continue
786+ }
787+ freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
788+ return rc.retryCurrentForLease(epoch, ac, lease)
789+ }, &member)
790+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
791+ return
792+ }
793+ failed = append(failed, freshFailed...)
794+ member.tab.mu.Lock()
795+ stillCurrent := member.tab.floating.state == floatingVisible &&
796+ member.tab.floating.generation == member.floatingGeneration &&
797+ member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
798+ member.tab.mu.Unlock()
799+ if stillCurrent && len(freshFailed) == 0 {
800+ member.pane.mu.Lock()
801+ succeeded = succeeded || !member.pane.resizeRetry
802+ member.pane.mu.Unlock()
803+ }
804+ }
805+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
806+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
807+ }
808+ if succeeded {
809+ // Retry completion changes VT state after the original layout commit.
810+ // Keep a named session's eventual snapshot generation aligned with it.
811+ markSnapshotDirty(sess)
812+ rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
624 }813 }
625 }814 }
626 815
@@ -646,13 +835,8 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
646 if ac == nil {835 if ac == nil {
647 // Headless geometry has no coordinator/transport to coalesce, but keeps836 // Headless geometry has no coordinator/transport to coalesce, but keeps
648 // the same prepare/apply/commit ordering.837 // the same prepare/apply/commit ordering.
649- result, ok := d.applySessionLayout(sess, size, nil, nil)838+ _, ok := d.applySessionLayout(sess, size, nil, nil)
650- if !ok || !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {839+ return ok
651- markSnapshotDirty(sess)
652- }) {
653- return false
654- }
655- return d.publishResizeCommit(result.members, sess, nil, nil, 0, nil, size)
656 }840 }
657 rc := sess.renderCoordinator()841 rc := sess.renderCoordinator()
658 if rc == nil || !rc.leaseCurrent(lease, true) {842 if rc == nil || !rc.leaseCurrent(lease, true) {
M internal/usecase/daemon/transactional_resize_test.go +190 -399
@@ -5,7 +5,6 @@ import (
5 "io"5 "io"
6 "strings"6 "strings"
7 "sync"7 "sync"
8- "sync/atomic"
9 "testing"8 "testing"
10 "time"9 "time"
11 10
@@ -14,6 +13,7 @@ import (
14 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
16 "github.com/bnema/vev/internal/usecase/layout"15 "github.com/bnema/vev/internal/usecase/layout"
16+ "github.com/bnema/vev/internal/usecase/ui"
17 "github.com/bnema/vev/internal/usecase/visualsearch"17 "github.com/bnema/vev/internal/usecase/visualsearch"
18 )18 )
19 19
@@ -272,10 +272,10 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
272 }272 }
273 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)273 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
274 274
275- result, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)275+ failed, ok := d.applySessionLayout(sess, domain.Size{Cols: 100, Rows: 30}, nil, nil)
276 276
277 require.Equal(t, tc.wantOK, ok)277 require.Equal(t, tc.wantOK, ok)
278- require.Len(t, result.failed, tc.wantFailed)278+ require.Len(t, failed, tc.wantFailed)
279 count := 0279 count := 0
280 for _, notification := range d.notices.history() {280 for _, notification := range d.notices.history() {
281 if notification.Code == domain.NoticeResizeFailed {281 if notification.Code == domain.NoticeResizeFailed {
@@ -407,402 +407,6 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
407 // stopped. Only the newest prepared epoch may apply, commit, and emit one full407 // stopped. Only the newest prepared epoch may apply, commit, and emit one full
408 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its408 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
409 // channels make callback order explicit and do not use wall-clock waits.409 // channels make callback order explicit and do not use wall-clock waits.
410-func publishResizeOwnerMoveUnderFence(t *testing.T, source, destination *session, tb *tab, p *pane) {
411- t.Helper()
412- fences := newMovePaneResizeFences(source, destination, tb, tb, p)
413- require.True(t, fences.acquire(func() bool {
414- p.mu.Lock()
415- p.publishOwnerLocked(destination, tb, 0)
416- p.mu.Unlock()
417- return true
418- }))
419- fences.Release()
420-}
421-
422-func pauseResizeOwnerPostEffect(d *Daemon, effect resizeOwnerPostEffect) (<-chan struct{}, func()) {
423- entered := make(chan struct{})
424- release := make(chan struct{})
425- var once sync.Once
426- d.beforeResizeOwnerPostEffect = func(got resizeOwnerPostEffect) {
427- if got != effect {
428- return
429- }
430- once.Do(func() { close(entered) })
431- <-release
432- }
433- return entered, func() { close(release) }
434-}
435-
436-func TestResizeOwnerMoveImmediatelyBeforeSnapshotDirtyDropsStaleSource(t *testing.T) {
437- d, source, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
438- source.snapEligible.Store(true)
439- tb := source.activeTab()
440- p := tb.focusedPane()
441- tb.mu.Lock()
442- tb.size = domain.Size{Cols: 100, Rows: 20}
443- tb.bumpLayoutGenerationLocked()
444- tb.mu.Unlock()
445- destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
446- entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostSnapshotDirty)
447-
448- result := make(chan bool, 1)
449- go func() { result <- d.applyTabLayout(source, tb) }()
450- awaitTestCompletion(t, entered, "resize did not pause before snapshot dirtiness")
451- publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
452- release()
453-
454- require.False(t, awaitTestValue(t, result, "stale source layout did not return"))
455- require.False(t, source.snapDirty.Load(), "stale source dirtied its snapshot after the move won the fence")
456-}
457-
458-func TestRetryOwnerMoveImmediatelyBeforeScheduleContinuesAtDestination(t *testing.T) {
459- resizeErr := errors.New("scripted resize failure")
460- pty := &transactionalResizePTY{errs: []error{resizeErr, resizeErr}}
461- d, source, _, _ := newManualSessionWithPTYs(t, pty)
462- clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)}
463- d.clock = clock
464- tb := source.activeTab()
465- p := tb.focusedPane()
466- tb.mu.Lock()
467- tb.size = domain.Size{Cols: 100, Rows: 20}
468- tb.bumpLayoutGenerationLocked()
469- tb.mu.Unlock()
470- destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
471- entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRetrySchedule)
472-
473- result := make(chan bool, 1)
474- go func() { result <- d.applyTabLayout(source, tb) }()
475- awaitTestCompletion(t, entered, "resize did not pause before retry scheduling")
476- publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
477- release()
478-
479- require.False(t, awaitTestValue(t, result, "stale source retry publication did not return"))
480- tb.layoutRetryMu.Lock()
481- require.False(t, tb.layoutRetryRunning, "stale source published a retry worker")
482- tb.layoutRetryMu.Unlock()
483- for {
484- select {
485- case timer := <-clock.timers:
486- require.NotEqual(t, minOutputRenderDeadline, timer.delay, "stale source armed a retry timer")
487- default:
488- goto timersDrained
489- }
490- }
491-
492-timersDrained:
493- d.beforeResizeOwnerPostEffect = nil
494- require.True(t, d.applyTabLayout(destination, tb), "destination must recreate retry from pane metadata")
495- for {
496- timer := awaitTestValue(t, clock.timers, "destination did not continue the moved retry")
497- if timer.delay == minOutputRenderDeadline {
498- break
499- }
500- }
501-}
502-
503-type resizeFenceCheckingClock struct {
504- locks []*sync.Mutex
505- calls atomic.Int32
506- locksFree atomic.Bool
507-}
508-
509-func (c *resizeFenceCheckingClock) check() {
510- c.calls.Add(1)
511- locked := make([]*sync.Mutex, 0, len(c.locks))
512- for _, lock := range c.locks {
513- if !lock.TryLock() {
514- for i := len(locked) - 1; i >= 0; i-- {
515- locked[i].Unlock()
516- }
517- c.locksFree.Store(false)
518- return
519- }
520- locked = append(locked, lock)
521- }
522- for i := len(locked) - 1; i >= 0; i-- {
523- locked[i].Unlock()
524- }
525-}
526-
527-func (c *resizeFenceCheckingClock) Now() time.Time {
528- c.check()
529- return time.Time{}
530-}
531-
532-func (c *resizeFenceCheckingClock) NewTimer(time.Duration) ports.Timer {
533- c.check()
534- return stubTimer{}
535-}
536-
537-type blockingResizeCommitObserver struct {
538- entered chan struct{}
539- release chan struct{}
540- once sync.Once
541-}
542-
543-func (o *blockingResizeCommitObserver) ObserveRuntime(mark ports.RuntimeMark) {
544- if mark.Kind != ports.RuntimeResizeCommitted {
545- return
546- }
547- o.once.Do(func() { close(o.entered) })
548- <-o.release
549-}
550-
551-func TestResizeCommitObserverBlocksWithoutHoldingOwnerOrAttachmentFences(t *testing.T) {
552- d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
553- observer := &blockingResizeCommitObserver{entered: make(chan struct{}), release: make(chan struct{})}
554- d.runtimeObserver = observer
555- rc := d.attachCoordinator(sess, nil, ac, true)
556- lease := rc.attachmentLease(ac)
557- epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
558- tb := sess.activeTab()
559- p := tb.focusedPane()
560-
561- result := make(chan bool, 1)
562- go func() { result <- d.runResizeTransaction(sess, ac, lease, epoch) }()
563- awaitTestCompletion(t, observer.entered, "resize commit observer did not block")
564- for _, lock := range []struct {
565- name string
566- mu *sync.Mutex
567- }{
568- {name: "attachment send", mu: &ac.sendMu},
569- {name: "session resize", mu: &sess.layoutApplyMu},
570- {name: "tab resize", mu: &tb.layoutApplyMu},
571- {name: "pane resize", mu: &p.resizeMu},
572- } {
573- require.True(t, lock.mu.TryLock(), "%s fence remained held across observer callback", lock.name)
574- lock.mu.Unlock()
575- }
576- close(observer.release)
577- require.True(t, awaitTestValue(t, result, "resize did not finish after observer release"))
578-}
579-
580-func TestResizeOwnerPostEffectsReleaseFencesBeforeTimerAndRenderCallbacks(t *testing.T) {
581- d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
582- tb := sess.activeTab()
583- p := tb.focusedPane()
584- clock := &resizeFenceCheckingClock{locks: []*sync.Mutex{&sess.layoutApplyMu, &tb.layoutApplyMu, &p.resizeMu}}
585- clock.locksFree.Store(true)
586- d.clock = clock
587- rc := d.attachCoordinator(sess, nil, ac, true)
588- callbackLocksFree := atomic.Bool{}
589- callbackLocksFree.Store(true)
590- rc.opts.onInvalidate = func(renderInvalidation) {
591- clock.check()
592- callbackLocksFree.Store(clock.locksFree.Load())
593- }
594- lease := rc.attachmentLease(ac)
595- epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
596-
597- require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
598- require.Positive(t, clock.calls.Load(), "clock boundary was not exercised")
599- require.True(t, clock.locksFree.Load(), "timer or clock work ran under a resize ownership fence")
600- require.True(t, callbackLocksFree.Load(), "render callback ran under a resize ownership fence")
601-}
602-
603-func TestResizeOwnerMoveBeforeClientGeometryAndCommitTelemetryDropsStaleSource(t *testing.T) {
604- first := &transactionalResizePTY{}
605- second := &transactionalResizePTY{}
606- d, source, ac, _ := newManualSessionWithPTYs(t, first, second)
607- observer := &daemonRuntimeObserver{}
608- d.runtimeObserver = observer
609- rc := d.attachCoordinator(source, nil, ac, true)
610- lease := rc.attachmentLease(ac)
611- target := domain.Size{Cols: 100, Rows: 30}
612- epoch := rc.recordResizeRequestForLease(target, ac, lease)
613- require.NotZero(t, epoch)
614- movedTab := source.tabs[1]
615- movedPane := movedTab.focusedPane()
616- destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{movedTab}}
617-
618- entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostCommitPublication)
619- result := make(chan bool, 1)
620- go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }()
621- select {
622- case <-entered:
623- publishResizeOwnerMoveUnderFence(t, source, destination, movedTab, movedPane)
624- release()
625- case accepted := <-result:
626- ac.sendMu.Lock()
627- publishedSize := ac.size
628- ac.sendMu.Unlock()
629- t.Fatalf("resize completed before geometry/telemetry owner fence: accepted=%v size=%+v marks=%+v", accepted, publishedSize, observer.marks)
630- case <-time.After(time.Second):
631- t.Fatal("resize neither reached geometry/telemetry publication nor completed")
632- }
633-
634- require.False(t, awaitTestValue(t, result, "stale source geometry publication did not return"))
635- ac.sendMu.Lock()
636- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "stale source published attachment geometry after ownership commit")
637- ac.sendMu.Unlock()
638- for _, mark := range observer.marks {
639- require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "stale source published resize-commit telemetry after ownership commit")
640- }
641-
642- // The destination's finalizer is the only accepted geometry/telemetry owner.
643- d.beforeResizeOwnerPostEffect = nil
644- destinationSize := domain.Size{Cols: 90, Rows: 28}
645- require.True(t, d.requestTransactionalResizeForLease(destination, nil, nil, destinationSize, true))
646- movedPane.mu.Lock()
647- require.Equal(t, domain.Size{Cols: 90, Rows: 26}, domain.Size{Cols: movedPane.screen.Frame.Width, Rows: movedPane.screen.Frame.Height})
648- movedPane.mu.Unlock()
649- commits := 0
650- for _, mark := range observer.marks {
651- if mark.Kind == ports.RuntimeResizeCommitted {
652- commits++
653- }
654- }
655- require.Equal(t, 1, commits, "only the destination finalizer may publish resize-commit telemetry")
656-}
657-
658-func TestSameOwnerResizePublishesClientGeometryAndCommitTelemetryOnce(t *testing.T) {
659- d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{}, &transactionalResizePTY{})
660- observer := &daemonRuntimeObserver{}
661- d.runtimeObserver = observer
662- rc := d.attachCoordinator(sess, nil, ac, true)
663- lease := rc.attachmentLease(ac)
664- target := domain.Size{Cols: 100, Rows: 30}
665- epoch := rc.recordResizeRequestForLease(target, ac, lease)
666-
667- require.True(t, d.runResizeTransaction(sess, ac, lease, epoch))
668- ac.sendMu.Lock()
669- require.Equal(t, target, ac.size)
670- ac.sendMu.Unlock()
671- commits := 0
672- for _, mark := range observer.marks {
673- if mark.Kind == ports.RuntimeResizeCommitted {
674- commits++
675- }
676- }
677- require.Equal(t, 1, commits)
678-}
679-
680-func TestResizeOwnerMoveImmediatelyBeforeRenderInvalidationDropsStaleSource(t *testing.T) {
681- d, source, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
682- rc := d.attachCoordinator(source, nil, ac, true)
683- lease := rc.attachmentLease(ac)
684- epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
685- var invalidations atomic.Int32
686- rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) }
687- tb := source.activeTab()
688- p := tb.focusedPane()
689- destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
690- entered, release := pauseResizeOwnerPostEffect(d, resizeOwnerPostRenderInvalidation)
691-
692- result := make(chan bool, 1)
693- go func() { result <- d.runResizeTransaction(source, ac, lease, epoch) }()
694- awaitTestCompletion(t, entered, "resize did not pause before render invalidation")
695- publishResizeOwnerMoveUnderFence(t, source, destination, tb, p)
696- release()
697-
698- require.False(t, awaitTestValue(t, result, "stale source invalidation did not return"))
699- require.Zero(t, invalidations.Load(), "stale source invalidated its renderer after the move won the fence")
700-}
701-
702-func TestRetryOwnerAcceptedTabStopsWhenFailedPaneMoves(t *testing.T) {
703- resizeErr := errors.New("scripted resize failure")
704- pty := &transactionalResizePTY{errs: []error{resizeErr}}
705- d, source, _, _ := newManualSessionWithPTYs(t, pty)
706- clock := &layoutRetryClock{timers: make(chan *layoutRetryTimer, 8)}
707- d.clock = clock
708- tb := source.activeTab()
709- p := tb.focusedPane()
710- tb.mu.Lock()
711- tb.size = domain.Size{Cols: 100, Rows: 20}
712- tb.bumpLayoutGenerationLocked()
713- tb.mu.Unlock()
714-
715- require.True(t, d.applyTabLayout(source, tb))
716- var timer *layoutRetryTimer
717- for timer == nil {
718- candidate := awaitTestValue(t, clock.timers, "accepted failure did not schedule its retry")
719- if candidate.delay == minOutputRenderDeadline {
720- timer = candidate
721- }
722- }
723- source.snapshotMu.Lock()
724- sourceGeneration := source.snapshotGeneration
725- source.snapshotMu.Unlock()
726- notices := len(d.notices.history())
727-
728- destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
729- publishPaneOwner(p, destination, tb, 0)
730- timer.fire()
731-
732- require.Eventually(t, func() bool {
733- tb.layoutRetryMu.Lock()
734- defer tb.layoutRetryMu.Unlock()
735- return !tb.layoutRetryRunning
736- }, time.Second, time.Millisecond, "stale source retry did not retire")
737- require.Equal(t, []domain.Size{{Cols: 100, Rows: 20}}, pty.requested(), "stale source retry reached the moved pane PTY")
738- source.snapshotMu.Lock()
739- require.Equal(t, sourceGeneration, source.snapshotGeneration, "stale source retry dirtied its former session")
740- source.snapshotMu.Unlock()
741- require.Len(t, d.notices.history(), notices, "stale source retry published a notice")
742-}
743-
744-func TestTransactionalResizeOwnerMoveAfterExternalApplyPublishesOnlyDestinationGeometry(t *testing.T) {
745- entered := make(chan struct{})
746- release := make(chan struct{})
747- pty := &transactionalResizePTY{}
748- pty.onResize = func() {
749- if len(pty.requested()) != 1 {
750- return
751- }
752- close(entered)
753- <-release
754- }
755- d, source, ac, _ := newManualSessionWithPTYs(t, pty)
756- observer := &daemonRuntimeObserver{}
757- d.runtimeObserver = observer
758- rc := d.attachCoordinator(source, nil, ac, true)
759- var invalidations atomic.Int32
760- rc.opts.onInvalidate = func(renderInvalidation) { invalidations.Add(1) }
761- lease := rc.attachmentLease(ac)
762- epoch := rc.recordResizeRequestForLease(domain.Size{Cols: 100, Rows: 30}, ac, lease)
763- require.NotZero(t, epoch)
764- tb := source.activeTab()
765- p := tb.focusedPane()
766- destination := &session{id: "destination", name: "destination", ctx: source.ctx, tabs: []*tab{tb}}
767-
768- result := make(chan bool, 1)
769- go func() {
770- result <- d.runResizeTransaction(source, ac, lease, epoch)
771- }()
772- awaitTestCompletion(t, entered, "source resize did not reach the external PTY")
773-
774- source.mu.Lock()
775- source.tabs = nil
776- source.active = -1
777- source.mu.Unlock()
778- publishPaneOwner(p, destination, tb, 0)
779- close(release)
780-
781- require.False(t, awaitTestValue(t, result, "source resize did not finish after the owner move"))
782- require.False(t, source.snapDirty.Load(), "retired source owner dirtied its snapshot")
783- require.Zero(t, invalidations.Load(), "retired source owner invalidated its renderer")
784- require.Empty(t, d.notices.history(), "retired source owner published a resize notice")
785- for _, mark := range observer.marks {
786- require.NotEqual(t, ports.RuntimeResizeCommitted, mark.Kind, "retired source owner published resize telemetry")
787- }
788- ac.sendMu.Lock()
789- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, ac.size, "retired source owner published client geometry")
790- ac.sendMu.Unlock()
791- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, tb.size, "retired source owner published the moved tab size")
792- p.mu.Lock()
793- require.Equal(t, domain.Rect{Width: 80, Height: 23}, p.rect, "retired source owner published pane geometry")
794- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height}, "retired source owner published VT geometry")
795- require.True(t, p.resizeRetry, "the destination owner needs metadata for the externally applied stale size")
796- p.mu.Unlock()
797-
798- require.True(t, d.applyTabLayout(destination, tb))
799- require.Equal(t, []domain.Size{{Cols: 100, Rows: 28}, {Cols: 80, Rows: 23}}, pty.requested(), "destination layout must restore its owned geometry")
800- p.mu.Lock()
801- require.False(t, p.resizeRetry)
802- require.Equal(t, domain.Size{Cols: 80, Rows: 23}, domain.Size{Cols: p.screen.Frame.Width, Rows: p.screen.Frame.Height})
803- p.mu.Unlock()
804-}
805-
806 func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {410 func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
807 first, second := &transactionalResizePTY{}, &transactionalResizePTY{}411 first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
808 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)412 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
@@ -941,6 +545,193 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
941 source.snapshotMu.Unlock()545 source.snapshotMu.Unlock()
942 }546 }
943 547
548+func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
549+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
550+ for _, tc := range []struct {
551+ name string
552+ from domain.Size
553+ to domain.Size
554+ wantMode ui.PresentationMode
555+ }{
556+ {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
557+ {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
558+ } {
559+ t.Run(tc.name, func(t *testing.T) {
560+ d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
561+ d.ApplyConfig(domain.Config{Floating: cfg})
562+ require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
563+
564+ tb := sess.activeTab()
565+ fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
566+ toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
567+ entered, release := make(chan struct{}), make(chan struct{})
568+ var once sync.Once
569+ popupPTY := &transactionalResizePTY{onResize: func() {
570+ once.Do(func() { close(entered) })
571+ <-release
572+ }}
573+ popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
574+ popup.rect = fromGeometry.Inner
575+ popup.popupGeometry = fromGeometry
576+ installTestFloating(tb, popup, true)
577+
578+ done := make(chan bool, 1)
579+ go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }()
580+ <-entered
581+ popup.mu.Lock()
582+ require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
583+ require.Equal(t, fromGeometry.Inner, popup.rect)
584+ require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
585+ popup.mu.Unlock()
586+ close(release)
587+ require.True(t, <-done)
588+
589+ require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
590+ popup.mu.Lock()
591+ require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
592+ require.Equal(t, toGeometry, popup.popupGeometry)
593+ require.Equal(t, toGeometry.Inner, popup.rect)
594+ require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
595+ popup.mu.Unlock()
596+ })
597+ }
598+}
599+
600+func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
601+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
602+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
603+ requestedSize := domain.Size{Cols: 79, Rows: 40}
604+ requested := calculateContentFloatingGeometry(requestedSize, cfg)
605+ require.Equal(t, ui.PresentationFloating, committed.Mode)
606+ require.Equal(t, ui.PresentationDrawer, requested.Mode)
607+ resizeErr := errors.New("floating resize failed")
608+ pty := &transactionalResizePTY{errs: []error{resizeErr}}
609+ popup := newPane("floating", pty, rectSize(committed.Inner))
610+ popup.rect = committed.Inner
611+ popup.popupGeometry = committed
612+ tb := newTab(nil, requestedSize)
613+ installTestFloating(tb, popup, true)
614+ d := newTestDaemon(t, nil, stubClock{})
615+ d.ApplyConfig(domain.Config{Floating: cfg})
616+ var tabUnlocked, paneUnlocked bool
617+ pty.onResize = func() {
618+ tabUnlocked = tb.mu.TryLock()
619+ if tabUnlocked {
620+ tb.mu.Unlock()
621+ }
622+ paneUnlocked = popup.mu.TryLock()
623+ if paneUnlocked {
624+ popup.mu.Unlock()
625+ }
626+ }
627+
628+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
629+
630+ require.True(t, ok)
631+ require.Len(t, failed, 1)
632+ require.ErrorIs(t, failed[0].err, resizeErr)
633+ require.Equal(t, []domain.Size{rectSize(requested.Inner)}, pty.requested())
634+ require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
635+ require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
636+ popup.mu.Lock()
637+ require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle")
638+ require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry")
639+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
640+ require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable")
641+ require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate")
642+ popup.mu.Unlock()
643+ tb.mu.Lock()
644+ _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
645+ tb.mu.Unlock()
646+ require.True(t, visible)
647+ require.Equal(t, committed, inputGeometry, "input mapping must retain committed geometry")
648+}
649+
650+func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *testing.T) {
651+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
652+ size := domain.Size{Cols: 79, Rows: 20}
653+ requested := calculateContentFloatingGeometry(size, cfg)
654+ require.Equal(t, ui.PresentationDrawer, requested.Mode)
655+ committed := requested
656+ committed.Mode = ui.PresentationFloating
657+ committed.Bounds.X++
658+ committed.Inner.X++
659+ pty := &transactionalResizePTY{}
660+ popup := newPane("floating", pty, rectSize(committed.Inner))
661+ popup.rect = committed.Inner
662+ popup.popupGeometry = committed
663+ tb := newTab(nil, size)
664+ installTestFloating(tb, popup, true)
665+ d := newTestDaemon(t, nil, stubClock{})
666+ d.ApplyConfig(domain.Config{Floating: cfg})
667+
668+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
669+
670+ require.True(t, ok)
671+ require.Empty(t, failed)
672+ require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY")
673+ popup.mu.Lock()
674+ require.Equal(t, requested.Inner, popup.rect)
675+ require.Equal(t, requested, popup.popupGeometry)
676+ require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
677+ require.False(t, popup.resizeRetry)
678+ popup.mu.Unlock()
679+}
680+
681+func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
682+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
683+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
684+ pty := &transactionalResizePTY{}
685+ popup := newPane("floating", pty, rectSize(committed.Inner))
686+ popup.rect = committed.Inner
687+ popup.popupGeometry = committed
688+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
689+ installTestFloating(tb, popup, true)
690+ d := newTestDaemon(t, nil, stubClock{})
691+ d.ApplyConfig(domain.Config{Floating: cfg})
692+ calls := 0
693+
694+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool {
695+ calls++
696+ return calls == 1
697+ })
698+
699+ require.True(t, ok, "a publication accepted while current must not later report stale")
700+ require.Equal(t, 1, calls, "current must not be rechecked after publication")
701+ require.Empty(t, failed)
702+ require.Empty(t, pty.requested())
703+ requested := calculateContentFloatingGeometry(tb.size, cfg)
704+ popup.mu.Lock()
705+ require.Equal(t, requested, popup.popupGeometry)
706+ require.Equal(t, committed.Inner, popup.rect)
707+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
708+ popup.mu.Unlock()
709+}
710+
711+func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
712+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
713+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
714+ pty := &transactionalResizePTY{}
715+ popup := newPane("floating", pty, rectSize(committed.Inner))
716+ popup.rect = committed.Inner
717+ popup.popupGeometry = committed
718+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
719+ installTestFloating(tb, popup, true)
720+ d := newTestDaemon(t, nil, stubClock{})
721+ d.ApplyConfig(domain.Config{Floating: cfg})
722+
723+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false })
724+
725+ require.False(t, ok)
726+ require.Empty(t, failed)
727+ require.Empty(t, pty.requested())
728+ popup.mu.Lock()
729+ require.Equal(t, committed, popup.popupGeometry)
730+ require.Equal(t, committed.Inner, popup.rect)
731+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
732+ popup.mu.Unlock()
733+}
734+
944 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {735 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
945 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}736 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
946 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})737 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
A internal/usecase/keys/bindings_test.go +51 -0
@@ -0,0 +1,51 @@
1+package keys
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestConsumeOrExpelPaneActionsBindWithoutDefaults(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ action Action
13+ key rune
14+ }{
15+ {name: "consume-or-expel-pane-left", action: ActionConsumeOrExpelPaneLeft, key: 'H'},
16+ {name: "consume-or-expel-pane-right", action: ActionConsumeOrExpelPaneRight, key: 'L'},
17+ }
18+
19+ for _, tt := range tests {
20+ t.Run(tt.name, func(t *testing.T) {
21+ bindings, warnings := BuildBindings(map[string]string{
22+ tt.name: "alt+" + string(tt.key),
23+ })
24+
25+ require.Empty(t, warnings)
26+ assertAltRuneAction(t, bindings, tt.key, tt.action)
27+ })
28+ }
29+}
30+
31+func TestConsumeOrExpelPaneActionsAreAbsentFromAllDefaultBindings(t *testing.T) {
32+ tests := []struct {
33+ name string
34+ action Action
35+ }{
36+ {name: "left", action: ActionConsumeOrExpelPaneLeft},
37+ {name: "right", action: ActionConsumeOrExpelPaneRight},
38+ }
39+ defaults := DefaultBindings()
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ for _, bound := range defaults.altRunes {
44+ require.NotEqual(t, tt.action, bound)
45+ }
46+ for _, bound := range defaults.altArrows {
47+ require.NotEqual(t, tt.action, bound)
48+ }
49+ })
50+ }
51+}
M internal/usecase/keys/keyspec.go +23 -21
@@ -49,27 +49,29 @@ func ParseKeySpec(spec string) (KeySpec, error) {
49 }49 }
50 50
51 var actionNames = map[Action]string{51 var actionNames = map[Action]string{
52- ActionOpenPalette: "open-palette",52+ ActionOpenPalette: "open-palette",
53- ActionToggleFloatingPane: "toggle-floating-pane",53+ ActionToggleFloatingPane: "toggle-floating-pane",
54- ActionJumpAttention: "jump-attention",54+ ActionJumpAttention: "jump-attention",
55- ActionFocusPaneLeft: "focus-pane-left",55+ ActionFocusPaneLeft: "focus-pane-left",
56- ActionFocusPaneRight: "focus-pane-right",56+ ActionFocusPaneRight: "focus-pane-right",
57- ActionFocusPaneUp: "focus-pane-up",57+ ActionFocusPaneUp: "focus-pane-up",
58- ActionFocusPaneDown: "focus-pane-down",58+ ActionFocusPaneDown: "focus-pane-down",
59- ActionSwitchTab1: "switch-tab-1",59+ ActionSwitchTab1: "switch-tab-1",
60- ActionSwitchTab2: "switch-tab-2",60+ ActionSwitchTab2: "switch-tab-2",
61- ActionSwitchTab3: "switch-tab-3",61+ ActionSwitchTab3: "switch-tab-3",
62- ActionSwitchTab4: "switch-tab-4",62+ ActionSwitchTab4: "switch-tab-4",
63- ActionSwitchTab5: "switch-tab-5",63+ ActionSwitchTab5: "switch-tab-5",
64- ActionSwitchTab6: "switch-tab-6",64+ ActionSwitchTab6: "switch-tab-6",
65- ActionSwitchTab7: "switch-tab-7",65+ ActionSwitchTab7: "switch-tab-7",
66- ActionSwitchTab8: "switch-tab-8",66+ ActionSwitchTab8: "switch-tab-8",
67- ActionSwitchTab9: "switch-tab-9",67+ ActionSwitchTab9: "switch-tab-9",
68- ActionGrowPaneWidth: "grow-pane-width",68+ ActionGrowPaneWidth: "grow-pane-width",
69- ActionShrinkPaneWidth: "shrink-pane-width",69+ ActionShrinkPaneWidth: "shrink-pane-width",
70- ActionGrowPaneHeight: "grow-pane-height",70+ ActionGrowPaneHeight: "grow-pane-height",
71- ActionShrinkPaneHeight: "shrink-pane-height",71+ ActionShrinkPaneHeight: "shrink-pane-height",
72- ActionEqualizePanes: "equalize-panes",72+ ActionEqualizePanes: "equalize-panes",
73+ ActionConsumeOrExpelPaneLeft: "consume-or-expel-pane-left",
74+ ActionConsumeOrExpelPaneRight: "consume-or-expel-pane-right",
73 }75 }
74 76
75 var actionsByName = func() map[string]Action {77 var actionsByName = func() map[string]Action {
M internal/usecase/keys/keyspec_test.go +2 -0
@@ -84,6 +84,8 @@ func TestActionNamesAreCanonical(t *testing.T) {
84 {ActionGrowPaneHeight, "grow-pane-height"},84 {ActionGrowPaneHeight, "grow-pane-height"},
85 {ActionShrinkPaneHeight, "shrink-pane-height"},85 {ActionShrinkPaneHeight, "shrink-pane-height"},
86 {ActionEqualizePanes, "equalize-panes"},86 {ActionEqualizePanes, "equalize-panes"},
87+ {ActionConsumeOrExpelPaneLeft, "consume-or-expel-pane-left"},
88+ {ActionConsumeOrExpelPaneRight, "consume-or-expel-pane-right"},
87 }89 }
88 90
89 for _, tc := range cases {91 for _, tc := range cases {
M internal/usecase/keys/router.go +2 -0
@@ -43,6 +43,8 @@ const (
43 ActionGrowPaneHeight43 ActionGrowPaneHeight
44 ActionShrinkPaneHeight44 ActionShrinkPaneHeight
45 ActionEqualizePanes45 ActionEqualizePanes
46+ ActionConsumeOrExpelPaneLeft
47+ ActionConsumeOrExpelPaneRight
46 )48 )
47 49
48 // Handler receives router outputs. Forward is called only for bytes that should50 // Handler receives router outputs. Forward is called only for bytes that should
A internal/usecase/layout/consume_expel.go +260 -0
@@ -0,0 +1,260 @@
1+package layout
2+
3+import (
4+ "errors"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+var (
10+ ErrUnsupportedColumnLayout = errors.New("unsupported column layout")
11+ ErrInvalidDirection = errors.New("invalid direction")
12+)
13+
14+// ConsumeOrExpelPane moves target horizontally between canonical root columns.
15+// A singleton column is consumed into its immediate neighbor; a member of a
16+// vertical or stack column is expelled into a new singleton column.
17+func (t *Tree) ConsumeOrExpelPane(target PaneID, dir Direction, area domain.Rect) (bool, error) {
18+ if dir != Left && dir != Right {
19+ return false, ErrInvalidDirection
20+ }
21+ if !validCanonicalColumnTree(t) {
22+ return false, ErrUnsupportedColumnLayout
23+ }
24+
25+ columns := canonicalColumns(t.Root)
26+ columnIndex, _, found := findColumnMember(columns, target)
27+ if !found {
28+ return false, ErrNotFound
29+ }
30+
31+ if columns[columnIndex].Kind == Leaf {
32+ destinationIndex := columnIndex - 1
33+ if dir == Right {
34+ destinationIndex = columnIndex + 1
35+ }
36+ if destinationIndex < 0 || destinationIndex >= len(columns) {
37+ return false, nil
38+ }
39+ }
40+
41+ candidate := t.clone()
42+ if _, solved := Solve(candidate.Root, area); !solved {
43+ return false, ErrTooSmall
44+ }
45+
46+ columns = canonicalColumns(candidate.Root)
47+ columnIndex, memberIndex, _ := findColumnMember(columns, target)
48+ if candidate.Root.Kind == Split && candidate.Root.Dir == Horizontal {
49+ normalizeChildWeightsFromArea(candidate.Root, area)
50+ }
51+
52+ if columns[columnIndex].Kind == Leaf {
53+ consumeSingletonColumn(candidate, columnIndex, dir, area)
54+ } else {
55+ expelColumnMember(candidate, columnIndex, memberIndex, dir)
56+ }
57+ candidate.Focus = target
58+
59+ if _, solved := Solve(candidate.Root, area); !solved {
60+ return false, ErrTooSmall
61+ }
62+ *t = *candidate
63+ return true, nil
64+}
65+
66+func validCanonicalColumnTree(t *Tree) bool {
67+ if t == nil || t.Root == nil {
68+ return false
69+ }
70+
71+ columns := canonicalColumns(t.Root)
72+ if len(columns) == 0 {
73+ return false
74+ }
75+ if t.Root.Kind == Split && t.Root.Dir == Horizontal && len(columns) < 2 {
76+ return false
77+ }
78+
79+ ids := make(map[PaneID]struct{})
80+ for _, column := range columns {
81+ if !validCanonicalColumn(column, ids) {
82+ return false
83+ }
84+ }
85+ if t.Focus == "" {
86+ return false
87+ }
88+ _, focused := ids[t.Focus]
89+ return focused
90+}
91+
92+func canonicalColumns(root *Node) []*Node {
93+ if root == nil {
94+ return nil
95+ }
96+ if root.Kind == Split && root.Dir == Horizontal {
97+ return root.Children
98+ }
99+ return []*Node{root}
100+}
101+
102+func validCanonicalColumn(column *Node, ids map[PaneID]struct{}) bool {
103+ if column == nil {
104+ return false
105+ }
106+
107+ switch column.Kind {
108+ case Leaf:
109+ if len(column.Children) != 0 {
110+ return false
111+ }
112+ return addUniqueLeafID(ids, column.Leaf)
113+ case Split:
114+ if column.Dir != Vertical || len(column.Children) < 2 {
115+ return false
116+ }
117+ case Stack:
118+ if len(column.Children) < 2 {
119+ return false
120+ }
121+ default:
122+ return false
123+ }
124+
125+ expanded := column.Kind != Stack
126+ for _, member := range column.Children {
127+ if member == nil || member.Kind != Leaf || len(member.Children) != 0 || !addUniqueLeafID(ids, member.Leaf) {
128+ return false
129+ }
130+ if column.Kind == Stack && member.Leaf == column.Expanded {
131+ expanded = true
132+ }
133+ }
134+ return expanded
135+}
136+
137+func addUniqueLeafID(ids map[PaneID]struct{}, id PaneID) bool {
138+ if id == "" {
139+ return false
140+ }
141+ if _, exists := ids[id]; exists {
142+ return false
143+ }
144+ ids[id] = struct{}{}
145+ return true
146+}
147+
148+func findColumnMember(columns []*Node, target PaneID) (int, int, bool) {
149+ for columnIndex, column := range columns {
150+ if column.Kind == Leaf {
151+ if column.Leaf == target {
152+ return columnIndex, -1, true
153+ }
154+ continue
155+ }
156+ for memberIndex, member := range column.Children {
157+ if member.Leaf == target {
158+ return columnIndex, memberIndex, true
159+ }
160+ }
161+ }
162+ return 0, 0, false
163+}
164+
165+func consumeSingletonColumn(t *Tree, sourceIndex int, dir Direction, area domain.Rect) {
166+ root := t.Root
167+ destinationIndex := sourceIndex - 1
168+ if dir == Right {
169+ destinationIndex = sourceIndex + 1
170+ }
171+
172+ columnAreas, _ := splitChildRects(root, area)
173+ moved := root.Children[sourceIndex]
174+ moved.Weight = 0
175+ destination := root.Children[destinationIndex]
176+
177+ switch destination.Kind {
178+ case Leaf:
179+ weight := destination.Weight
180+ destination.Weight = 0
181+ destination = &Node{
182+ Kind: Split,
183+ Dir: Vertical,
184+ Children: []*Node{destination, moved},
185+ Weight: weight,
186+ }
187+ case Split:
188+ normalizeChildWeightsFromArea(destination, columnAreas[destinationIndex])
189+ destination.Children = append(destination.Children, moved)
190+ case Stack:
191+ destination.Children = append(destination.Children, moved)
192+ destination.Expanded = moved.Leaf
193+ clearChildWeights(destination)
194+ }
195+ root.Children[destinationIndex] = destination
196+ root.Children = removeNodeAt(root.Children, sourceIndex)
197+
198+ if len(root.Children) == 1 {
199+ promoted := root.Children[0]
200+ promoted.Weight = root.Weight
201+ t.Root = promoted
202+ }
203+}
204+
205+func expelColumnMember(t *Tree, columnIndex, memberIndex int, dir Direction) {
206+ root := t.Root
207+ rootIsHorizontal := root.Kind == Split && root.Dir == Horizontal
208+ columns := canonicalColumns(root)
209+ source := columns[columnIndex]
210+ wrapperWeight := source.Weight
211+ if !rootIsHorizontal {
212+ wrapperWeight = root.Weight
213+ }
214+
215+ moved := source.Children[memberIndex]
216+ moved.Weight = 0
217+ source.Children = removeNodeAt(source.Children, memberIndex)
218+ if source.Kind == Stack {
219+ if source.Expanded == moved.Leaf {
220+ source.Expanded = source.Children[0].Leaf
221+ }
222+ clearChildWeights(source)
223+ }
224+
225+ remaining := source
226+ if len(source.Children) == 1 {
227+ remaining = source.Children[0]
228+ remaining.Weight = source.Weight
229+ }
230+
231+ if !rootIsHorizontal {
232+ remaining.Weight = 0
233+ children := []*Node{remaining, moved}
234+ if dir == Left {
235+ children[0], children[1] = children[1], children[0]
236+ }
237+ t.Root = &Node{Kind: Split, Dir: Horizontal, Children: children, Weight: wrapperWeight}
238+ return
239+ }
240+
241+ root.Children[columnIndex] = remaining
242+ insertionIndex := columnIndex
243+ if dir == Right {
244+ insertionIndex++
245+ }
246+ root.Children = insertNodeAt(root.Children, insertionIndex, moved)
247+}
248+
249+func removeNodeAt(nodes []*Node, index int) []*Node {
250+ copy(nodes[index:], nodes[index+1:])
251+ nodes[len(nodes)-1] = nil
252+ return nodes[:len(nodes)-1]
253+}
254+
255+func insertNodeAt(nodes []*Node, index int, node *Node) []*Node {
256+ nodes = append(nodes, nil)
257+ copy(nodes[index+1:], nodes[index:])
258+ nodes[index] = node
259+ return nodes
260+}
A internal/usecase/layout/consume_expel_test.go +304 -0
@@ -0,0 +1,304 @@
1+package layout
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestConsumeOrExpelPaneTransitions(t *testing.T) {
11+ t.Parallel()
12+
13+ area := domain.Rect{Width: 101, Height: 12}
14+ tests := []struct {
15+ name string
16+ tree *Tree
17+ target PaneID
18+ dir Direction
19+ want *Tree
20+ wantChange bool
21+ }{
22+ {
23+ name: "consume singleton left into leaf",
24+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
25+ target: "b",
26+ dir: Left,
27+ want: &Tree{Root: vertical("a", "b"), Focus: "b"},
28+ wantChange: true,
29+ },
30+ {
31+ name: "consume singleton right into leaf",
32+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
33+ target: "a",
34+ dir: Right,
35+ want: &Tree{Root: vertical("b", "a"), Focus: "a"},
36+ wantChange: true,
37+ },
38+ {
39+ name: "consume singleton left into vertical column",
40+ tree: &Tree{Root: horizontal(vertical("a", "b"), NewLeaf("c")), Focus: "c"},
41+ target: "c",
42+ dir: Left,
43+ want: &Tree{Root: verticalNodes(
44+ consumeExpelWeightedLeaf("a", 6),
45+ consumeExpelWeightedLeaf("b", 5),
46+ NewLeaf("c"),
47+ ), Focus: "c"},
48+ wantChange: true,
49+ },
50+ {
51+ name: "consume singleton right into vertical column",
52+ tree: &Tree{Root: horizontal(NewLeaf("a"), vertical("b", "c")), Focus: "a"},
53+ target: "a",
54+ dir: Right,
55+ want: &Tree{Root: verticalNodes(
56+ consumeExpelWeightedLeaf("b", 6),
57+ consumeExpelWeightedLeaf("c", 5),
58+ NewLeaf("a"),
59+ ), Focus: "a"},
60+ wantChange: true,
61+ },
62+ {
63+ name: "consume singleton left into stack appends and expands",
64+ tree: &Tree{Root: horizontal(stack("a", "a", "b"), NewLeaf("c")), Focus: "c"},
65+ target: "c",
66+ dir: Left,
67+ want: &Tree{Root: stack("c", "a", "b", "c"), Focus: "c"},
68+ wantChange: true,
69+ },
70+ {
71+ name: "consume singleton right into stack appends and expands",
72+ tree: &Tree{Root: horizontal(NewLeaf("a"), stack("b", "b", "c")), Focus: "a"},
73+ target: "a",
74+ dir: Right,
75+ want: &Tree{Root: stack("a", "b", "c", "a"), Focus: "a"},
76+ wantChange: true,
77+ },
78+ {
79+ name: "expel middle vertical member left from sole root column",
80+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
81+ target: "b",
82+ dir: Left,
83+ want: &Tree{Root: horizontal(NewLeaf("b"), vertical("a", "c")), Focus: "b"},
84+ wantChange: true,
85+ },
86+ {
87+ name: "expel middle vertical member right from sole root column",
88+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "b"},
89+ target: "b",
90+ dir: Right,
91+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
92+ wantChange: true,
93+ },
94+ {
95+ name: "expel vertical member left beside its source column",
96+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
97+ target: "b",
98+ dir: Left,
99+ want: &Tree{Root: horizontal(
100+ NewLeaf("b"),
101+ consumeExpelWeightedNode(vertical("a", "c"), 50),
102+ consumeExpelWeightedLeaf("d", 50),
103+ ), Focus: "b"},
104+ wantChange: true,
105+ },
106+ {
107+ name: "expel vertical member right beside its source column",
108+ tree: &Tree{Root: horizontal(vertical("a", "b", "c"), NewLeaf("d")), Focus: "b"},
109+ target: "b",
110+ dir: Right,
111+ want: &Tree{Root: horizontal(
112+ consumeExpelWeightedNode(vertical("a", "c"), 50),
113+ NewLeaf("b"),
114+ consumeExpelWeightedLeaf("d", 50),
115+ ), Focus: "b"},
116+ wantChange: true,
117+ },
118+ {
119+ name: "expel stack member left from sole root column",
120+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
121+ target: "b",
122+ dir: Left,
123+ want: &Tree{Root: horizontal(NewLeaf("b"), stack("a", "a", "c")), Focus: "b"},
124+ wantChange: true,
125+ },
126+ {
127+ name: "expel stack member right from sole root column",
128+ tree: &Tree{Root: stack("a", "a", "b", "c"), Focus: "b"},
129+ target: "b",
130+ dir: Right,
131+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
132+ wantChange: true,
133+ },
134+ {
135+ name: "expelling expanded stack member repairs source expansion",
136+ tree: &Tree{Root: stack("b", "a", "b", "c"), Focus: "b"},
137+ target: "b",
138+ dir: Right,
139+ want: &Tree{Root: horizontal(stack("a", "a", "c"), NewLeaf("b")), Focus: "b"},
140+ wantChange: true,
141+ },
142+ {
143+ name: "expel collapses two-member vertical column",
144+ tree: &Tree{Root: vertical("a", "b"), Focus: "a"},
145+ target: "a",
146+ dir: Left,
147+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
148+ wantChange: true,
149+ },
150+ {
151+ name: "expel collapses two-member stack column",
152+ tree: &Tree{Root: stack("b", "a", "b"), Focus: "b"},
153+ target: "b",
154+ dir: Right,
155+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
156+ wantChange: true,
157+ },
158+ {
159+ name: "real move focuses explicitly targeted non-focused pane",
160+ tree: &Tree{Root: vertical("a", "b", "c"), Focus: "a"},
161+ target: "b",
162+ dir: Right,
163+ want: &Tree{Root: horizontal(vertical("a", "c"), NewLeaf("b")), Focus: "b"},
164+ wantChange: true,
165+ },
166+ {
167+ name: "left edge singleton is exact no-op and preserves different focus",
168+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
169+ target: "a",
170+ dir: Left,
171+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "b"},
172+ wantChange: false,
173+ },
174+ {
175+ name: "right edge singleton is exact no-op and preserves different focus",
176+ tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
177+ target: "b",
178+ dir: Right,
179+ want: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("b")), Focus: "a"},
180+ wantChange: false,
181+ },
182+ {
183+ name: "sole singleton root is exact no-op",
184+ tree: NewTree("a"),
185+ target: "a",
186+ dir: Left,
187+ want: NewTree("a"),
188+ wantChange: false,
189+ },
190+ }
191+
192+ for _, tt := range tests {
193+ t.Run(tt.name, func(t *testing.T) {
194+ before := tt.tree.Clone()
195+ beforeIDs := LeafIDs(tt.tree.Root)
196+ requireUniquePaneIDs(t, beforeIDs)
197+
198+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, area)
199+ require.NoError(t, err)
200+ require.Equal(t, tt.wantChange, changed)
201+ require.Equal(t, tt.want, tt.tree)
202+ if !tt.wantChange {
203+ require.Equal(t, before, tt.tree, "a successful edge no-op must preserve the complete tree")
204+ }
205+
206+ afterIDs := LeafIDs(tt.tree.Root)
207+ require.ElementsMatch(t, beforeIDs, afterIDs)
208+ require.Len(t, afterIDs, len(beforeIDs), "tree surgery must neither create nor lose pane IDs")
209+ requireUniquePaneIDs(t, afterIDs)
210+ _, solved := Solve(tt.tree.Root, area)
211+ require.True(t, solved, "every successful result must solve")
212+ })
213+ }
214+}
215+
216+func TestConsumeOrExpelPaneRejectsMalformedOrUnsolvableLayoutsAtomically(t *testing.T) {
217+ t.Parallel()
218+
219+ area := domain.Rect{Width: 101, Height: 12}
220+ tests := []struct {
221+ name string
222+ tree *Tree
223+ target PaneID
224+ dir Direction
225+ area domain.Rect
226+ want error
227+ }{
228+ {name: "nil tree", tree: nil, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
229+ {name: "nil root", tree: &Tree{}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
230+ {name: "missing target", tree: NewTree("a"), target: "missing", dir: Left, area: area, want: ErrNotFound},
231+ {name: "empty leaf ID", tree: &Tree{Root: NewLeaf(""), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
232+ {name: "duplicate leaf ID including duplicate target", tree: &Tree{Root: horizontal(NewLeaf("a"), NewLeaf("a")), Focus: "a"}, target: "a", dir: Right, area: area, want: ErrUnsupportedColumnLayout},
233+ {name: "missing focus", tree: &Tree{Root: NewLeaf("a")}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
234+ {name: "dangling focus", tree: &Tree{Root: NewLeaf("a"), Focus: "missing"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
235+ {name: "empty horizontal root", tree: &Tree{Root: &Node{Kind: Split, Dir: Horizontal}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
236+ {name: "empty vertical column", tree: &Tree{Root: &Node{Kind: Split, Dir: Vertical}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
237+ {name: "empty stack", tree: &Tree{Root: &Node{Kind: Stack}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
238+ {name: "nested horizontal root column", tree: &Tree{Root: horizontal(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
239+ {name: "vertical column has non-leaf member", tree: &Tree{Root: verticalNodes(NewLeaf("a"), horizontal(NewLeaf("b"), NewLeaf("c"))), Focus: "b"}, target: "b", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
240+ {name: "stack has non-leaf member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), vertical("b", "c")}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
241+ {name: "stack has nil member", tree: &Tree{Root: &Node{Kind: Stack, Children: []*Node{NewLeaf("a"), nil}, Expanded: "a"}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
242+ {name: "stack expanded member is missing", tree: &Tree{Root: stack("missing", "a", "b"), Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
243+ {name: "unsupported root split direction", tree: &Tree{Root: &Node{Kind: Split, Dir: SplitDir(99), Children: []*Node{NewLeaf("a"), NewLeaf("b")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
244+ {name: "unsupported node kind", tree: &Tree{Root: &Node{Kind: Kind(99), Children: []*Node{NewLeaf("a")}}, Focus: "a"}, target: "a", dir: Left, area: area, want: ErrUnsupportedColumnLayout},
245+ {name: "invalid up direction", tree: NewTree("a"), target: "a", dir: Up, area: area, want: ErrInvalidDirection},
246+ {name: "invalid down direction", tree: NewTree("a"), target: "a", dir: Down, area: area, want: ErrInvalidDirection},
247+ {name: "invalid unknown direction", tree: NewTree("a"), target: "a", dir: Direction(99), area: area, want: ErrInvalidDirection},
248+ {name: "too small after expel", tree: &Tree{Root: vertical("a", "b"), Focus: "a"}, target: "a", dir: Right, area: domain.Rect{Width: 40, Height: 5}, want: ErrTooSmall},
249+ }
250+
251+ for _, tt := range tests {
252+ t.Run(tt.name, func(t *testing.T) {
253+ before := tt.tree.Clone()
254+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
255+ require.False(t, changed)
256+ require.ErrorIs(t, err, tt.want)
257+ require.Equal(t, before, tt.tree, "every refusal must preserve the exact original tree")
258+ })
259+ }
260+}
261+
262+func horizontal(columns ...*Node) *Node {
263+ return &Node{Kind: Split, Dir: Horizontal, Children: columns}
264+}
265+
266+func vertical(ids ...PaneID) *Node {
267+ children := make([]*Node, 0, len(ids))
268+ for _, id := range ids {
269+ children = append(children, NewLeaf(id))
270+ }
271+ return verticalNodes(children...)
272+}
273+
274+func verticalNodes(children ...*Node) *Node {
275+ return &Node{Kind: Split, Dir: Vertical, Children: children}
276+}
277+
278+func stack(expanded PaneID, ids ...PaneID) *Node {
279+ children := make([]*Node, 0, len(ids))
280+ for _, id := range ids {
281+ children = append(children, NewLeaf(id))
282+ }
283+ return &Node{Kind: Stack, Children: children, Expanded: expanded}
284+}
285+
286+func consumeExpelWeightedLeaf(id PaneID, weight float64) *Node {
287+ return &Node{Kind: Leaf, Leaf: id, Weight: weight}
288+}
289+
290+func consumeExpelWeightedNode(node *Node, weight float64) *Node {
291+ node.Weight = weight
292+ return node
293+}
294+
295+func requireUniquePaneIDs(t *testing.T, ids []PaneID) {
296+ t.Helper()
297+ seen := make(map[PaneID]struct{}, len(ids))
298+ for _, id := range ids {
299+ require.NotEmpty(t, id)
300+ _, duplicate := seen[id]
301+ require.False(t, duplicate, "pane ID %q occurs more than once", id)
302+ seen[id] = struct{}{}
303+ }
304+}
M internal/usecase/layout/weights_test.go +166 -0
@@ -323,6 +323,172 @@ func TestWeightSplitStackWrapperTransfersShare(t *testing.T) {
323 }323 }
324 }324 }
325 325
326+func TestWeightConsumeOrExpelPaneSemantics(t *testing.T) {
327+ t.Parallel()
328+
329+ tests := []struct {
330+ name string
331+ tree *Tree
332+ target PaneID
333+ dir Direction
334+ area domain.Rect
335+ check func(*testing.T, *Tree, []Placement, []Placement)
336+ }{
337+ {
338+ name: "horizontal insertion preserves normalized sibling shares and gives expelled column default weight",
339+ tree: &Tree{Root: weightedNode(horizontal(
340+ weightedNode(vertical("a", "b"), 3),
341+ weightedLeaf("c", 1),
342+ ), 11), Focus: "b"},
343+ target: "b",
344+ dir: Right,
345+ area: domain.Rect{Width: 202, Height: 5},
346+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
347+ require.Equal(t, map[PaneID]int{"a": 151, "b": 151, "c": 50}, placementWidths(before))
348+ require.Equal(t, 11.0, tree.Root.Weight)
349+ require.Equal(t, []float64{151, 0, 50}, childWeights(tree.Root))
350+ require.Equal(t, []PaneID{"a", "b", "c"}, LeafIDs(tree.Root))
351+ require.Equal(t, map[PaneID]int{"a": 135, "b": 20, "c": 45}, placementWidths(after))
352+ },
353+ },
354+ {
355+ name: "vertical consume preserves solved member shares and gives moved pane default weight",
356+ tree: &Tree{Root: weightedNode(horizontal(
357+ weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 2),
358+ weightedLeaf("c", 1),
359+ ), 9), Focus: "c"},
360+ target: "c",
361+ dir: Left,
362+ area: domain.Rect{Width: 101, Height: 20},
363+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
364+ require.Equal(t, map[PaneID]int{"a": 14, "b": 5, "c": 20}, placementHeights(before))
365+ require.Equal(t, Vertical, tree.Root.Dir)
366+ require.Equal(t, 9.0, tree.Root.Weight, "root promotion retains the removed horizontal wrapper share")
367+ require.Equal(t, []float64{14, 5, 0}, childWeights(tree.Root))
368+ require.Equal(t, map[PaneID]int{"a": 12, "b": 4, "c": 2}, placementHeights(after))
369+ },
370+ },
371+ {
372+ name: "stack consume clears member weights and preserves normalized outer column share",
373+ tree: &Tree{Root: horizontal(
374+ weightedLeaf("x", 1),
375+ weightedNode(&Node{Kind: Stack, Children: []*Node{weightedLeaf("a", 8), weightedLeaf("b", 4)}, Expanded: "a"}, 3),
376+ weightedLeaf("c", 1),
377+ ), Focus: "c"},
378+ target: "c",
379+ dir: Left,
380+ area: domain.Rect{Width: 103, Height: 4},
381+ check: func(t *testing.T, tree *Tree, before, after []Placement) {
382+ require.Equal(t, map[PaneID]int{"x": 20, "a": 61, "b": 0, "c": 20}, placementWidths(before))
383+ require.Len(t, tree.Root.Children, 2)
384+ stackColumn := tree.Root.Children[1]
385+ require.Equal(t, Stack, stackColumn.Kind)
386+ require.Equal(t, 61.0, stackColumn.Weight)
387+ require.Equal(t, PaneID("c"), stackColumn.Expanded)
388+ require.Equal(t, []float64{0, 0, 0}, childWeights(stackColumn))
389+ require.Equal(t, 77, placementSpanWidth(after, "a"))
390+ require.Equal(t, 77, placementSpanWidth(after, "b"))
391+ require.Equal(t, 77, placementSpanWidth(after, "c"))
392+ },
393+ },
394+ {
395+ name: "stack expel clears remaining weights and repairs expanded member",
396+ tree: &Tree{Root: weightedNode(&Node{Kind: Stack, Children: []*Node{
397+ weightedLeaf("a", 5), weightedLeaf("b", 4), weightedLeaf("c", 3),
398+ }, Expanded: "b"}, 7), Focus: "b"},
399+ target: "b",
400+ dir: Right,
401+ area: domain.Rect{Width: 101, Height: 4},
402+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
403+ require.Equal(t, Horizontal, tree.Root.Dir)
404+ require.Equal(t, 7.0, tree.Root.Weight)
405+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
406+ stackColumn := tree.Root.Children[0]
407+ require.Equal(t, PaneID("a"), stackColumn.Expanded)
408+ require.Equal(t, []float64{0, 0}, childWeights(stackColumn))
409+ require.Equal(t, 50, placementSpanWidth(after, "a"))
410+ require.Equal(t, 50, placementSpanWidth(after, "c"))
411+ require.Equal(t, 50, placementSpanWidth(after, "b"))
412+ },
413+ },
414+ {
415+ name: "column collapse copies normalized container weight to promoted leaf",
416+ tree: &Tree{Root: horizontal(
417+ weightedLeaf("x", 1),
418+ weightedNode(vertical("a", "b"), 3),
419+ weightedLeaf("y", 1),
420+ ), Focus: "a"},
421+ target: "a",
422+ dir: Left,
423+ area: domain.Rect{Width: 103, Height: 5},
424+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
425+ require.Equal(t, []PaneID{"x", "a", "b", "y"}, LeafIDs(tree.Root))
426+ require.Equal(t, Leaf, tree.Root.Children[2].Kind)
427+ require.Equal(t, PaneID("b"), tree.Root.Children[2].Leaf)
428+ require.Equal(t, 61.0, tree.Root.Children[2].Weight)
429+ require.Equal(t, []float64{20, 0, 61, 20}, childWeights(tree.Root))
430+ require.Equal(t, map[PaneID]int{"x": 20, "a": 20, "b": 40, "y": 20}, placementWidths(after))
431+ },
432+ },
433+ {
434+ name: "sole-column expel creates equal default horizontal children and retains wrapper weight",
435+ tree: &Tree{Root: weightedNode(verticalNodes(weightedLeaf("a", 3), weightedLeaf("b", 1)), 9), Focus: "a"},
436+ target: "a",
437+ dir: Left,
438+ area: domain.Rect{Width: 101, Height: 5},
439+ check: func(t *testing.T, tree *Tree, _, after []Placement) {
440+ require.Equal(t, Horizontal, tree.Root.Dir)
441+ require.Equal(t, 9.0, tree.Root.Weight)
442+ require.Equal(t, []float64{0, 0}, childWeights(tree.Root))
443+ require.Equal(t, map[PaneID]int{"a": 50, "b": 50}, placementWidths(after))
444+ },
445+ },
446+ }
447+
448+ for _, tt := range tests {
449+ t.Run(tt.name, func(t *testing.T) {
450+ beforeIDs := LeafIDs(tt.tree.Root)
451+ requireUniquePaneIDs(t, beforeIDs)
452+ before, solved := Solve(tt.tree.Root, tt.area)
453+ require.True(t, solved, "weight fixture must solve before surgery")
454+
455+ changed, err := tt.tree.ConsumeOrExpelPane(tt.target, tt.dir, tt.area)
456+ require.NoError(t, err)
457+ require.True(t, changed)
458+ require.Equal(t, tt.target, tt.tree.Focus)
459+
460+ afterIDs := LeafIDs(tt.tree.Root)
461+ require.ElementsMatch(t, beforeIDs, afterIDs)
462+ require.Len(t, afterIDs, len(beforeIDs))
463+ requireUniquePaneIDs(t, afterIDs)
464+ after, solved := Solve(tt.tree.Root, tt.area)
465+ require.True(t, solved, "weighted result must solve")
466+ tt.check(t, tt.tree, before, after)
467+ })
468+ }
469+}
470+
471+func childWeights(node *Node) []float64 {
472+ weights := make([]float64, len(node.Children))
473+ for i, child := range node.Children {
474+ weights[i] = child.Weight
475+ }
476+ return weights
477+}
478+
479+func placementSpanWidth(placements []Placement, id PaneID) int {
480+ for _, placement := range placements {
481+ if placement.ID != id {
482+ continue
483+ }
484+ if placement.Content.Width > 0 {
485+ return placement.Content.Width
486+ }
487+ return placement.TitleBar.Width
488+ }
489+ return 0
490+}
491+
326 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {492 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
327 t.Parallel()493 t.Parallel()
328 494
M internal/usecase/ui/modal.go +65 -39
@@ -18,8 +18,7 @@ type Modal struct {
18 FixedHeight int18 FixedHeight int
19 }19 }
20 20
21-// Bounds returns the modal rectangle positioned within base and clamped to base.21+func (m Modal) preferredBounds(base domain.Size) domain.Rect {
22-func (m Modal) Bounds(base domain.Size) domain.Rect {
23 width := percentOf(base.Cols, m.WidthPct)22 width := percentOf(base.Cols, m.WidthPct)
24 height := percentOf(base.Rows, m.HeightPct)23 height := percentOf(base.Rows, m.HeightPct)
25 if m.FixedWidth > 0 {24 if m.FixedWidth > 0 {
@@ -37,38 +36,41 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
37 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)36 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)
38 }37 }
39 38
40-// Inner returns the modal content rectangle after removing a one-cell border.39+// Resolve computes the modal's responsive presentation from one preferred
41-func (m Modal) Inner(base domain.Size) domain.Rect {40+// bounds and inner pair.
42- bounds := m.Bounds(base)41+func (m Modal) Resolve(base domain.Size) Presentation {
43- return domain.Rect{42+ bounds := m.preferredBounds(base)
44- X: bounds.X + 1,43+ return ResolvePresentation(base, bounds, modalInner(bounds))
45- Y: bounds.Y + 1,44+}
46- Width: max(0, bounds.Width-2),45+
47- Height: max(0, bounds.Height-2),46+// CompositePresentation draws an already-resolved modal presentation and
47+// returns its inner content rectangle.
48+func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {
49+ FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior})
50+ drawBorderEdges(f, p.Bounds, p.Borders, border)
51+ if p.Borders&BorderTop != 0 {
52+ m.drawTitle(f, p.Bounds, border)
53+ }
54+ return p.Inner
55+}
56+
57+func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) {
58+ if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" {
59+ return
48 }60 }
61+ left := bounds.X + 1
62+ right := bounds.X + bounds.Width - 1
63+ start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2)
64+ DrawText(f, start, bounds.Y, right, m.Title, style)
49 }65 }
50 66
51-// Composite draws the modal border and title, fills its interior, and returns67+func modalInner(bounds domain.Rect) domain.Rect {
52-// the inner content rectangle. Border and interior styles are deliberately68+ return domain.Rect{
53-// independent so unfocused structure and chrome surfaces retain their roles.
54-// Cells outside the modal bounds are not changed.
55-func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
56- bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
57- inner := domain.Rect{
58 X: bounds.X + 1,69 X: bounds.X + 1,
59 Y: bounds.Y + 1,70 Y: bounds.Y + 1,
60 Width: max(0, bounds.Width-2),71 Width: max(0, bounds.Width-2),
61 Height: max(0, bounds.Height-2),72 Height: max(0, bounds.Height-2),
62 }73 }
63- DrawBox(f, bounds, border)
64- FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
65- if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
66- left := bounds.X + 1
67- right := bounds.X + bounds.Width - 1
68- start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2)
69- DrawText(f, start, bounds.Y, right, m.Title, border)
70- }
71- return inner
72 }74 }
73 75
74 // FillRect fills rect with cell, clipped to the frame bounds.76 // FillRect fills rect with cell, clipped to the frame bounds.
@@ -86,7 +88,11 @@ func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
86 88
87 // DrawBox draws a single-cell box clipped to the frame bounds.89 // DrawBox draws a single-cell box clipped to the frame bounds.
88 func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {90 func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
89- if rect.Width <= 0 || rect.Height <= 0 {91+ drawBorderEdges(f, rect, BorderAll, style)
92+}
93+
94+func drawBorderEdges(f renderer.Frame, rect domain.Rect, edges BorderEdges, style renderer.Style) {
95+ if rect.Width <= 0 || rect.Height <= 0 || edges == 0 {
90 return96 return
91 }97 }
92 left := rect.X98 left := rect.X
@@ -94,18 +100,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
94 right := rect.X + rect.Width - 1100 right := rect.X + rect.Width - 1
95 bottom := rect.Y + rect.Height - 1101 bottom := rect.Y + rect.Height - 1
96 102
97- for x := left; x <= right; x++ {103+ if edges&BorderTop != 0 {
98- setCell(f, x, top, '─', style)104+ for x := left; x <= right; x++ {
99- setCell(f, x, bottom, '─', style)105+ setCell(f, x, top, '─', style)
100- }106+ }
101- for y := top; y <= bottom; y++ {107+ }
102- setCell(f, left, y, '│', style)108+ if edges&BorderBottom != 0 {
103- setCell(f, right, y, '│', style)109+ for x := left; x <= right; x++ {
104- }110+ setCell(f, x, bottom, '─', style)
105- setCell(f, left, top, '┌', style)111+ }
106- setCell(f, right, top, '┐', style)112+ }
107- setCell(f, left, bottom, '└', style)113+ if edges&BorderLeft != 0 {
108- setCell(f, right, bottom, '┘', style)114+ for y := top; y <= bottom; y++ {
115+ setCell(f, left, y, '│', style)
116+ }
117+ }
118+ if edges&BorderRight != 0 {
119+ for y := top; y <= bottom; y++ {
120+ setCell(f, right, y, '│', style)
121+ }
122+ }
123+ if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft {
124+ setCell(f, left, top, '┌', style)
125+ }
126+ if edges&(BorderTop|BorderRight) == BorderTop|BorderRight {
127+ setCell(f, right, top, '┐', style)
128+ }
129+ if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft {
130+ setCell(f, left, bottom, '└', style)
131+ }
132+ if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight {
133+ setCell(f, right, bottom, '┘', style)
134+ }
109 }135 }
110 136
111 // DrawText draws text starting at x,y before exclusive clipX and returns the137 // DrawText draws text starting at x,y before exclusive clipX and returns the
M internal/usecase/ui/modal_test.go +178 -72
@@ -8,7 +8,7 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11-func TestModalBounds(t *testing.T) {11+func TestModalResolveBounds(t *testing.T) {
12 tests := []struct {12 tests := []struct {
13 name string13 name string
14 base domain.Size14 base domain.Size
@@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) {
22 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},22 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
23 },23 },
24 {24 {
25- name: "minimum clamps to base",25+ name: "minimum clamps to drawer availability",
26 base: domain.Size{Cols: 20, Rows: 6},26 base: domain.Size{Cols: 20, Rows: 6},
27 m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},27 m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},
28- want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6},28+ want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2},
29 },29 },
30 {30 {
31- name: "odd centering rounds down",31+ name: "narrow preferred height becomes drawer height",
32 base: domain.Size{Cols: 11, Rows: 7},32 base: domain.Size{Cols: 11, Rows: 7},
33 m: Modal{WidthPct: 50, HeightPct: 50},33 m: Modal{WidthPct: 50, HeightPct: 50},
34- want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3},34+ want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3},
35 },35 },
36 {36 {
37- name: "zero value fills base centered",37+ name: "zero value fills available drawer area",
38 base: domain.Size{Cols: 12, Rows: 8},38 base: domain.Size{Cols: 12, Rows: 8},
39 m: Modal{},39 m: Modal{},
40- want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8},40+ want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4},
41 },41 },
42 {42 {
43 name: "bottom anchor honors bottom margin",43 name: "bottom anchor honors bottom margin",
@@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) {
63 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},63 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
64 },64 },
65 {65 {
66- name: "right margin clamps to screen",66+ name: "drawer ignores right margin",
67 base: domain.Size{Cols: 20, Rows: 10},67 base: domain.Size{Cols: 20, Rows: 10},
68 m: Modal{68 m: Modal{
69 FixedWidth: 8,69 FixedWidth: 8,
@@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) {
71 Anchor: domain.AnchorRight,71 Anchor: domain.AnchorRight,
72 Margins: Margins{Right: 20},72 Margins: Margins{Right: 20},
73 },73 },
74- want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},74+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
75 },75 },
76 {76 {
77- name: "right anchored fixed dimensions clamp on small terminal",77+ name: "right anchored drawer can have zero height",
78 base: domain.Size{Cols: 10, Rows: 4},78 base: domain.Size{Cols: 10, Rows: 4},
79 m: Modal{79 m: Modal{
80 FixedWidth: 30,80 FixedWidth: 30,
@@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) {
82 Anchor: domain.AnchorRight,82 Anchor: domain.AnchorRight,
83 Margins: Margins{Right: 3},83 Margins: Margins{Right: 3},
84 },84 },
85- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},85+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
86 },86 },
87 {87 {
88- name: "fixed dimensions clamp on small terminal",88+ name: "fixed drawer dimensions clamp on small terminal",
89 base: domain.Size{Cols: 10, Rows: 4},89 base: domain.Size{Cols: 10, Rows: 4},
90 m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},90 m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},
91- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},91+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
92 },92 },
93 {93 {
94- name: "bottom margin clamps to screen",94+ name: "drawer ignores bottom margin",
95 base: domain.Size{Cols: 20, Rows: 10},95 base: domain.Size{Cols: 20, Rows: 10},
96 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},96 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
97- want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},97+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
98 },98 },
99 {99 {
100 name: "bottom right anchor honors combined margins",100 name: "bottom right anchor honors combined margins",
@@ -110,58 +110,171 @@ func TestModalBounds(t *testing.T) {
110 }110 }
111 for _, tt := range tests {111 for _, tt := range tests {
112 t.Run(tt.name, func(t *testing.T) {112 t.Run(tt.name, func(t *testing.T) {
113- if got := tt.m.Bounds(tt.base); got != tt.want {113+ if got := tt.m.Resolve(tt.base).Bounds; got != tt.want {
114- t.Fatalf("Bounds() = %+v, want %+v", got, tt.want)114+ t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want)
115 }115 }
116 })116 })
117 }117 }
118 }118 }
119 119
120-func TestModalInnerDegenerate(t *testing.T) {120+func TestModalResolveInnerDegenerate(t *testing.T) {
121- m := Modal{WidthPct: 100, HeightPct: 100}121+ got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner
122- got := m.Inner(domain.Size{Cols: 2, Rows: 2})122+ require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got)
123- want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0}
124- if got != want {
125- t.Fatalf("Inner() = %+v, want %+v", got, want)
126- }
127 }123 }
128 124
129-func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {125+func TestModalResolve(t *testing.T) {
130- f := renderer.NewFrame(10, 6)126+ tests := []struct {
131- exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}127+ name string
132- FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)128+ base domain.Size
129+ want Presentation
130+ }{
131+ {
132+ name: "narrow modal resolves to drawer",
133+ base: domain.Size{Cols: 20, Rows: 10},
134+ want: Presentation{
135+ Mode: PresentationDrawer,
136+ Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
137+ Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3},
138+ Borders: BorderTop,
139+ },
140+ },
141+ {
142+ name: "complete frame preserves floating modal geometry",
143+ base: domain.Size{Cols: 80, Rows: 10},
144+ want: Presentation{
145+ Mode: PresentationFloating,
146+ Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4},
147+ Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2},
148+ Borders: BorderAll,
149+ },
150+ },
151+ }
152+
153+ modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ require.Equal(t, tt.want, modal.Resolve(tt.base))
157+ })
158+ }
159+}
133 160
161+func TestCompositePresentationDrawsFloatingChrome(t *testing.T) {
162+ modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
163+ presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10})
164+ frame := renderer.NewFrame(80, 10)
165+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
166+ FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior)
134 border := renderer.Style{Bold: true, Foreground: 2, Background: -1}167 border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
135 interior := renderer.Style{Foreground: 3, Background: 4}168 interior := renderer.Style{Foreground: 3, Background: 4}
136- m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}169+
137- inner := m.Composite(f, border, interior)170+ inner := modal.CompositePresentation(frame, presentation, border, interior)
138- wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}171+ require.Equal(t, presentation.Inner, inner)
139- if inner != wantInner {172+ require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune)
140- t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)173+ require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune)
174+ require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
175+ require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
176+ require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune)
177+ require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior))
178+ assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior)
179+}
180+
181+func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) {
182+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
183+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
184+ bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4}
185+ inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2}
186+
187+ tests := []struct {
188+ name string
189+ presentation Presentation
190+ assert func(*testing.T, renderer.Frame)
191+ }{
192+ {
193+ name: "all edges render for drawer mode",
194+ presentation: Presentation{
195+ Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll,
196+ },
197+ assert: func(t *testing.T, frame renderer.Frame) {
198+ require.Equal(t, '┌', frame.At(1, 1).Rune)
199+ require.Equal(t, '│', frame.At(1, 2).Rune)
200+ require.Equal(t, '┘', frame.At(8, 4).Rune)
201+ require.Equal(t, 'T', frame.At(2, 1).Rune)
202+ },
203+ },
204+ {
205+ name: "top edge alone renders for floating mode",
206+ presentation: Presentation{
207+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop,
208+ },
209+ assert: func(t *testing.T, frame renderer.Frame) {
210+ require.Equal(t, '─', frame.At(1, 1).Rune)
211+ require.Equal(t, 'T', frame.At(2, 1).Rune)
212+ assertCell(t, frame, 1, 2, exterior)
213+ assertCell(t, frame, 8, 4, exterior)
214+ },
215+ },
216+ {
217+ name: "title is hidden without top edge",
218+ presentation: Presentation{
219+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft,
220+ },
221+ assert: func(t *testing.T, frame renderer.Frame) {
222+ require.Equal(t, '│', frame.At(1, 1).Rune)
223+ require.Equal(t, '│', frame.At(1, 4).Rune)
224+ assertCell(t, frame, 4, 1, exterior)
225+ assertCell(t, frame, 8, 4, exterior)
226+ },
227+ },
141 }228 }
142 229
143- assertRune(t, f, 2, 1, '┌')230+ for _, tt := range tests {
144- assertRune(t, f, 7, 1, '┐')231+ t.Run(tt.name, func(t *testing.T) {
145- assertRune(t, f, 2, 4, '└')232+ frame := renderer.NewFrame(10, 6)
146- assertRune(t, f, 7, 4, '┘')233+ FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior)
147- assertRune(t, f, 3, 1, '─')234+ (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle())
148- assertRune(t, f, 2, 2, '│')235+ tt.assert(t, frame)
149- assertRune(t, f, 4, 1, 'H')236+ })
150- assertRune(t, f, 5, 1, 'i')
151-
152- for y := inner.Y; y < inner.Y+inner.Height; y++ {
153- for x := inner.X; x < inner.X+inner.Width; x++ {
154- want := renderer.Cell{Rune: ' ', Style: interior}
155- if got := f.At(x, y); !got.Equal(want) {
156- t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want)
157- }
158- }
159 }237 }
160- if got := f.At(2, 1).Style; !got.Equal(border) {238+}
161- t.Fatalf("border style = %+v, want %+v", got, border)239+
240+func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) {
241+ modal := Modal{FixedHeight: 4, Title: " Rename "}
242+ presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10})
243+ frame := renderer.NewFrame(20, 10)
244+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
245+ FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior)
246+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
247+ interior := renderer.Style{Foreground: 3, Background: 4}
248+
249+ inner := modal.CompositePresentation(frame, presentation, border, interior)
250+ require.Equal(t, presentation.Inner, inner)
251+ require.Equal(t, '─', frame.At(0, 5).Rune)
252+ require.True(t, frame.At(0, 5).Style.Equal(border))
253+ require.Equal(t, 'R', frame.At(7, 5).Rune)
254+ require.NotEqual(t, '│', frame.At(0, 6).Rune)
255+ require.Equal(t, ' ', frame.At(0, 6).Rune)
256+ require.NotEqual(t, '─', frame.At(0, 8).Rune)
257+ require.Equal(t, ' ', frame.At(0, 8).Rune)
258+
259+ for _, y := range []int{0, 1, 2, 3, 4, 9} {
260+ for x := range frame.Width {
261+ assertCell(t, frame, x, y, exterior)
262+ }
162 }263 }
163- assertCell(t, f, 0, 0, exterior)264+}
164- assertCell(t, f, 9, 5, exterior)265+
266+func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) {
267+ modal := Modal{FixedHeight: 4, Title: "你你"}
268+ presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10})
269+ frame := renderer.NewFrame(4, 10)
270+ border := renderer.Style{Foreground: 2, Background: -1}
271+
272+ modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle())
273+
274+ require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune)
275+ require.True(t, frame.At(2, presentation.Bounds.Y).Continuation)
276+ require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune)
277+ require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip")
165 }278 }
166 279
167 func TestFillRectClips(t *testing.T) {280 func TestFillRectClips(t *testing.T) {
@@ -221,26 +334,19 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
221 }334 }
222 }335 }
223 336
224-func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) {337+func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) {
225 border := renderer.Style{Foreground: 1, Background: 2}338 border := renderer.Style{Foreground: 1, Background: 2}
226 interior := renderer.Style{Foreground: 3, Background: 4}339 interior := renderer.Style{Foreground: 3, Background: 4}
227- for _, tt := range []struct {340+ frame := renderer.NewFrame(2, 2)
228- name string341+ modal := Modal{WidthPct: 100, HeightPct: 100}
229- size domain.Size342+ presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2})
230- want domain.Rect343+
231- }{344+ got := modal.CompositePresentation(frame, presentation, border, interior)
232- {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}},345+
233- {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}},346+ require.Equal(t, presentation.Inner, got)
234- } {347+ for y := range frame.Height {
235- t.Run(tt.name, func(t *testing.T) {348+ for x := range frame.Width {
236- frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows)349+ require.Equal(t, renderer.BlankCell(), frame.At(x, y))
237- got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior)350+ }
238- require.Equal(t, tt.want, got)
239- for y := range frame.Height {
240- for x := range frame.Width {
241- require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds")
242- }
243- }
244- })
245 }351 }
246 }352 }
A internal/usecase/ui/presentation.go +75 -0
@@ -0,0 +1,75 @@
1+package ui
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+const (
6+ ResponsiveDrawerBreakpoint = 80
7+ DrawerContextRows = 3
8+ DrawerBottomRows = 1
9+)
10+
11+// PresentationMode describes whether an overlay uses its preferred floating
12+// geometry or becomes a narrow-terminal drawer.
13+type PresentationMode uint8
14+
15+const (
16+ PresentationFloating PresentationMode = iota
17+ PresentationDrawer
18+)
19+
20+// BorderEdges identifies the edges drawn around a presentation.
21+type BorderEdges uint8
22+
23+const (
24+ BorderTop BorderEdges = 1 << iota
25+ BorderRight
26+ BorderBottom
27+ BorderLeft
28+ BorderAll = BorderTop | BorderRight | BorderBottom | BorderLeft
29+)
30+
31+// Presentation is the resolved outer and content geometry for an overlay.
32+type Presentation struct {
33+ Mode PresentationMode
34+ Bounds domain.Rect
35+ Inner domain.Rect
36+ Borders BorderEdges
37+}
38+
39+// ResolvePresentation preserves preferred geometry on complete frames at or
40+// above the responsive breakpoint. Narrow frames use a full-width bottom
41+// drawer while reserving frame rows 0-2 and the bottom bar.
42+func ResolvePresentation(base domain.Size, preferredBounds, preferredInner domain.Rect) Presentation {
43+ if !base.Valid() {
44+ return Presentation{}
45+ }
46+ if base.Cols >= ResponsiveDrawerBreakpoint {
47+ return Presentation{
48+ Mode: PresentationFloating,
49+ Bounds: preferredBounds,
50+ Inner: preferredInner,
51+ Borders: BorderAll,
52+ }
53+ }
54+
55+ available := max(0, base.Rows-DrawerContextRows-DrawerBottomRows)
56+ height := min(max(preferredBounds.Height, 0), available)
57+ bounds := domain.Rect{
58+ X: 0,
59+ Y: base.Rows - DrawerBottomRows - height,
60+ Width: base.Cols,
61+ Height: height,
62+ }
63+ inner := domain.Rect{
64+ X: 0,
65+ Y: bounds.Y + min(1, height),
66+ Width: base.Cols,
67+ Height: max(0, height-1),
68+ }
69+ return Presentation{
70+ Mode: PresentationDrawer,
71+ Bounds: bounds,
72+ Inner: inner,
73+ Borders: BorderTop,
74+ }
75+}
A internal/usecase/ui/presentation_test.go +111 -0
@@ -0,0 +1,111 @@
1+package ui
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestResolvePresentation(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ base domain.Size
14+ bounds, inner domain.Rect
15+ want Presentation
16+ }{
17+ {
18+ name: "79 columns becomes full-width drawer",
19+ base: domain.Size{Cols: 79, Rows: 24},
20+ bounds: domain.Rect{X: 8, Y: 6, Width: 63, Height: 11},
21+ inner: domain.Rect{X: 9, Y: 7, Width: 61, Height: 9},
22+ want: Presentation{
23+ Mode: PresentationDrawer,
24+ Bounds: domain.Rect{X: 0, Y: 12, Width: 79, Height: 11},
25+ Inner: domain.Rect{X: 0, Y: 13, Width: 79, Height: 10},
26+ Borders: BorderTop,
27+ },
28+ },
29+ {
30+ name: "80 columns preserves preferred geometry",
31+ base: domain.Size{Cols: 80, Rows: 24},
32+ bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18},
33+ inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16},
34+ want: Presentation{
35+ Mode: PresentationFloating,
36+ Bounds: domain.Rect{X: 8, Y: 3, Width: 64, Height: 18},
37+ Inner: domain.Rect{X: 9, Y: 4, Width: 62, Height: 16},
38+ Borders: BorderAll,
39+ },
40+ },
41+ {
42+ name: "100 percent height preserves three rows and bottom bar",
43+ base: domain.Size{Cols: 79, Rows: 24},
44+ bounds: domain.Rect{Width: 79, Height: 24},
45+ inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 22},
46+ want: Presentation{
47+ Mode: PresentationDrawer,
48+ Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 20},
49+ Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 19},
50+ Borders: BorderTop,
51+ },
52+ },
53+ {
54+ name: "preferred height larger than available is capped",
55+ base: domain.Size{Cols: 79, Rows: 10},
56+ bounds: domain.Rect{X: -100, Y: -100, Width: 1000, Height: 1000},
57+ inner: domain.Rect{X: -99, Y: -99, Width: 998, Height: 998},
58+ want: Presentation{
59+ Mode: PresentationDrawer,
60+ Bounds: domain.Rect{X: 0, Y: 3, Width: 79, Height: 6},
61+ Inner: domain.Rect{X: 0, Y: 4, Width: 79, Height: 5},
62+ Borders: BorderTop,
63+ },
64+ },
65+ {
66+ name: "negative preferred height becomes empty drawer",
67+ base: domain.Size{Cols: 79, Rows: 24},
68+ bounds: domain.Rect{Height: -1},
69+ inner: domain.Rect{Height: -3},
70+ want: Presentation{
71+ Mode: PresentationDrawer,
72+ Bounds: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0},
73+ Inner: domain.Rect{X: 0, Y: 23, Width: 79, Height: 0},
74+ Borders: BorderTop,
75+ },
76+ },
77+ {
78+ name: "four-row terminal reserves all protected rows",
79+ base: domain.Size{Cols: 79, Rows: 4},
80+ bounds: domain.Rect{Width: 79, Height: 4},
81+ inner: domain.Rect{X: 1, Y: 1, Width: 77, Height: 2},
82+ want: Presentation{
83+ Mode: PresentationDrawer,
84+ Bounds: domain.Rect{X: 0, Y: 3, Width: 79},
85+ Inner: domain.Rect{X: 0, Y: 3, Width: 79},
86+ Borders: BorderTop,
87+ },
88+ },
89+ {
90+ name: "one-row terminal is panic-free and empty",
91+ base: domain.Size{Cols: 1, Rows: 1},
92+ bounds: domain.Rect{Width: 1, Height: 1},
93+ inner: domain.Rect{X: 1, Y: 1},
94+ want: Presentation{
95+ Mode: PresentationDrawer,
96+ Bounds: domain.Rect{X: 0, Y: 0, Width: 1},
97+ Inner: domain.Rect{X: 0, Y: 0, Width: 1},
98+ Borders: BorderTop,
99+ },
100+ },
101+ {name: "zero frame is empty", base: domain.Size{}, want: Presentation{}},
102+ {name: "negative columns are empty", base: domain.Size{Cols: -1, Rows: 24}, want: Presentation{}},
103+ {name: "negative rows are empty", base: domain.Size{Cols: 79, Rows: -1}, want: Presentation{}},
104+ }
105+
106+ for _, tt := range tests {
107+ t.Run(tt.name, func(t *testing.T) {
108+ require.Equal(t, tt.want, ResolvePresentation(tt.base, tt.bounds, tt.inner))
109+ })
110+ }
111+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close