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.
5252 | Alt+h/j/k/l, Alt+Arrow | focus pane |
5353 | Alt+a | jump to a session needing attention |
5454
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.
5656
5757 ## Remote attach
5858
M docs/configuration.md +21 -2
@@ -34,6 +34,9 @@ focus-pane-left = alt+h
3434 focus-pane-right = alt+l
3535 focus-pane-up = alt+k
3636 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
3740 switch-tab-1 = alt+1
3841 # ... through switch-tab-9 = alt+9
3942
@@ -53,6 +56,8 @@ code.split-right = SPR
5356 code.split-left = SPL
5457 code.split-up = SPU
5558 code.split-down = SPD
59+code.consume-or-expel-pane-left = CEL
60+code.consume-or-expel-pane-right = CER
5661 code.stack-pane = STP
5762 code.toggle-stack = TST
5863 code.close-pane = CLP
@@ -96,6 +101,14 @@ Slots `0`, `7`, `8`, and `15` are valid explicit selections, but log a warning b
96101
97102 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.
98103
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+
99112 ## Navigation overflow
100113
101114 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 = " -_@"
116129
117130 Set `copy.word-separators = ""` to use only Unicode whitespace as a separator.
118131
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+
119138 ## Palette anchor
120139
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.
122141
123142 ## Floating terminal
124143
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.
126145
127146 ## Bar anchors
128147
M internal/app/cmd_test.go +8 -3
@@ -26,6 +26,8 @@ func TestParseCmdArgs(t *testing.T) {
2626 {"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
2727 {"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
2828 {"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},
2931 {"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
3032 {"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
3133 {"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
@@ -94,7 +96,7 @@ func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
9496 if called {
9597 t.Fatal("help dialed daemon")
9698 }
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"} {
98100 if !strings.Contains(out.String(), want) {
99101 t.Fatalf("help %q missing %q", out.String(), want)
100102 }
@@ -173,8 +175,11 @@ func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
173175 }
174176 }
175177
176-func TestResizeOneShotCmdsParseAndUseSelfTarget(t *testing.T) {
177- for _, slug := range []string{"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes"} {
178+func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
179+ for _, slug := range []string{
180+ "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
181+ "consume-or-expel-pane-left", "consume-or-expel-pane-right",
182+ } {
178183 t.Run(slug, func(t *testing.T) {
179184 invocation, err := parseArgs([]string{"cmd", "--self", slug})
180185 require.NoError(t, err)
M internal/usecase/command/command.go +4 -0
@@ -44,6 +44,8 @@ type Context interface {
4444 SplitLeft() error
4545 SplitUp() error
4646 SplitDown() error
47+ ConsumeOrExpelPaneLeft() error
48+ ConsumeOrExpelPaneRight() error
4749 StackPane() error
4850 ToggleStack() error
4951 ToggleFloatingPane() error
@@ -131,6 +133,8 @@ type ControlContext interface {
131133 SplitLeft() error
132134 SplitUp() error
133135 SplitDown() error
136+ ConsumeOrExpelPaneLeft() error
137+ ConsumeOrExpelPaneRight() error
134138 StackPane() error
135139 ToggleStack() error
136140 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
167167 return _c
168168 }
169169
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+
170258 // CreateSession provides a mock function for the type MockContext
171259 func (_mock *MockContext) CreateSession() error {
172260 ret := _mock.Called()
M internal/usecase/command/registry.go +2 -0
@@ -12,6 +12,8 @@ func Registry() []Command {
1212 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() }),
1313 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() }),
1414 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() }),
1517 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() }),
1618 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() }),
1719 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 (
99
1010 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
1111 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"}
1313
1414 if len(commands) != len(wantCodes) {
1515 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -115,6 +115,8 @@ func TestBySlugExactMatch(t *testing.T) {
115115 ok bool
116116 }{
117117 {slug: "split-right", ok: true},
118+ {slug: "consume-or-expel-pane-left", ok: true},
119+ {slug: "consume-or-expel-pane-right", ok: true},
118120 {slug: "SPLIT-RIGHT", ok: false},
119121 {slug: "toast", ok: true},
120122 {slug: "list-panes", ok: true},
@@ -132,7 +134,8 @@ func TestBySlugExactMatch(t *testing.T) {
132134 func TestRegistryControlMetadata(t *testing.T) {
133135 scriptable := map[string]TargetKind{
134136 "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,
136139 "close-pane": TargetPane, "focus-pane-left": TargetPane, "focus-pane-right": TargetPane,
137140 "focus-pane-up": TargetPane, "focus-pane-down": TargetPane,
138141 "new-tab": TargetSession, "close-tab": TargetTab, "next-tab": TargetSession,
@@ -217,6 +220,8 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) {
217220 }{
218221 {name: "zero argument mutation delegates", slug: "split-right", wantCall: "split-right"},
219222 {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"},
220225 {name: "grow width delegates", slug: "grow-pane-width", wantCall: "grow-pane-width"},
221226 {name: "shrink width delegates", slug: "shrink-pane-width", wantCall: "shrink-pane-width"},
222227 {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
267272 func (s *controlSpy) SplitLeft() error { return s.record("split-left") }
268273 func (s *controlSpy) SplitUp() error { return s.record("split-up") }
269274 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") }
270277 func (s *controlSpy) StackPane() error { return s.record("stack-pane") }
271278 func (s *controlSpy) ToggleStack() error { return s.record("toggle-stack") }
272279 func (s *controlSpy) GrowPaneWidth() error { return s.record("grow-pane-width") }
@@ -360,6 +367,8 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
360367 {code: "SPL", expect: func(ctx *MockContext) { ctx.EXPECT().SplitLeft().Return(nil).Once() }},
361368 {code: "SPU", expect: func(ctx *MockContext) { ctx.EXPECT().SplitUp().Return(nil).Once() }},
362369 {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() }},
363372 {code: "STP", expect: func(ctx *MockContext) { ctx.EXPECT().StackPane().Return(nil).Once() }},
364373 {code: "TST", expect: func(ctx *MockContext) { ctx.EXPECT().ToggleStack().Return(nil).Once() }},
365374 {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 (
226226 daemonActionRenameTab
227227 daemonActionResizePane
228228 daemonActionEqualizePanes
229+ daemonActionConsumeOrExpelPane
229230 )
230231
231232 type daemonActionTarget struct {
@@ -309,6 +310,8 @@ func (a daemonActions) Run(request daemonActionRequest) error {
309310 return a.d.resizePane(target, request.axis, request.delta)
310311 case daemonActionEqualizePanes:
311312 return a.d.equalizePanes(target)
313+ case daemonActionConsumeOrExpelPane:
314+ return a.d.consumeOrExpelPane(target, request.direction)
312315 default:
313316 return errors.New("daemon: unknown action")
314317 }
@@ -365,13 +368,19 @@ func (e controlExec) runAction(request daemonActionRequest) error {
365368 runner = daemonActions{d: e.d}
366369 }
367370 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 {
369378 e.sess.mu.Lock()
370379 ac := e.sess.client
371380 e.sess.mu.Unlock()
372381 finishDaemonActionForClient(e.d, request, ac, "control.go")
373382 }
374- return err
383+ return nil
375384 }
376385
377386 func finishDaemonActionForClient(d *Daemon, request daemonActionRequest, ac *attachedClient, producer string) {
@@ -465,6 +474,15 @@ func (e controlExec) SplitRight() error { return e.split(layout.Right) }
465474 func (e controlExec) SplitLeft() error { return e.split(layout.Left) }
466475 func (e controlExec) SplitUp() error { return e.split(layout.Up) }
467476 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+}
468486 func (e controlExec) StackPane() error {
469487 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
470488 }
M internal/usecase/daemon/control_test.go +92 -21
@@ -16,28 +16,99 @@ import (
1616 )
1717
1818 func TestPaletteAndControlShareExplicitDaemonActionTarget(t *testing.T) {
19- d := newTestDaemon(t, nil, stubClock{})
20- sess := addControlSession(d, "work", "t_work", "p_work")
21- tb := sess.activeTab()
22- tb.mu.Lock()
23- pane := tb.focusedPane()
24- tb.mu.Unlock()
25- spy := &actionRunnerSpy{}
26-
27- require.NoError(t, (paletteExec{d: d, sess: sess, actions: spy}).SplitRight())
28- require.NoError(t, (controlExec{
29- d: d, sess: sess, tab: tb, actions: spy,
30- target: daemonActionTarget{session: sess, tab: tb, pane: pane},
31- }).SplitRight())
32-
33- require.Len(t, spy.requests, 2)
34- for _, request := range spy.requests {
35- require.Equal(t, daemonActionSplitPane, request.kind)
36- require.Same(t, sess, request.target.session)
37- require.Same(t, tb, request.target.tab)
38- require.Same(t, pane, request.target.pane)
39- require.Equal(t, layout.Right, request.direction)
19+ tests := []struct {
20+ name string
21+ palette func(paletteExec) error
22+ control func(controlExec) error
23+ kind daemonActionKind
24+ direction layout.Direction
25+ }{
26+ {name: "split right", palette: func(e paletteExec) error { return e.SplitRight() }, control: func(e controlExec) error { return e.SplitRight() }, kind: daemonActionSplitPane, direction: layout.Right},
27+ {name: "consume or expel left", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneLeft() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneLeft() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Left},
28+ {name: "consume or expel right", palette: func(e paletteExec) error { return e.ConsumeOrExpelPaneRight() }, control: func(e controlExec) error { return e.ConsumeOrExpelPaneRight() }, kind: daemonActionConsumeOrExpelPane, direction: layout.Right},
4029 }
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)
41112 }
42113
43114 type actionRunnerSpy struct {
M internal/usecase/daemon/copymode_test.go +1 -1
@@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
100100 func TestCopySearchModalGeometry(t *testing.T) {
101101 base := domain.Size{Cols: 100, Rows: 40}
102102
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)
104104 require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106106 }
M internal/usecase/daemon/floating.go +3 -7
@@ -144,7 +144,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane,
144144 p.mu.Lock()
145145 geometry := p.committedFloatingGeometryLocked(desired)
146146 p.mu.Unlock()
147- if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
147+ if !geometry.committable() {
148148 return nil, floatingGeometry{}, false
149149 }
150150 return p, geometry, true
@@ -345,11 +345,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
345345 sess.mu.Unlock()
346346 tb.mu.Lock()
347347 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)
348- size := rectSize(geometry.Inner)
349- if !size.Valid() {
350- geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}}
351- size = rectSize(geometry.Inner)
352- }
348+ size := rectSize(geometry.ptyRect())
353349 focused := tb.focusedPane()
354350 tabStableID, tabCtx := tb.stableID, tb.ctx
355351 tb.mu.Unlock()
@@ -419,7 +415,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
419415 return
420416 }
421417 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
422- p.rect = spec.geometry.Inner
418+ p.rect = spec.geometry.ptyRect()
423419 p.popupGeometry = spec.geometry
424420 p.title.displayFallback = spec.fallback
425421 if !lifetime.publish(p) {
M internal/usecase/daemon/floating_render.go +191 -27
@@ -13,6 +13,7 @@ import (
1313 // translates both fields and returns/caches frame-absolute geometry. Callers
1414 // must not translate a composed or cached value again.
1515 type floatingGeometry struct {
16+ Mode ui.PresentationMode
1617 Bounds domain.Rect
1718 Inner domain.Rect
1819 }
@@ -21,6 +22,22 @@ func (g floatingGeometry) valid() bool {
2122 return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
2223 }
2324
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+
2441 func (g floatingGeometry) translate(dx, dy int) floatingGeometry {
2542 g.Bounds.X += dx
2643 g.Bounds.Y += dy
@@ -33,7 +50,7 @@ func (g floatingGeometry) translate(dx, dy int) floatingGeometry {
3350 // last committed. Newly constructed test panes fall back to the requested
3451 // geometry until their first resize. The caller holds p.mu.
3552 func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {
36- if p.popupGeometry.valid() {
53+ if p.popupGeometry.committable() {
3754 return p.popupGeometry
3855 }
3956 return fallback
@@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry
5875 return axis
5976 }
6077
61-// calculateContentFloatingGeometry returns origin-zero, tab-content-relative
62-// coordinates for a percentage-sized popup. Launch sizing derives from Inner
63-// too, so rendering and PTY geometry share the same per-axis percentage and
64-// tiny-border rules.
65-func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
78+// calculatePreferredFloatingGeometry retains the canonical percentage and
79+// tiny-axis rules used by wide floating terminals.
80+func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
6681 if !content.Valid() {
6782 return floatingGeometry{}
6883 }
@@ -75,6 +90,7 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
7590 Height: y.BoundsSize,
7691 }
7792 return floatingGeometry{
93+ Mode: ui.PresentationFloating,
7894 Bounds: bounds,
7995 Inner: domain.Rect{
8096 X: bounds.X + x.BorderOffset,
@@ -85,12 +101,33 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
85101 }
86102 }
87103
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+
88126 type floatingComposeInput struct {
89127 baseFrame renderer.Frame
90128 baseDamage []renderer.Damage
91129 floating capturedFloatingRenderState
92130 content domain.Rect
93- layout capturedTabLayout
94131 theme themeui.Theme
95132 borderMuted renderer.Style
96133 borderActive renderer.Style
@@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
103140 baseDamage := input.baseDamage
104141 floating := input.floating
105142 content := input.content
106- layoutSnap := input.layout
107143 theme := input.theme
108144 borderMuted := input.borderMuted
109145 borderActive := input.borderActive
@@ -111,10 +147,9 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
111147 full := input.full
112148
113149 frame := base.Clone()
114- layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
115- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme)
150+ applyOverlayBackdrop(frame, theme)
116151 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)
118153 damage := append([]renderer.Damage(nil), baseDamage...)
119154 for _, d := range floating.pane.damage {
120155 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
@@ -125,39 +160,168 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
125160 if floating.focused {
126161 border = borderActive
127162 }
128- drawFloatingBorder(frame, geometry.Bounds, floating.title, border)
163+ drawFloatingBorder(frame, geometry, floating.title, border)
129164 if full || popupChanged {
130165 return frame, []renderer.Damage{renderer.FullRedraw()}
131166 }
132- if titleChanged && geometry.Bounds.Height >= 3 {
133- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
167+ hasTitleBorder := geometry.Bounds.Height >= 3
168+ if geometry.Mode == ui.PresentationDrawer {
169+ hasTitleBorder = geometry.Bounds.Height > 0
170+ }
171+ if titleChanged && hasTitleBorder {
172+ if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
173+ damage = append(damage, titleDamage)
174+ }
134175 }
135176 return frame, damage
136177 }
137178
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
139238 if bounds.Width <= 0 || bounds.Height <= 0 {
140239 return
141240 }
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+ }
142246 // Each axis omits its borders independently for tiny popups, matching
143247 // 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)
144250 if bounds.Height >= 3 {
145- for x := bounds.X; x < bounds.X+bounds.Width; x++ {
146- frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})
147- frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style})
251+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
252+ if hasBottom {
253+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
148254 }
149255 }
150256 if bounds.Width >= 3 {
151- for y := bounds.Y; y < bounds.Y+bounds.Height; y++ {
152- frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style})
153- frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style})
257+ drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
258+ if hasRight {
259+ drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
154260 }
155261 }
156- if bounds.Width >= 3 && bounds.Height >= 3 {
157- frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style})
158- frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style})
159- frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style})
160- frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style})
161- ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
262+ if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom {
263+ setFloatingCell(frame, bounds.X, bounds.Y, '┌', style)
264+ setFloatingCell(frame, right, bounds.Y, '┐', style)
265+ setFloatingCell(frame, bounds.X, bottom, '└', style)
266+ setFloatingCell(frame, right, bottom, '┘', style)
267+ drawFloatingTitle(frame, bounds, title, style)
268+ }
269+}
270+
271+func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) {
272+ if y < 0 || y >= frame.Height {
273+ return
274+ }
275+ span, ok := clippedFloatingSpan(x, width, frame.Width)
276+ if !ok {
277+ return
278+ }
279+ for dx := range span.length {
280+ frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style})
281+ }
282+}
283+
284+func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) {
285+ if x < 0 || x >= frame.Width {
286+ return
287+ }
288+ span, ok := clippedFloatingSpan(y, height, frame.Height)
289+ if !ok {
290+ return
291+ }
292+ for dy := range span.length {
293+ frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style})
294+ }
295+}
296+
297+func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) {
298+ if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height {
299+ return
300+ }
301+ frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
302+}
303+
304+func floatingLastCoordinate(origin, size int) (int, bool) {
305+ if size <= 0 {
306+ return 0, false
307+ }
308+ last := origin + size - 1
309+ if last < origin {
310+ return 0, false
311+ }
312+ return last, true
313+}
314+
315+func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
316+ const inset = 2
317+ titleWidth := bounds.Width - 2*inset
318+ if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset {
319+ return
320+ }
321+ titleX := bounds.X + inset
322+ span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width)
323+ if !ok {
324+ return
162325 }
326+ ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
163327 }
M internal/usecase/daemon/floating_render_test.go +284 -13
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/usecase/layout"
1414 themeui "github.com/bnema/vev/internal/usecase/theme"
15+ "github.com/bnema/vev/internal/usecase/ui"
1516 "github.com/bnema/vev/pkg/renderer"
1617 )
1718
@@ -39,21 +40,66 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr
3940
4041 func TestFloatingGeometryTranslate(t *testing.T) {
4142 geometry := floatingGeometry{
43+ Mode: ui.PresentationDrawer,
4244 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
4345 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},
4446 }
4547
4648 translated := geometry.translate(11, 13)
4749 require.Equal(t, floatingGeometry{
50+ Mode: ui.PresentationDrawer,
4851 Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8},
4952 Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6},
5053 }, translated)
5154 require.Equal(t, floatingGeometry{
55+ Mode: ui.PresentationDrawer,
5256 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
5357 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},
5458 }, geometry)
5559 }
5660
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+
57103 func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {
58104 const frameX, frameY = 7, 3
59105 tests := []struct {
@@ -93,26 +139,77 @@ func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {
93139 }
94140 }
95141
96-func TestCalculateFloatingGeometry(t *testing.T) {
142+func TestCalculatePreferredFloatingGeometry(t *testing.T) {
97143 tests := []struct {
98144 name string
99145 content domain.Size
100146 cfg domain.FloatingConfig
101147 want floatingGeometry
102148 }{
103- {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}},
104- {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}},
105- {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}},
106- {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}},
107- {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},
149+ {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}},
150+ {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}},
151+ {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}},
152+ {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}},
153+ {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},
108154 }
109155 for _, tt := range tests {
110156 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))
112158 })
113159 }
114160 }
115161
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+
116213 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
117214 tests := []struct {
118215 name string
@@ -133,9 +230,9 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
133230 }
134231
135232 func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
136- base := renderer.NewFrame(40, 12)
233+ base := renderer.NewFrame(80, 12)
137234 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}
139236 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
140237 paneFrame := renderer.NewFrame(6, 3)
141238 paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})
@@ -181,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi
181278 require.Equal(t, 1, damage[0].Height)
182279 }
183280
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+
184413 func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
185414 base := renderer.NewFrame(20, 9)
186415 content := domain.Rect{Y: 1, Width: 20, Height: 7}
@@ -401,12 +630,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
401630
402631 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
403632 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{})
405634 for _, cell := range frame.Cells {
406635 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
407636 }
408637 }
409638
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+
410681 func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
411682 base := renderer.NewFrame(80, 24)
412683 content := domain.Rect{Y: 1, Width: 80, Height: 22}
@@ -574,13 +845,13 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
574845 require.Empty(t, pty.sizes())
575846
576847 // 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.
578849 require.NoError(t, d.toggleFloating(sess, nil))
579850 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
580- require.Equal(t, current, floating.rect)
851+ require.Equal(t, initial, floating.rect)
581852 require.Equal(t, initial.Width, floating.screen.Frame.Width)
582853 require.Equal(t, initial.Height, floating.screen.Frame.Height)
583- require.Equal(t, currentGeometry, floating.popupGeometry)
854+ require.Equal(t, initialGeometry, floating.popupGeometry)
584855
585856 require.NoError(t, d.toggleFloating(sess, nil)) // hide
586857 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
208208 d.sessWg.Wait()
209209 }
210210
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+
211276 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
212277 floatingPTY := portsmocks.NewMockPTY(t)
213278 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) {
9292 }
9393
9494 tb.mu.Lock()
95- contentRow := ev.Row - 1
95+ contentRow := ev.Row - clientTopBarRows
9696 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
9797 if floatingVisible {
9898 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
@@ -201,7 +201,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
201201 if translated {
202202 d.writeToPane(sess, p, ev.Raw)
203203 } else {
204- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))
204+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
205205 }
206206 return
207207 }
@@ -359,14 +359,21 @@ func (h daemonKeyHandler) Action(action keys.Action) {
359359 if owned {
360360 defer effect.End()
361361 }
362- runResizeAction := func(request daemonActionRequest) {
362+ runAction := func(request daemonActionRequest) {
363363 request.target = resolveDaemonActionTarget(sess)
364364 runner := h.actions
365365 if runner == nil {
366366 runner = daemonActions{d: h.d}
367367 }
368368 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)
370377 return
371378 }
372379 if h.actions == nil {
@@ -405,15 +412,19 @@ func (h daemonKeyHandler) Action(action keys.Action) {
405412 h.d.reportError(sess, err)
406413 }
407414 case keys.ActionGrowPaneWidth:
408- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
415+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: resizeStepCols})
409416 case keys.ActionShrinkPaneWidth:
410- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
417+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Width, delta: -resizeStepCols})
411418 case keys.ActionGrowPaneHeight:
412- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
419+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: resizeStepRows})
413420 case keys.ActionShrinkPaneHeight:
414- runResizeAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
421+ runAction(daemonActionRequest{kind: daemonActionResizePane, axis: layout.Height, delta: -resizeStepRows})
415422 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})
417428 case keys.ActionSwitchTab1, keys.ActionSwitchTab2, keys.ActionSwitchTab3,
418429 keys.ActionSwitchTab4, keys.ActionSwitchTab5, keys.ActionSwitchTab6,
419430 keys.ActionSwitchTab7, keys.ActionSwitchTab8, keys.ActionSwitchTab9:
M internal/usecase/daemon/input_test.go +177 -2
@@ -21,11 +21,114 @@ import (
2121 "github.com/bnema/vev/internal/usecase/layout"
2222 "github.com/bnema/vev/internal/usecase/mouse"
2323 "github.com/bnema/vev/internal/usecase/picker"
24+ "github.com/bnema/vev/internal/usecase/ui"
2425 "github.com/bnema/vev/pkg/vt"
2526 )
2627
2728 // --- test doubles -----------------------------------------------------------
2829
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+
29132 func TestResizeActionAdaptersSubmitEquivalentRequests(t *testing.T) {
30133 tests := []struct {
31134 name string
@@ -602,7 +705,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
602705 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
603706 installTestFloating(tb, floating, true)
604707 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)
606709
607710 d.handleInput(sess, ac, raw)
608711 }
@@ -620,12 +723,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
620723 installTestFloating(tb, floating, true)
621724 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
622725
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)
624727 outside := []byte("\x1b[<0;1;2M")
625728 d.handleInput(sess, ac, border)
626729 d.handleInput(sess, ac, outside)
627730 }
628731
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+
629804 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
630805 writes := make(chan []byte, 1)
631806 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 (
88 )
99
1010 func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11- inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase)
12- modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height})
13- for y := range min(inner.Height, modalFrame.Height) {
14- for x := range min(inner.Width, modalFrame.Width) {
15- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
16- }
17- }
11+ presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height})
12+ modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase)
13+ modalFrame := renderInner(rectSize(presentation.Inner))
14+ copyModalInner(base, presentation.Inner, modalFrame)
1815 return base, []renderer.Damage{renderer.FullRedraw()}
1916 }
M internal/usecase/daemon/mouse_panes.go +1 -1
@@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
187187 func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {
188188 ev.Col -= colOffset
189189 ev.Row -= contentYOffset
190- ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1)
190+ ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows)
191191 return ev
192192 }
193193
M internal/usecase/daemon/overlay_backdrop.go +5 -27
@@ -6,33 +6,11 @@ import (
66 "github.com/bnema/vev/pkg/renderer"
77 )
88
9-// overlayBackdrop controls optional dimming of pane content behind an overlay.
10-// Chrome (bars, dividers, and pane titles) is deliberately not part of the
11-// backdrop.
12-type overlayBackdrop struct {
13- DimPaneContents bool
14-}
15-
16-// apply dims the content rectangles from a tab-content-relative layout
17-// snapshot. A failed solve follows the tab renderer's focused-pane fallback by
18-// dimming the complete content area.
19-func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) {
20- if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 {
21- return
22- }
23- if !layoutSnap.ok {
24- dimFrameRect(frame, contentArea, theme)
25- return
26- }
27- for _, placement := range layoutSnap.placements {
28- if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
29- continue
30- }
31- r := placement.Content
32- r.X += contentArea.X
33- r.Y += contentArea.Y
34- dimFrameRect(frame, r, theme)
35- }
9+// applyOverlayBackdrop dims every cell already composed into the complete
10+// renderer frame. Modal composition calls it immediately before painting each
11+// active layer so bars, panes, toasts, and lower overlays share one backdrop.
12+func applyOverlayBackdrop(frame renderer.Frame, theme themeui.Theme) {
13+ dimFrameRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, theme)
3614 }
3715
3816 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 (
66 "github.com/stretchr/testify/require"
77
88 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
109 themeui "github.com/bnema/vev/internal/usecase/theme"
1110 "github.com/bnema/vev/pkg/renderer"
1211 )
@@ -15,82 +14,39 @@ func backdropTheme() themeui.Theme {
1514 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}}
1615 }
1716
18-func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) {
19- frame := renderer.NewFrame(8, 6)
17+func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) {
18+ frame := renderer.NewFrame(4, 3)
2019 original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}
21- frame.Set(2, 2, original)
22- frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})
23- frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title
24- frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar
25- frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar
26- frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed
27-
28- snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{
29- {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}},
30- {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}},
31- }}
32- (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme())
33-
34- require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin")
35- require.True(t, frame.At(2, 2).Style.HasForegroundRGB)
36- require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming")
37- require.Zero(t, frame.At(3, 2).Rune)
38- require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp")
39- require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp")
40- require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp")
41- require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed")
42-}
43-
44-func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
45- tests := []struct {
46- name string
47- content domain.Rect
48- affected [][2]int
49- unaffected [][2]int
50- }{
51- {
52- name: "negative origin",
53- content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3},
54- affected: [][2]int{{0, 0}, {1, 1}},
55- unaffected: [][2]int{{2, 2}, {3, 3}},
56- },
57- {
58- name: "right and bottom overflow",
59- content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4},
60- affected: [][2]int{{2, 2}, {3, 3}},
61- unaffected: [][2]int{{0, 0}, {1, 1}},
62- },
20+ for y := range frame.Height {
21+ for x := range frame.Width {
22+ frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*frame.Width + x), Style: renderer.DefaultStyle()})
23+ }
6324 }
64-
65- for _, tt := range tests {
66- t.Run(tt.name, func(t *testing.T) {
67- frame := renderer.NewFrame(4, 4)
68- original := renderer.DefaultStyle()
69- snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}}
70-
71- (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())
72-
73- for _, point := range tt.affected {
74- require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), frame.At(point[0], point[1]).Style)
75- }
76- for _, point := range tt.unaffected {
77- require.Equal(t, original, frame.At(point[0], point[1]).Style)
78- }
79- })
25+ frame.Set(1, 1, original)
26+ frame.Set(2, 1, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})
27+
28+ applyOverlayBackdrop(frame, backdropTheme())
29+ dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
30+ for y := range frame.Height {
31+ for x := range frame.Width {
32+ require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y)
33+ }
8034 }
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)
8138 }
8239
83-func TestOverlayBackdropDisabledAndFallback(t *testing.T) {
40+func TestDimFrameRectClipsToFrame(t *testing.T) {
8441 frame := renderer.NewFrame(4, 4)
85- frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
8642 before := frame.Clone()
87- area := domain.Rect{Y: 1, Width: 4, Height: 2}
88- (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
89- require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default")
43+ dimFrameRect(frame, domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, backdropTheme())
44+ dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
9045
91- (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())
92- require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area")
93- require.True(t, frame.At(3, 2).Style.HasForegroundRGB)
94- require.False(t, frame.At(0, 0).Style.HasForegroundRGB)
95- require.False(t, frame.At(0, 3).Style.HasForegroundRGB)
46+ for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} {
47+ require.Equal(t, dimmed, frame.At(point[0], point[1]).Style)
48+ }
49+ for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} {
50+ require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style)
51+ }
9652 }
M internal/usecase/daemon/palette.go +28 -9
@@ -322,7 +322,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
322322 return
323323 }
324324 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)
326326 sess.dispatchMu.Unlock()
327327 if roleHandoff {
328328 if current := ac.currentSession(); current != nil {
@@ -393,12 +393,13 @@ func (ac *attachedClient) closeExecutedPalette(generation uint64, rawQuery strin
393393 }
394394
395395 type paletteExec struct {
396- d *Daemon
397- sess *session
398- ac *attachedClient
399- recent []recentSession
400- actions daemonActionRunner
401- effect *roleEffectTicket
396+ d *Daemon
397+ sess *session
398+ ac *attachedClient
399+ recent []recentSession
400+ actions daemonActionRunner
401+ effect *roleEffectTicket
402+ redrawClosedPalette bool
402403 }
403404
404405 func (e paletteExec) runAction(request daemonActionRequest) error {
@@ -410,10 +411,19 @@ func (e paletteExec) runAction(request daemonActionRequest) error {
410411 runner = daemonActions{d: e.d}
411412 }
412413 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 {
414424 finishDaemonActionForClient(e.d, request, e.ac, "palette.go")
415425 }
416- return err
426+ return nil
417427 }
418428
419429 func (e paletteExec) CreateTab() error {
@@ -442,6 +452,15 @@ func (e paletteExec) SplitRight() error { return e.split(layout.Right) }
442452 func (e paletteExec) SplitLeft() error { return e.split(layout.Left) }
443453 func (e paletteExec) SplitUp() error { return e.split(layout.Up) }
444454 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+}
445464
446465 func (e paletteExec) StackPane() error {
447466 return e.runAction(daemonActionRequest{kind: daemonActionStackPane})
M internal/usecase/daemon/palette_test.go +45 -7
@@ -520,14 +520,22 @@ func TestPaletteJRSDisplacedTargetKeepsInteractionOpen(t *testing.T) {
520520 }
521521 d.handleInput(sess, ac, []byte("\x1b "))
522522 awaitFrame(t, sends, ports.MsgOutput)
523- go d.handleInput(sess, ac, []byte("JRS 1\r"))
524- <-validated
523+ inputHandled := make(chan struct{})
524+ go func() {
525+ d.handleInput(sess, ac, []byte("JRS 1\r"))
526+ close(inputHandled)
527+ }()
528+ awaitTestCompletion(t, validated, "JRS did not validate its captured target")
525529 d.mu.Lock()
526530 delete(d.sessions, target.id)
527531 d.mu.Unlock()
528532 close(releaseHandoff)
529533
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")
531539 require.True(t, ac.overlays.paletteActive())
532540 ac.overlays.paletteMu.Lock()
533541 require.Equal(t, "JRS 1", ac.overlays.palette.Query())
@@ -734,7 +742,7 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
734742 for i, cmd := range commands {
735743 codes[i] = cmd.Code
736744 }
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)
738746 }
739747
740748 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
@@ -852,10 +860,12 @@ func TestPaletteModalGeometry(t *testing.T) {
852860
853861 auto := domain.PaletteConfig{}
854862 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}},
855865 {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}},
856866 {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}},
857867 {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}},
859869 }
860870 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
861871 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) {
869879 for _, tt := range tests {
870880 t.Run(tt.name, func(t *testing.T) {
871881 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+ }
873891 require.Equal(t, " Commands ", modal.Title)
874892 require.Equal(t, 11, modal.FixedHeight)
875893 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
@@ -877,6 +895,26 @@ func TestPaletteModalGeometry(t *testing.T) {
877895 }
878896 }
879897
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+
880918 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
881919 model := palette.New(nil)
882920 base := renderer.NewFrame(120, 40)
@@ -902,7 +940,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
902940 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
903941
904942 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
905- inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size)
943+ inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner
906944
907945 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
908946 }
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 *
6464 func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testing.T) {
6565 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: 1, historyRows: 10_000})
6666
67- markSnapshotDirty(fixture.sess)
68- require.True(t, fixture.d.scheduleSnapshot(fixture.sess))
69- awaitSnapshotClean(t, fixture.sess)
67+ // This contract concerns capture and publication bytes, not queue latency.
68+ // Publish synchronously so the 10k-row setup cannot race the test deadline.
69+ publishBenchmarkSnapshot(t, fixture, 1)
7070
7171 var copies atomic.Uint64
7272 fixture.sess.snapshotMu.Lock()
@@ -83,9 +83,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
8383 for i, r := range "changed" {
8484 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
8585 }
86- markSnapshotDirty(fixture.sess)
87- require.True(t, fixture.d.scheduleFinalSnapshot(fixture.sess))
88- awaitSnapshotClean(t, fixture.sess)
86+ publishBenchmarkSnapshot(t, fixture, 2)
8987
9088 metrics := fixture.snapshots.metrics()
9189 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) {
13441344 func TestPickerModalGeometry(t *testing.T) {
13451345 base := domain.Size{Cols: 100, Rows: 40}
13461346
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)
13481348 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
13491349 }
13501350
M internal/usecase/daemon/prompt_test.go +1 -1
@@ -20,7 +20,7 @@ func (f failingPTYFactory) Open(context.Context, string, []string, []string, str
2020 func TestPromptModalGeometry(t *testing.T) {
2121 base := domain.Size{Cols: 100, Rows: 40}
2222
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)
2424 }
2525
2626 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
M internal/usecase/daemon/render_capture.go +5 -3
@@ -102,9 +102,11 @@ type capturedOverlayRenderState struct {
102102 }
103103
104104 type capturedModal struct {
105- modal ui.Modal
106- inner renderer.Frame
107- focused bool
105+ active bool
106+ title string
107+ presentation ui.Presentation
108+ inner renderer.Frame
109+ focused bool
108110 }
109111
110112 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 ...
126126 damage = append(damage, d)
127127 }
128128 }
129- // Keep the committed cache unadorned: floating composition clones this
130- // base, so closing or moving a popup cannot retain dimmed/bordered cells.
129+ // Keep the committed cache unadorned. Toasts are composed first so floating
130+ // terminals and scoped modals dim them with the rest of the complete frame.
131131 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+ }
132139 if state.floating.visible {
133140 var floatingDamage []renderer.Damage
134141 frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
135- baseFrame: baseFrame,
142+ baseFrame: frame,
136143 baseDamage: damage,
137144 floating: state.floating,
138145 content: content,
139- layout: state.layout,
140146 theme: state.theme,
141147 borderMuted: styles.BorderMuted,
142148 borderActive: styles.BorderActive,
143149 cache: in,
144- full: full || state.overlays.active(),
150+ full: full || overlaysActive,
145151 })
146152 damage = floatingDamage
147153 }
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+ }
148161 if !full {
149- if !sameCells(in.bars.top, frame.Row(0)) {
162+ if !sameCells(in.bars.top, baseFrame.Row(0)) {
150163 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
151164 }
152- if !sameCells(in.bars.bottom, frame.Row(rows+1)) {
165+ if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) {
153166 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
154167 }
155168 }
@@ -157,17 +170,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157170 // toasts, so every render damages both the last and current toast coverage.
158171 // This restores cells exposed by dismissal and redraws a stable toast over
159172 // 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)
171173 if full || overlaysActive {
172174 damage = []renderer.Damage{renderer.FullRedraw()}
173175 } else {
@@ -222,91 +224,111 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222224 }
223225 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
224226 if snap.copySearchModel != nil {
225- o.copySearch.modal = copySearchModal
226- o.copySearch.focused = true
227- o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
227+ presentation := copySearchModal.Resolve(size)
228+ o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
229+ o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
228230 }
229231 if snap.pickerActive && snap.pickerModel != nil {
230- o.picker.modal = pickerModal
231- o.picker.focused = true
232+ presentation := pickerModal.Resolve(size)
233+ o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true}
232234 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)
234236 }
235237 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
236- o.noticesOverlay.modal = noticesModal
237- o.noticesOverlay.focused = true
238+ presentation := noticesModal.Resolve(size)
239+ o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
238240 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)
240242 }
241243 if snap.paletteActive && snap.paletteModel != nil {
242- o.palette.modal = paletteModalFor(size, paletteCfg)
243- o.palette.focused = true
244+ modal := paletteModalFor(size, paletteCfg)
245+ presentation := modal.Resolve(size)
246+ o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
244247 guidance := ""
245248 if snap.paletteHints != nil {
246249 guidance = snap.paletteHints.Feedback
247250 }
248251 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})
250253 }
251254 if snap.promptActive && snap.promptModel != nil {
252- o.prompt.modal = promptModalFor(snap.promptModel.Title())
253- o.prompt.focused = true
254- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
255+ modal := promptModalFor(snap.promptModel.Title())
256+ presentation := modal.Resolve(size)
257+ o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
258+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
255259 }
256260 state.cursor.hiddenByOverlay = o.active()
257261 }
258262
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) {
260264 o := state.overlays
261- if o.copyActive {
262- target := domain.Rect{}
263- if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
264- target = state.floating.geometry.translate(content.X, content.Y).Inner
265+ if !o.copyActive {
266+ return frame, damage
267+ }
268+ target := domain.Rect{}
269+ if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
270+ target = state.floating.geometry.translate(content.X, content.Y).Inner
271+ }
272+ if target.Width == 0 {
273+ id := o.copyPaneID
274+ if id == "" {
275+ id = state.layout.focus
265276 }
266- if target.Width == 0 {
267- id := o.copyPaneID
268- if id == "" {
269- id = state.layout.focus
270- }
271- for _, p := range state.panes {
272- if p.id == id && !p.placement.Collapsed {
273- target = p.placement.Content
274- target.Y += content.Y
275- break
276- }
277+ for _, p := range state.panes {
278+ if p.id == id && !p.placement.Collapsed {
279+ target = p.placement.Content
280+ target.Y += content.Y
281+ break
277282 }
278283 }
279- frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles)
280- }
281- layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
282- if o.paletteActive && !state.floating.visible {
283- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)
284- }
285- // This paint order intentionally differs from HandleInput's keyboard
286- // priority (prompt > palette > picker > notices > copy, see
287- // overlay_runtime.go), which paints the picker under notices instead of
288- // over it. Currently unreachable: notices only opens via the palette, and
289- // HandleInput short-circuits to the first active overlay, so picker and
290- // notices are never simultaneously active. If that ever changes, this
291- // mismatch would let the picker own the keyboard while notices visually
292- // covers it.
293- for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {
294- if modal.inner.Width == 0 && modal.inner.Height == 0 {
284+ }
285+ return composeCopyClientFrame(o.copyMode, target, frame, state.styles)
286+}
287+
288+func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
289+ o := state.overlays
290+ // Paint in reverse keyboard priority so the same layer that owns input is
291+ // visually topmost: prompt > palette > picker > notices > copy search.
292+ for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} {
293+ if !modal.active {
295294 continue
296295 }
296+ applyOverlayBackdrop(frame, state.theme)
297297 border := state.styles.BorderMuted
298298 if modal.focused {
299299 border = state.styles.BorderActive
300300 }
301- inner := modal.modal.Composite(frame, border, state.styles.PickerBase)
302- for y := range min(inner.Height, modal.inner.Height) {
303- copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
304- }
301+ (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
302+ copyModalInner(frame, modal.presentation.Inner, modal.inner)
305303 damage = []renderer.Damage{renderer.FullRedraw()}
306304 }
307305 return frame, damage
308306 }
309307
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+
310332 func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
311333 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
312334 return nil
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -1
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/vev/internal/ports"
1111 "github.com/bnema/vev/internal/usecase/layout"
1212 themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/internal/usecase/ui"
1314 "github.com/bnema/vev/pkg/renderer"
1415 "github.com/stretchr/testify/require"
1516 )
@@ -326,7 +327,7 @@ func cacheState(title string, generation uint64) capturedRenderState {
326327 reset: false,
327328 layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
328329 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},
330331 bars: barState{topRight: title, bottomRight: title},
331332 styles: resolveStyles(nil),
332333 }
M internal/usecase/daemon/render_pipeline_test.go +253 -7
@@ -9,13 +9,20 @@ import (
99 "path/filepath"
1010 "strings"
1111 "testing"
12+ "time"
1213
1314 "github.com/bnema/vev/internal/domain"
1415 "github.com/bnema/vev/internal/ports"
1516 "github.com/bnema/vev/internal/testutil/replaytest"
17+ scopy "github.com/bnema/vev/internal/usecase/copy"
1618 "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"
1723 themeui "github.com/bnema/vev/internal/usecase/theme"
1824 "github.com/bnema/vev/internal/usecase/ui"
25+ "github.com/bnema/vev/internal/usecase/visualsearch"
1926 "github.com/bnema/vev/pkg/renderer"
2027 "github.com/bnema/vev/pkg/vt"
2128 "github.com/stretchr/testify/require"
@@ -220,21 +227,21 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
220227 reset: true,
221228 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}}}},
222229 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},
224231 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}},
226233 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
227234 styles: resolveStyles(nil),
228235 }
229236 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))
231238 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
232239
233240 stream := newOutputStateStream()
234241 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
235242 require.NoError(t, err)
236243 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))
238245 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
239246 client.Write(terminalBytes)
240247 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
@@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
298305 active := renderer.Style{Foreground: 3, Background: 4}
299306 interior := renderer.Style{Foreground: 5, Background: 6}
300307 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
308+ presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8})
301309
302310 for _, tt := range []struct {
303311 name string
@@ -310,16 +318,254 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
310318 t.Run(tt.name, func(t *testing.T) {
311319 state := capturedRenderState{
312320 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}},
314322 }
315- frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})
316- bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8})
323+ frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil)
324+ bounds := presentation.Bounds
317325 require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
318326 require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
319327 })
320328 }
321329 }
322330
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+
323569 func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
324570 terminal := renderer.DefaultStyle()
325571 muted := renderer.Style{Foreground: 1}
M internal/usecase/daemon/render_test.go +14 -5
@@ -48,7 +48,10 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
4848 dimmed := client.Frame.At(0, 1)
4949 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
5050 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+ }
5255 paletteVisible := false
5356 for y := range client.Frame.Height {
5457 paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")
@@ -93,7 +96,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
9396 }
9497 }
9598
96-func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
99+func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
97100 p, release := newBlockingPTY(t)
98101 defer release()
99102 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -110,7 +113,9 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
110113
111114 d.enterPalette(sess, ac)
112115 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")
114119 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")
115120 }
116121
@@ -133,8 +138,12 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
133138 dimmed := client.Frame.At(0, 1)
134139 require.Equal(t, 'X', dimmed.Rune)
135140 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")
136- require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")
137- require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")
141+ dimmedTopBar := topBar
142+ dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style)
143+ dimmedBottomBar := bottomBar
144+ dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style)
145+ require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop")
146+ require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop")
138147
139148 d.handleInput(sess, ac, []byte("\x1b"))
140149 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 {
3232 })
3333 }
3434
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-
8335 func resizeUserError(err error) error {
8436 switch {
8537 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 *
106106 }
107107 return publication.emit()
108108 }
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
4141 if r.publishErr != nil {
4242 return r.publishErr
4343 }
44- objects := make(map[ports.SnapshotDigest][]byte, len(p.Objects))
45- for _, object := range p.Objects {
46- objects[object.Digest] = append([]byte(nil), object.Data...)
47- }
48- r.generations[p.Name] = ports.SnapshotGeneration{IncarnationID: p.IncarnationID, Name: p.Name, Generation: p.Generation, ParentCheckpoint: p.ParentCheckpoint, Manifest: append([]byte(nil), p.Manifest...), Objects: objects}
44+ r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p))
4945 found := false
5046 for _, name := range r.names {
5147 found = found || name == p.Name
@@ -183,11 +179,9 @@ func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation u
183179 manifest.Generation = generation
184180 encoded, err := snapcodec.MarshalManifest(manifest)
185181 require.NoError(t, err)
186- objects := make(map[ports.SnapshotDigest][]byte, len(publication.Objects))
187- for _, object := range publication.Objects {
188- objects[object.Digest] = append([]byte(nil), object.Data...)
189- }
190- return ports.SnapshotGeneration{IncarnationID: publication.IncarnationID, Name: snapshot.Name, Generation: generation, ParentCheckpoint: publication.ParentCheckpoint, Manifest: encoded, Objects: objects}
182+ publication.Generation = generation
183+ publication.Manifest = encoded
184+ return cloneAcceptanceGeneration(snapshotGeneration(publication))
191185 }
192186
193187 func TestValidateRestoreSessionSnapshot(t *testing.T) {
@@ -267,6 +261,156 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
267261 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
268262 }
269263
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+
270414 func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
271415 snapshot := restoreAcceptanceSession(t, "restored")
272416 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
2323 return frame
2424 }
2525
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)
2728 message := "This session is now active elsewhere."
2829 if feedback != "" {
2930 message = feedback
M internal/usecase/daemon/snatched_render_test.go +1 -1
@@ -92,7 +92,7 @@ func TestSnatchedPanelFrame(t *testing.T) {
9292 return
9393 }
9494
95- require.Equal(t, tt.wantBounds, snatchedModal.Bounds(tt.wantSize))
95+ require.Equal(t, tt.wantBounds, snatchedModal.Resolve(tt.wantSize).Bounds)
9696 topLeft := frame.At(tt.wantBounds.X, tt.wantBounds.Y)
9797 bottomRight := frame.At(tt.wantBounds.X+tt.wantBounds.Width-1, tt.wantBounds.Y+tt.wantBounds.Height-1)
9898 require.Equal(t, '┌', topLeft.Rune)
M internal/usecase/daemon/transactional_resize.go +295 -111
@@ -1,6 +1,8 @@
11 package daemon
22
33 import (
4+ "context"
5+
46 "github.com/bnema/vev/internal/domain"
57 "github.com/bnema/vev/internal/ports"
68 "github.com/bnema/vev/internal/usecase/layout"
@@ -9,13 +11,13 @@ import (
911 type resizeOwnerPostEffect uint8
1012
1113 const (
12- resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota + 1
14+ resizeOwnerPostSnapshotDirty resizeOwnerPostEffect = iota
1315 resizeOwnerPostRetrySchedule
1416 resizeOwnerPostRenderInvalidation
1517 resizeOwnerPostCommitPublication
1618 )
1719
18-// resizeMember is an unpublished layout decision. Prepare only reads guarded
20+// resizeMember is an unpublished layout decision. Prepare only reads guarded
1921 // state; apply performs the external PTY call; commit is the sole publisher.
2022 type resizeMember struct {
2123 session *session
@@ -43,11 +45,6 @@ type preparedTabLayout struct {
4345 members []resizeMember
4446 }
4547
46-type resizeApplyResult struct {
47- members []resizeMember
48- failed []resizeMember
49-}
50-
5148 func prepareTabLayoutLocked(sess *session, tb *tab) preparedTabLayout {
5249 return prepareTabLayoutForSizeLocked(sess, tb, tb.size)
5350 }
@@ -138,9 +135,6 @@ func resizeMemberOwnerCurrent(member *resizeMember) bool {
138135 if member == nil || member.pane == nil {
139136 return false
140137 }
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.
144138 if member.owner.owner == nil {
145139 return true
146140 }
@@ -170,33 +164,6 @@ func resizeMembersOwnerCurrent(members []resizeMember) bool {
170164 return true
171165 }
172166
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-
200167 func commitPreparedTabLayoutLocked(plan *preparedTabLayout) bool {
201168 for i := range plan.members {
202169 member := &plan.members[i]
@@ -270,22 +237,22 @@ func (d *Daemon) finishPreparedTabMembers(plan *preparedTabLayout, accepted bool
270237 // applyTabLayoutTransaction is the canonical tiled-layout publisher. It owns
271238 // one per-tab single-writer loop, but never holds tab or pane state locks while
272239 // 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) {
274241 return d.applyTabLayoutTransactionWithNotice(sess, tb, true, current...)
275242 }
276243
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) {
278245 if tb == nil {
279- return resizeApplyResult{}, false
246+ return nil, false
280247 }
281248 tb.layoutApplyMu.Lock()
282249 defer tb.layoutApplyMu.Unlock()
283250 for {
284251 if tb.ctx != nil && tb.ctx.Err() != nil {
285- return resizeApplyResult{}, false
252+ return nil, false
286253 }
287254 if len(current) != 0 && !current[0]() {
288- return resizeApplyResult{}, false
255+ return nil, false
289256 }
290257 tb.mu.Lock()
291258 plan := prepareTabLayoutLocked(sess, tb)
@@ -304,10 +271,6 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
304271 d.finishPreparedTabMembers(&plan, accepted)
305272 if !accepted {
306273 d.cancelStalePreparedGates(sess, tb, &plan)
307- if preparedTabOwnerStale(&plan) {
308- d.releasePreparedSessionGates([]*preparedTabLayout{&plan})
309- return resizeApplyResult{}, false
310- }
311274 if len(current) != 0 && !current[0]() {
312275 for i := range plan.members {
313276 member := &plan.members[i]
@@ -318,7 +281,7 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
318281 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
319282 member.pane.resizeMu.Unlock()
320283 }
321- return resizeApplyResult{}, false
284+ return nil, false
322285 }
323286 continue
324287 }
@@ -328,32 +291,87 @@ func (d *Daemon) applyTabLayoutTransactionWithNotice(sess *session, tb *tab, rep
328291 failed = append(failed, member)
329292 }
330293 }
331- if reportFailure && len(failed) != 0 && resizeMembersOwnerCurrent(failed) {
294+ if reportFailure && len(failed) != 0 {
332295 d.notify(sess, domain.NoticeWarn, domain.NoticeResizeFailed,
333296 "pane resize failed; retrying in background", failed[len(failed)-1].err)
334297 }
335- return resizeApplyResult{members: append([]resizeMember(nil), plan.members...), failed: failed}, true
298+ return failed, true
336299 }
337300 }
338301
339302 func (d *Daemon) applyTabLayout(sess *session, tb *tab) bool {
340- result, ok := d.applyTabLayoutTransaction(sess, tb)
303+ failed, ok := d.applyTabLayoutTransaction(sess, tb)
341304 if !ok {
342305 return false
343306 }
344- if !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
345- markSnapshotDirty(sess)
346- }) {
347- return false
348- }
349- if len(result.failed) != 0 {
350- if !d.scheduleAcceptedTabLayoutRetry(sess, tb, result.failed) {
351- return false
352- }
307+ markSnapshotDirty(sess)
308+ if len(failed) != 0 {
309+ d.scheduleAcceptedTabLayoutRetry(sess, tb)
353310 }
354311 return true
355312 }
356313
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+
357375 // replayResizePending completes a pane's apply epoch. It keeps the gate set
358376 // while each buffered batch goes through the normal VT path, so reads arriving
359377 // 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
390408 // used for retryable PTY work. Floating state is not part of a tiled layout
391409 // generation, so its slot generation and exact pane identity validate its
392410 // 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) {
394412 return d.applyVisibleFloatingLayoutForMember(sess, tb, current, nil)
395413 }
396414
397415 // applyVisibleFloatingLayoutForMember optionally fences a delayed retry to the
398416 // exact failed floating slot. Unlike tiled panes, a popup is not in tb.panes,
399417 // 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) {
401419 if tb == nil {
402- return resizeApplyResult{}, true
420+ return nil, true
403421 }
404422 tb.mu.Lock()
405423 if tb.floating.state != floatingVisible || tb.floating.pane == nil ||
406424 (expected != nil && (tb.floating.generation != expected.floatingGeneration || tb.floating.pane != expected.pane)) {
407425 tb.mu.Unlock()
408- return resizeApplyResult{}, true
426+ return nil, true
409427 }
410428 p := tb.floating.pane
411429 generation := tb.floating.generation
412430 size := tb.size
413431 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
414432 tb.mu.Unlock()
415- if !geometry.valid() {
416- return resizeApplyResult{}, true
433+ if !geometry.committable() {
434+ return nil, true
435+ }
436+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
437+ // A drawer can validly reserve only its separator row. Publish that
438+ // presentation without inventing a physical PTY size; the last usable
439+ // rectangle and screen remain committed until a later resize has content.
440+ tb.mu.Lock()
441+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
442+ tb.floating.pane == p && tb.size == size
443+ if current != nil && !current() {
444+ currentSlot = false
445+ }
446+ if currentSlot {
447+ p.mu.Lock()
448+ p.popupGeometry = geometry
449+ p.mu.Unlock()
450+ tb.mu.Unlock()
451+ return nil, true
452+ }
453+ tb.mu.Unlock()
454+ if current != nil && !current() {
455+ return nil, false
456+ }
457+ return nil, true
417458 }
418459
419460 // This keeps successful PTY resizes gated until the
@@ -423,34 +464,30 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
423464 owner := p.effectLeaseLocked()
424465 p.mu.Unlock()
425466 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,
427468 }}}
428469 d.applyPreparedTabMembers(&plan)
429470
430471 // The PTY may have accepted an intermediate size, but a hidden, replaced,
431472 // relaunched, or resized slot must never receive this attempt's geometry.
432473 tb.mu.Lock()
433- currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size &&
434- resizeMemberOwnerCurrent(&plan.members[0])
474+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation && tb.floating.pane == p && tb.size == size && resizeMemberOwnerCurrent(&plan.members[0])
435475 if current != nil && !current() {
436476 currentSlot = false
437477 }
438- if currentSlot {
478+ if currentSlot && plan.members[0].ok {
439479 p.mu.Lock()
440480 currentSlot = resizeMemberOwnerCurrent(&plan.members[0])
441481 if currentSlot {
442- p.rect = geometry.Inner
482+ p.rect = plan.members[0].rect
443483 p.popupGeometry = geometry
444- if plan.members[0].ok {
445- p.resizeRetry = false
446- p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
447- }
484+ p.resizeRetry = false
485+ p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
448486 }
449487 p.mu.Unlock()
450488 }
451489 tb.mu.Unlock()
452490 if !currentSlot {
453- ownerCurrent := resizeMemberOwnerCurrent(&plan.members[0])
454491 // This attempt has no tiled transaction loop to retain the gate for, so
455492 // discard its buffered bytes at the old screen size before returning.
456493 for i := range plan.members {
@@ -462,17 +499,16 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
462499 d.replayResizePending(member.session, member.tab, member.pane, false, member.rect)
463500 member.pane.resizeMu.Unlock()
464501 }
465- if !ownerCurrent || (current != nil && !current()) {
466- return resizeApplyResult{}, false
502+ if current != nil && !current() {
503+ return nil, false
467504 }
468- return resizeApplyResult{}, true
505+ return nil, true
469506 }
470507 if plan.members[0].ok {
471508 d.finishPreparedTabMembers(&plan, true)
472- return resizeApplyResult{members: append([]resizeMember(nil), plan.members...)}, true
509+ return nil, true
473510 }
474- members := append([]resizeMember(nil), plan.members...)
475- return resizeApplyResult{members: members, failed: members}, true
511+ return plan.members, true
476512 }
477513
478514 // releasePreparedSessionGates abandons a session attempt which cannot be
@@ -501,9 +537,9 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
501537 // transaction: all PTYs are applied and plans validated first, then the
502538 // coordinator admits the epoch before any tab size, rectangle, VT screen,
503539 // 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) {
505541 if sess == nil {
506- return resizeApplyResult{}, false
542+ return nil, false
507543 }
508544 sess.layoutApplyMu.Lock()
509545 defer sess.layoutApplyMu.Unlock()
@@ -511,10 +547,10 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
511547 target := tabSize(size)
512548 for {
513549 if sess.ctx != nil && sess.ctx.Err() != nil {
514- return resizeApplyResult{}, false
550+ return nil, false
515551 }
516552 if current != nil && !current() {
517- return resizeApplyResult{}, false
553+ return nil, false
518554 }
519555 sess.mu.Lock()
520556 tabs := append([]*tab(nil), sess.tabs...)
@@ -565,11 +601,7 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
565601 valid = false
566602 break
567603 }
568- }
569- if valid {
570- for _, plan := range plans {
571- plan.tab.size = plan.size
572- }
604+ plan.tab.size = plan.size
573605 }
574606 }
575607 for i := len(tabs) - 1; i >= 0; i-- {
@@ -577,27 +609,20 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
577609 }
578610 if !valid {
579611 d.releasePreparedSessionGates(plans)
580- for _, plan := range plans {
581- if preparedTabOwnerStale(plan) {
582- return resizeApplyResult{}, false
583- }
584- }
585612 // Headless requests have no attachment epoch. A live tab mutation
586613 // still invalidates their plan, but must trigger a fresh prepare rather
587614 // than being mistaken for a canceled request.
588615 if current != nil && !current() {
589- return resizeApplyResult{}, false
616+ return nil, false
590617 }
591618 // A layout mutation invalidated the plans while this epoch remains
592619 // current. Reapply the fresh session geometry before admitting it.
593620 continue
594621 }
595622
596- members := make([]resizeMember, 0)
597623 failed := make([]resizeMember, 0)
598624 for _, plan := range plans {
599625 d.finishPreparedTabMembers(plan, true)
600- members = append(members, plan.members...)
601626 for _, member := range plan.members {
602627 if !member.ok {
603628 failed = append(failed, member)
@@ -605,12 +630,11 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
605630 }
606631 }
607632 if active >= 0 && active < len(tabs) {
608- floatingResult, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
633+ floatingFailed, ok := d.applyVisibleFloatingLayout(sess, tabs[active], current)
609634 if !ok {
610- return resizeApplyResult{}, false
635+ return nil, false
611636 }
612- members = append(members, floatingResult.members...)
613- failed = append(failed, floatingResult.failed...)
637+ failed = append(failed, floatingFailed...)
614638 }
615639 failedCurrent := len(failed) == 0 || resizeMembersOwnerCurrent(failed)
616640 if len(failed) != 0 && failedCurrent {
@@ -618,9 +642,174 @@ func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, ad
618642 "pane resize failed; retrying in background", failed[len(failed)-1].err)
619643 }
620644 if len(failed) != 0 && !failedCurrent {
621- return resizeApplyResult{}, false
645+ return nil, false
622646 }
623- return resizeApplyResult{members: members, failed: failed}, true
647+ markSnapshotDirty(sess)
648+ d.observeRuntime(ports.RuntimeResizeCommitted, 0, true)
649+ return failed, true
650+ }
651+}
652+
653+func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64) bool {
654+ rc := sess.renderCoordinator()
655+ if rc == nil {
656+ return false
657+ }
658+ snap := rc.resizeSnapshot()
659+ current := func() bool { return rc.resizeCurrentForLease(epoch, ac, lease, false) }
660+ if !current() {
661+ return false
662+ }
663+ d.exitCopyMode(ac)
664+ failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
665+ return rc.resizeCurrentForLease(epoch, ac, lease, true)
666+ })
667+ if !ok {
668+ return false
669+ }
670+ ac.sendMu.Lock()
671+ ac.size = snap.size
672+ ac.sendMu.Unlock()
673+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
674+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
675+ }
676+ d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
677+ // A successful transaction publishes exactly one full S2 frame. The
678+ // coordinator is the only emission route and stale epochs never reach it.
679+ if !rc.invalidateForLeaseAtResizeEpoch(ac, lease, epoch, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"}) {
680+ return false
681+ }
682+ // The resize debounce has already elapsed. Consume this sticky reset now;
683+ // fire preserves ACK and synchronized-output gates rather than scheduling a
684+ // second urgent deadline.
685+ rc.fireCurrent(false)
686+ return true
687+}
688+
689+// retryResizeMembers retries failed committed members through a freshly
690+// prepared tab transaction. The captured members identify retry candidates
691+// only: their rectangles must never cross this delayed boundary, because any
692+// later layout mutation may have changed their geometry or removed them.
693+func (d *Daemon) retryResizeMembers(sess *session, ac *attachedClient, lease *attachmentLease, epoch uint64, members []resizeMember) {
694+ rc := sess.renderCoordinator()
695+ if rc == nil || !rc.retryCurrentForLease(epoch, ac, lease) {
696+ return
697+ }
698+
699+ // Retain tab order for deterministic external PTY ordering while collapsing
700+ // several failed members from the same tab into one canonical transaction.
701+ tabs := make([]*tab, 0, len(members))
702+ seen := make(map[*tab]struct{}, len(members))
703+ for _, member := range members {
704+ if member.tab == nil || member.isFloating || !resizeMemberOwnerCurrent(&member) {
705+ continue
706+ }
707+ if _, ok := seen[member.tab]; ok {
708+ continue
709+ }
710+ seen[member.tab] = struct{}{}
711+ tabs = append(tabs, member.tab)
712+ }
713+
714+ failed := make([]resizeMember, 0, len(members))
715+ succeeded := false
716+ for _, tb := range tabs {
717+ if !rc.retryCurrentForLease(epoch, ac, lease) {
718+ return
719+ }
720+ // A later accepted layout transaction clears resizeRetry. In that case
721+ // this delayed callback has no remaining work and, importantly, must not
722+ // replay the obsolete rectangle it captured before the mutation.
723+ retryPending := false
724+ tb.mu.Lock()
725+ for _, member := range members {
726+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
727+ continue
728+ }
729+ member.pane.mu.Lock()
730+ retryPending = retryPending || member.pane.resizeRetry
731+ member.pane.mu.Unlock()
732+ }
733+ tb.mu.Unlock()
734+ if !retryPending {
735+ continue
736+ }
737+
738+ // applyTabLayoutTransaction captures the current generation, pane
739+ // pointers, and solved rectangles, and validates them again before any
740+ // VT/rectangle publication. It also preserves the resize gate and
741+ // degradation notice behavior for another failed external attempt.
742+ freshFailed, ok := d.applyTabLayoutTransaction(sess, tb, func() bool {
743+ return rc.retryCurrentForLease(epoch, ac, lease)
744+ })
745+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
746+ return
747+ }
748+ failed = append(failed, freshFailed...)
749+ // A retry success is a formerly failed, still-current target whose
750+ // canonical apply cleared its retry bit. A collapsed or removed target
751+ // is neither a retry completion nor a reason to publish a reset.
752+ tb.mu.Lock()
753+ for _, member := range members {
754+ if member.tab != tb || member.pane == nil || tb.panes[member.pane.id] != member.pane {
755+ continue
756+ }
757+ member.pane.mu.Lock()
758+ succeeded = succeeded || !member.pane.resizeRetry
759+ member.pane.mu.Unlock()
760+ }
761+ tb.mu.Unlock()
762+ }
763+ for _, member := range members {
764+ if !member.isFloating || member.tab == nil || member.pane == nil {
765+ continue
766+ }
767+ if !rc.retryCurrentForLease(epoch, ac, lease) {
768+ return
769+ }
770+ // Floating panes are outside tb.panes. Validate the exact accepted slot
771+ // before preparing a fresh geometry; applyVisibleFloatingLayout repeats
772+ // the same pointer/generation check around external PTY.Resize.
773+ member.tab.mu.Lock()
774+ currentSlot := member.tab.floating.state == floatingVisible &&
775+ member.tab.floating.generation == member.floatingGeneration &&
776+ member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
777+ retryPending := false
778+ if currentSlot {
779+ member.pane.mu.Lock()
780+ retryPending = member.pane.resizeRetry
781+ member.pane.mu.Unlock()
782+ }
783+ member.tab.mu.Unlock()
784+ if !currentSlot || !retryPending {
785+ continue
786+ }
787+ freshFailed, ok := d.applyVisibleFloatingLayoutForMember(sess, member.tab, func() bool {
788+ return rc.retryCurrentForLease(epoch, ac, lease)
789+ }, &member)
790+ if !ok || !rc.retryCurrentForLease(epoch, ac, lease) {
791+ return
792+ }
793+ failed = append(failed, freshFailed...)
794+ member.tab.mu.Lock()
795+ stillCurrent := member.tab.floating.state == floatingVisible &&
796+ member.tab.floating.generation == member.floatingGeneration &&
797+ member.tab.floating.pane == member.pane && resizeMemberOwnerCurrent(&member)
798+ member.tab.mu.Unlock()
799+ if stillCurrent && len(freshFailed) == 0 {
800+ member.pane.mu.Lock()
801+ succeeded = succeeded || !member.pane.resizeRetry
802+ member.pane.mu.Unlock()
803+ }
804+ }
805+ if len(failed) != 0 && rc.resizeRetryAvailableForLease(epoch, ac, lease) {
806+ rc.scheduleResizeRetryForLease(epoch, ac, lease, func() { d.retryResizeMembers(sess, ac, lease, epoch, failed) })
807+ }
808+ if succeeded {
809+ // Retry completion changes VT state after the original layout commit.
810+ // Keep a named session's eventual snapshot generation aligned with it.
811+ markSnapshotDirty(sess)
812+ rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "transactional_resize.go"})
624813 }
625814 }
626815
@@ -646,13 +835,8 @@ func (d *Daemon) requestTransactionalResizeForLease(sess *session, ac *attachedC
646835 if ac == nil {
647836 // Headless geometry has no coordinator/transport to coalesce, but keeps
648837 // the same prepare/apply/commit ordering.
649- result, ok := d.applySessionLayout(sess, size, nil, nil)
650- if !ok || !d.publishResizeOwnerPostEffect(result.members, resizeOwnerPostSnapshotDirty, func() {
651- markSnapshotDirty(sess)
652- }) {
653- return false
654- }
655- return d.publishResizeCommit(result.members, sess, nil, nil, 0, nil, size)
838+ _, ok := d.applySessionLayout(sess, size, nil, nil)
839+ return ok
656840 }
657841 rc := sess.renderCoordinator()
658842 if rc == nil || !rc.leaseCurrent(lease, true) {
M internal/usecase/daemon/transactional_resize_test.go +190 -399
@@ -5,7 +5,6 @@ import (
55 "io"
66 "strings"
77 "sync"
8- "sync/atomic"
98 "testing"
109 "time"
1110
@@ -14,6 +13,7 @@ import (
1413 "github.com/bnema/vev/internal/domain"
1514 "github.com/bnema/vev/internal/ports"
1615 "github.com/bnema/vev/internal/usecase/layout"
16+ "github.com/bnema/vev/internal/usecase/ui"
1717 "github.com/bnema/vev/internal/usecase/visualsearch"
1818 )
1919
@@ -272,10 +272,10 @@ func TestApplySessionLayoutReportsResizeFailuresOnce(t *testing.T) {
272272 }
273273 d, sess, _, _ := newManualSessionWithPTYs(t, first, second)
274274
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)
276276
277277 require.Equal(t, tc.wantOK, ok)
278- require.Len(t, result.failed, tc.wantFailed)
278+ require.Len(t, failed, tc.wantFailed)
279279 count := 0
280280 for _, notification := range d.notices.history() {
281281 if notification.Code == domain.NoticeResizeFailed {
@@ -407,402 +407,6 @@ func TestTransactionalResizeEpochLifecycleAndRetryContract(t *testing.T) {
407407 // stopped. Only the newest prepared epoch may apply, commit, and emit one full
408408 // frame. coordinatorMockClock is a generated MockClock/MockTimer harness; its
409409 // 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-
806410 func TestTransactionalResizeRejectsNewerEpochBeforeSessionPublication(t *testing.T) {
807411 first, second := &transactionalResizePTY{}, &transactionalResizePTY{}
808412 d, sess, ac, _ := newManualSessionWithPTYs(t, first, second)
@@ -941,6 +545,193 @@ func TestRetryOwnerCannotPublishFloatingGeometryAfterMove(t *testing.T) {
941545 source.snapshotMu.Unlock()
942546 }
943547
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+
944735 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
945736 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
946737 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) {
4949 }
5050
5151 var actionNames = map[Action]string{
52- ActionOpenPalette: "open-palette",
53- ActionToggleFloatingPane: "toggle-floating-pane",
54- ActionJumpAttention: "jump-attention",
55- ActionFocusPaneLeft: "focus-pane-left",
56- ActionFocusPaneRight: "focus-pane-right",
57- ActionFocusPaneUp: "focus-pane-up",
58- ActionFocusPaneDown: "focus-pane-down",
59- ActionSwitchTab1: "switch-tab-1",
60- ActionSwitchTab2: "switch-tab-2",
61- ActionSwitchTab3: "switch-tab-3",
62- ActionSwitchTab4: "switch-tab-4",
63- ActionSwitchTab5: "switch-tab-5",
64- ActionSwitchTab6: "switch-tab-6",
65- ActionSwitchTab7: "switch-tab-7",
66- ActionSwitchTab8: "switch-tab-8",
67- ActionSwitchTab9: "switch-tab-9",
68- ActionGrowPaneWidth: "grow-pane-width",
69- ActionShrinkPaneWidth: "shrink-pane-width",
70- ActionGrowPaneHeight: "grow-pane-height",
71- ActionShrinkPaneHeight: "shrink-pane-height",
72- ActionEqualizePanes: "equalize-panes",
52+ ActionOpenPalette: "open-palette",
53+ ActionToggleFloatingPane: "toggle-floating-pane",
54+ ActionJumpAttention: "jump-attention",
55+ ActionFocusPaneLeft: "focus-pane-left",
56+ ActionFocusPaneRight: "focus-pane-right",
57+ ActionFocusPaneUp: "focus-pane-up",
58+ ActionFocusPaneDown: "focus-pane-down",
59+ ActionSwitchTab1: "switch-tab-1",
60+ ActionSwitchTab2: "switch-tab-2",
61+ ActionSwitchTab3: "switch-tab-3",
62+ ActionSwitchTab4: "switch-tab-4",
63+ ActionSwitchTab5: "switch-tab-5",
64+ ActionSwitchTab6: "switch-tab-6",
65+ ActionSwitchTab7: "switch-tab-7",
66+ ActionSwitchTab8: "switch-tab-8",
67+ ActionSwitchTab9: "switch-tab-9",
68+ ActionGrowPaneWidth: "grow-pane-width",
69+ ActionShrinkPaneWidth: "shrink-pane-width",
70+ ActionGrowPaneHeight: "grow-pane-height",
71+ ActionShrinkPaneHeight: "shrink-pane-height",
72+ ActionEqualizePanes: "equalize-panes",
73+ ActionConsumeOrExpelPaneLeft: "consume-or-expel-pane-left",
74+ ActionConsumeOrExpelPaneRight: "consume-or-expel-pane-right",
7375 }
7476
7577 var actionsByName = func() map[string]Action {
M internal/usecase/keys/keyspec_test.go +2 -0
@@ -84,6 +84,8 @@ func TestActionNamesAreCanonical(t *testing.T) {
8484 {ActionGrowPaneHeight, "grow-pane-height"},
8585 {ActionShrinkPaneHeight, "shrink-pane-height"},
8686 {ActionEqualizePanes, "equalize-panes"},
87+ {ActionConsumeOrExpelPaneLeft, "consume-or-expel-pane-left"},
88+ {ActionConsumeOrExpelPaneRight, "consume-or-expel-pane-right"},
8789 }
8890
8991 for _, tc := range cases {
M internal/usecase/keys/router.go +2 -0
@@ -43,6 +43,8 @@ const (
4343 ActionGrowPaneHeight
4444 ActionShrinkPaneHeight
4545 ActionEqualizePanes
46+ ActionConsumeOrExpelPaneLeft
47+ ActionConsumeOrExpelPaneRight
4648 )
4749
4850 // 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) {
323323 }
324324 }
325325
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+
326492 func TestWeightCloneCopiesWeightIndependently(t *testing.T) {
327493 t.Parallel()
328494
M internal/usecase/ui/modal.go +65 -39
@@ -18,8 +18,7 @@ type Modal struct {
1818 FixedHeight int
1919 }
2020
21-// Bounds returns the modal rectangle positioned within base and clamped to base.
22-func (m Modal) Bounds(base domain.Size) domain.Rect {
21+func (m Modal) preferredBounds(base domain.Size) domain.Rect {
2322 width := percentOf(base.Cols, m.WidthPct)
2423 height := percentOf(base.Rows, m.HeightPct)
2524 if m.FixedWidth > 0 {
@@ -37,38 +36,41 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
3736 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)
3837 }
3938
40-// Inner returns the modal content rectangle after removing a one-cell border.
41-func (m Modal) Inner(base domain.Size) domain.Rect {
42- bounds := m.Bounds(base)
43- return domain.Rect{
44- X: bounds.X + 1,
45- Y: bounds.Y + 1,
46- Width: max(0, bounds.Width-2),
47- Height: max(0, bounds.Height-2),
39+// Resolve computes the modal's responsive presentation from one preferred
40+// bounds and inner pair.
41+func (m Modal) Resolve(base domain.Size) Presentation {
42+ bounds := m.preferredBounds(base)
43+ return ResolvePresentation(base, bounds, modalInner(bounds))
44+}
45+
46+// CompositePresentation draws an already-resolved modal presentation and
47+// returns its inner content rectangle.
48+func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {
49+ FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior})
50+ drawBorderEdges(f, p.Bounds, p.Borders, border)
51+ if p.Borders&BorderTop != 0 {
52+ m.drawTitle(f, p.Bounds, border)
53+ }
54+ return p.Inner
55+}
56+
57+func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) {
58+ if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" {
59+ return
4860 }
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)
4965 }
5066
51-// Composite draws the modal border and title, fills its interior, and returns
52-// the inner content rectangle. Border and interior styles are deliberately
53-// independent so unfocused structure and chrome surfaces retain their roles.
54-// Cells outside the modal bounds are not changed.
55-func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
56- bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
57- inner := domain.Rect{
67+func modalInner(bounds domain.Rect) domain.Rect {
68+ return domain.Rect{
5869 X: bounds.X + 1,
5970 Y: bounds.Y + 1,
6071 Width: max(0, bounds.Width-2),
6172 Height: max(0, bounds.Height-2),
6273 }
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
7274 }
7375
7476 // 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) {
8688
8789 // DrawBox draws a single-cell box clipped to the frame bounds.
8890 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 {
9096 return
9197 }
9298 left := rect.X
@@ -94,18 +100,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
94100 right := rect.X + rect.Width - 1
95101 bottom := rect.Y + rect.Height - 1
96102
97- for x := left; x <= right; x++ {
98- setCell(f, x, top, '─', style)
99- setCell(f, x, bottom, '─', style)
100- }
101- for y := top; y <= bottom; y++ {
102- setCell(f, left, y, '│', style)
103- setCell(f, right, y, '│', style)
104- }
105- setCell(f, left, top, '┌', style)
106- setCell(f, right, top, '┐', style)
107- setCell(f, left, bottom, '└', style)
108- setCell(f, right, bottom, '┘', style)
103+ if edges&BorderTop != 0 {
104+ for x := left; x <= right; x++ {
105+ setCell(f, x, top, '─', style)
106+ }
107+ }
108+ if edges&BorderBottom != 0 {
109+ for x := left; x <= right; x++ {
110+ setCell(f, x, bottom, '─', style)
111+ }
112+ }
113+ if edges&BorderLeft != 0 {
114+ for y := top; y <= bottom; y++ {
115+ setCell(f, left, y, '│', style)
116+ }
117+ }
118+ if edges&BorderRight != 0 {
119+ for y := top; y <= bottom; y++ {
120+ setCell(f, right, y, '│', style)
121+ }
122+ }
123+ if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft {
124+ setCell(f, left, top, '┌', style)
125+ }
126+ if edges&(BorderTop|BorderRight) == BorderTop|BorderRight {
127+ setCell(f, right, top, '┐', style)
128+ }
129+ if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft {
130+ setCell(f, left, bottom, '└', style)
131+ }
132+ if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight {
133+ setCell(f, right, bottom, '┘', style)
134+ }
109135 }
110136
111137 // 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 (
88 "github.com/stretchr/testify/require"
99 )
1010
11-func TestModalBounds(t *testing.T) {
11+func TestModalResolveBounds(t *testing.T) {
1212 tests := []struct {
1313 name string
1414 base domain.Size
@@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) {
2222 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
2323 },
2424 {
25- name: "minimum clamps to base",
25+ name: "minimum clamps to drawer availability",
2626 base: domain.Size{Cols: 20, Rows: 6},
2727 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},
2929 },
3030 {
31- name: "odd centering rounds down",
31+ name: "narrow preferred height becomes drawer height",
3232 base: domain.Size{Cols: 11, Rows: 7},
3333 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},
3535 },
3636 {
37- name: "zero value fills base centered",
37+ name: "zero value fills available drawer area",
3838 base: domain.Size{Cols: 12, Rows: 8},
3939 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},
4141 },
4242 {
4343 name: "bottom anchor honors bottom margin",
@@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) {
6363 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
6464 },
6565 {
66- name: "right margin clamps to screen",
66+ name: "drawer ignores right margin",
6767 base: domain.Size{Cols: 20, Rows: 10},
6868 m: Modal{
6969 FixedWidth: 8,
@@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) {
7171 Anchor: domain.AnchorRight,
7272 Margins: Margins{Right: 20},
7373 },
74- want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},
74+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
7575 },
7676 {
77- name: "right anchored fixed dimensions clamp on small terminal",
77+ name: "right anchored drawer can have zero height",
7878 base: domain.Size{Cols: 10, Rows: 4},
7979 m: Modal{
8080 FixedWidth: 30,
@@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) {
8282 Anchor: domain.AnchorRight,
8383 Margins: Margins{Right: 3},
8484 },
85- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},
85+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
8686 },
8787 {
88- name: "fixed dimensions clamp on small terminal",
88+ name: "fixed drawer dimensions clamp on small terminal",
8989 base: domain.Size{Cols: 10, Rows: 4},
9090 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},
9292 },
9393 {
94- name: "bottom margin clamps to screen",
94+ name: "drawer ignores bottom margin",
9595 base: domain.Size{Cols: 20, Rows: 10},
9696 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},
9898 },
9999 {
100100 name: "bottom right anchor honors combined margins",
@@ -110,58 +110,171 @@ func TestModalBounds(t *testing.T) {
110110 }
111111 for _, tt := range tests {
112112 t.Run(tt.name, func(t *testing.T) {
113- if got := tt.m.Bounds(tt.base); got != tt.want {
114- t.Fatalf("Bounds() = %+v, want %+v", got, tt.want)
113+ if got := tt.m.Resolve(tt.base).Bounds; got != tt.want {
114+ t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want)
115115 }
116116 })
117117 }
118118 }
119119
120-func TestModalInnerDegenerate(t *testing.T) {
121- m := Modal{WidthPct: 100, HeightPct: 100}
122- got := m.Inner(domain.Size{Cols: 2, Rows: 2})
123- want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0}
124- if got != want {
125- t.Fatalf("Inner() = %+v, want %+v", got, want)
126- }
120+func TestModalResolveInnerDegenerate(t *testing.T) {
121+ got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner
122+ require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got)
127123 }
128124
129-func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {
130- f := renderer.NewFrame(10, 6)
131- exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
132- FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)
125+func TestModalResolve(t *testing.T) {
126+ tests := []struct {
127+ name string
128+ base domain.Size
129+ want Presentation
130+ }{
131+ {
132+ name: "narrow modal resolves to drawer",
133+ base: domain.Size{Cols: 20, Rows: 10},
134+ want: Presentation{
135+ Mode: PresentationDrawer,
136+ Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
137+ Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3},
138+ Borders: BorderTop,
139+ },
140+ },
141+ {
142+ name: "complete frame preserves floating modal geometry",
143+ base: domain.Size{Cols: 80, Rows: 10},
144+ want: Presentation{
145+ Mode: PresentationFloating,
146+ Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4},
147+ Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2},
148+ Borders: BorderAll,
149+ },
150+ },
151+ }
152+
153+ modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ require.Equal(t, tt.want, modal.Resolve(tt.base))
157+ })
158+ }
159+}
133160
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)
134167 border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
135168 interior := renderer.Style{Foreground: 3, Background: 4}
136- m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}
137- inner := m.Composite(f, border, interior)
138- wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}
139- if inner != wantInner {
140- t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)
169+
170+ inner := modal.CompositePresentation(frame, presentation, border, interior)
171+ require.Equal(t, presentation.Inner, inner)
172+ require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune)
173+ require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune)
174+ require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
175+ require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
176+ require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune)
177+ require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior))
178+ assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior)
179+}
180+
181+func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) {
182+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
183+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
184+ bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4}
185+ inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2}
186+
187+ tests := []struct {
188+ name string
189+ presentation Presentation
190+ assert func(*testing.T, renderer.Frame)
191+ }{
192+ {
193+ name: "all edges render for drawer mode",
194+ presentation: Presentation{
195+ Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll,
196+ },
197+ assert: func(t *testing.T, frame renderer.Frame) {
198+ require.Equal(t, '┌', frame.At(1, 1).Rune)
199+ require.Equal(t, '│', frame.At(1, 2).Rune)
200+ require.Equal(t, '┘', frame.At(8, 4).Rune)
201+ require.Equal(t, 'T', frame.At(2, 1).Rune)
202+ },
203+ },
204+ {
205+ name: "top edge alone renders for floating mode",
206+ presentation: Presentation{
207+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop,
208+ },
209+ assert: func(t *testing.T, frame renderer.Frame) {
210+ require.Equal(t, '─', frame.At(1, 1).Rune)
211+ require.Equal(t, 'T', frame.At(2, 1).Rune)
212+ assertCell(t, frame, 1, 2, exterior)
213+ assertCell(t, frame, 8, 4, exterior)
214+ },
215+ },
216+ {
217+ name: "title is hidden without top edge",
218+ presentation: Presentation{
219+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft,
220+ },
221+ assert: func(t *testing.T, frame renderer.Frame) {
222+ require.Equal(t, '│', frame.At(1, 1).Rune)
223+ require.Equal(t, '│', frame.At(1, 4).Rune)
224+ assertCell(t, frame, 4, 1, exterior)
225+ assertCell(t, frame, 8, 4, exterior)
226+ },
227+ },
141228 }
142229
143- assertRune(t, f, 2, 1, '┌')
144- assertRune(t, f, 7, 1, '┐')
145- assertRune(t, f, 2, 4, '└')
146- assertRune(t, f, 7, 4, '┘')
147- assertRune(t, f, 3, 1, '─')
148- assertRune(t, f, 2, 2, '│')
149- assertRune(t, f, 4, 1, 'H')
150- assertRune(t, f, 5, 1, 'i')
151-
152- for y := inner.Y; y < inner.Y+inner.Height; y++ {
153- for x := inner.X; x < inner.X+inner.Width; x++ {
154- want := renderer.Cell{Rune: ' ', Style: interior}
155- if got := f.At(x, y); !got.Equal(want) {
156- t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want)
157- }
158- }
230+ for _, tt := range tests {
231+ t.Run(tt.name, func(t *testing.T) {
232+ frame := renderer.NewFrame(10, 6)
233+ FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior)
234+ (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle())
235+ tt.assert(t, frame)
236+ })
159237 }
160- if got := f.At(2, 1).Style; !got.Equal(border) {
161- t.Fatalf("border style = %+v, want %+v", got, border)
238+}
239+
240+func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) {
241+ modal := Modal{FixedHeight: 4, Title: " Rename "}
242+ presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10})
243+ frame := renderer.NewFrame(20, 10)
244+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
245+ FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior)
246+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
247+ interior := renderer.Style{Foreground: 3, Background: 4}
248+
249+ inner := modal.CompositePresentation(frame, presentation, border, interior)
250+ require.Equal(t, presentation.Inner, inner)
251+ require.Equal(t, '─', frame.At(0, 5).Rune)
252+ require.True(t, frame.At(0, 5).Style.Equal(border))
253+ require.Equal(t, 'R', frame.At(7, 5).Rune)
254+ require.NotEqual(t, '│', frame.At(0, 6).Rune)
255+ require.Equal(t, ' ', frame.At(0, 6).Rune)
256+ require.NotEqual(t, '─', frame.At(0, 8).Rune)
257+ require.Equal(t, ' ', frame.At(0, 8).Rune)
258+
259+ for _, y := range []int{0, 1, 2, 3, 4, 9} {
260+ for x := range frame.Width {
261+ assertCell(t, frame, x, y, exterior)
262+ }
162263 }
163- assertCell(t, f, 0, 0, exterior)
164- assertCell(t, f, 9, 5, exterior)
264+}
265+
266+func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) {
267+ modal := Modal{FixedHeight: 4, Title: "你你"}
268+ presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10})
269+ frame := renderer.NewFrame(4, 10)
270+ border := renderer.Style{Foreground: 2, Background: -1}
271+
272+ modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle())
273+
274+ require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune)
275+ require.True(t, frame.At(2, presentation.Bounds.Y).Continuation)
276+ require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune)
277+ require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip")
165278 }
166279
167280 func TestFillRectClips(t *testing.T) {
@@ -221,26 +334,19 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
221334 }
222335 }
223336
224-func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) {
337+func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) {
225338 border := renderer.Style{Foreground: 1, Background: 2}
226339 interior := renderer.Style{Foreground: 3, Background: 4}
227- for _, tt := range []struct {
228- name string
229- size domain.Size
230- want domain.Rect
231- }{
232- {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}},
233- {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}},
234- } {
235- t.Run(tt.name, func(t *testing.T) {
236- frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows)
237- got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior)
238- require.Equal(t, tt.want, got)
239- for y := range frame.Height {
240- for x := range frame.Width {
241- require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds")
242- }
243- }
244- })
340+ frame := renderer.NewFrame(2, 2)
341+ modal := Modal{WidthPct: 100, HeightPct: 100}
342+ presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2})
343+
344+ got := modal.CompositePresentation(frame, presentation, border, interior)
345+
346+ require.Equal(t, presentation.Inner, got)
347+ for y := range frame.Height {
348+ for x := range frame.Width {
349+ require.Equal(t, renderer.BlankCell(), frame.At(x, y))
350+ }
245351 }
246352 }
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 12

Tags 11

↑↓ moveenter openesc close