detached 43eb2f4

Merge pull request #142 from bnema/feat/responsive-overlay-drawers

feat(ui): add responsive overlay drawers
commit
43eb2f4d38bc0e65b2f7f81ff0696809cd1cbd17
author
bnema · · 2026-07-28 01:24 UTC
committer
GitHub · · 2026-07-28 01:24 UTC
merge of
832e1eb + 7ccd74f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
26 files · +1712 -369
M docs/configuration.md +8 -2
@@ -129,13 +129,19 @@ copy.word-separators = " -_@"
129129
130130 Set `copy.word-separators = ""` to use only Unicode whitespace as a separator.
131131
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+
132138 ## Palette anchor
133139
134-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.
135141
136142 ## Floating terminal
137143
138-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.
139145
140146 ## Bar anchors
141147
@@ -129,13 +129,19 @@ copy.word-separators = " -_@"
129 129
130 Set `copy.word-separators = ""` to use only Unicode whitespace as a separator.130 Set `copy.word-separators = ""` to use only Unicode whitespace as a separator.
131 131
132+## Responsive overlays
133+
134+On complete frames below 80 columns, interactive overlays use full-width bottom drawers immediately above the bottom bar. This applies to the floating terminal, command palette, session picker, notification history, prompts, and copy search. A drawer keeps its overlay's preferred outer height, capped at the frame height minus four rows, so frame rows 0–2 remain visible. Drawers use only a top border.
135+
136+Opening an interactive overlay dims the complete underlying frame, including panes, both bars, notices, and any lower-priority overlay. Notice toasts remain compact rather than becoming drawers. Copy mode remains full-screen; only its search prompt uses the responsive drawer.
137+
132 ## Palette anchor138 ## Palette anchor
133 139
134-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.
135 141
136 ## Floating terminal142 ## Floating terminal
137 143
138-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.
139 145
140 ## Bar anchors146 ## Bar anchors
141 147
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 }
@@ -100,7 +100,7 @@ func (t *ownedSwapErrorTransport) Close() error { return nil }
100 func TestCopySearchModalGeometry(t *testing.T) {100 func TestCopySearchModalGeometry(t *testing.T) {
101 base := domain.Size{Cols: 100, Rows: 40}101 base := domain.Size{Cols: 100, Rows: 40}
102 102
103- require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Bounds(base))103+ require.Equal(t, domain.Rect{X: 0, Y: 28, Width: 100, Height: 11}, copySearchModal.Resolve(base).Bounds)
104 require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)104 require.Equal(t, domain.AnchorBottom, copySearchModal.Anchor)
105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)105 require.Equal(t, ui.Margins{Bottom: 1}, copySearchModal.Margins)
106 }106 }
M internal/usecase/daemon/floating.go +3 -7
@@ -136,7 +136,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane,
136136 p.mu.Lock()
137137 geometry := p.committedFloatingGeometryLocked(desired)
138138 p.mu.Unlock()
139- if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
139+ if !geometry.committable() {
140140 return nil, floatingGeometry{}, false
141141 }
142142 return p, geometry, true
@@ -316,11 +316,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
316316 sess.mu.Unlock()
317317 tb.mu.Lock()
318318 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)
319- size := rectSize(geometry.Inner)
320- if !size.Valid() {
321- geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}}
322- size = rectSize(geometry.Inner)
323- }
319+ size := rectSize(geometry.ptyRect())
324320 focused := tb.focusedPane()
325321 tabStableID, tabCtx := tb.stableID, tb.ctx
326322 tb.mu.Unlock()
@@ -385,7 +381,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
385381 return
386382 }
387383 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
388- p.rect = spec.geometry.Inner
384+ p.rect = spec.geometry.ptyRect()
389385 p.popupGeometry = spec.geometry
390386 p.title.displayFallback = spec.fallback
391387 // CommandContext owns the child through openCtx, so retain both this context
@@ -136,7 +136,7 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane,
136 p.mu.Lock()136 p.mu.Lock()
137 geometry := p.committedFloatingGeometryLocked(desired)137 geometry := p.committedFloatingGeometryLocked(desired)
138 p.mu.Unlock()138 p.mu.Unlock()
139- if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {139+ if !geometry.committable() {
140 return nil, floatingGeometry{}, false140 return nil, floatingGeometry{}, false
141 }141 }
142 return p, geometry, true142 return p, geometry, true
@@ -316,11 +316,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati
316 sess.mu.Unlock()316 sess.mu.Unlock()
317 tb.mu.Lock()317 tb.mu.Lock()
318 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)318 geometry := calculateContentFloatingGeometry(domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows}, cfg)
319- size := rectSize(geometry.Inner)319+ size := rectSize(geometry.ptyRect())
320- if !size.Valid() {
321- geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}}
322- size = rectSize(geometry.Inner)
323- }
324 focused := tb.focusedPane()320 focused := tb.focusedPane()
325 tabStableID, tabCtx := tb.stableID, tb.ctx321 tabStableID, tabCtx := tb.stableID, tb.ctx
326 tb.mu.Unlock()322 tb.mu.Unlock()
@@ -385,7 +381,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau
385 return381 return
386 }382 }
387 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)383 p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size)
388- p.rect = spec.geometry.Inner384+ p.rect = spec.geometry.ptyRect()
389 p.popupGeometry = spec.geometry385 p.popupGeometry = spec.geometry
390 p.title.displayFallback = spec.fallback386 p.title.displayFallback = spec.fallback
391 // CommandContext owns the child through openCtx, so retain both this context387 // CommandContext owns the child through openCtx, so retain both this context
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 }
@@ -13,6 +13,7 @@ import (
13 // translates both fields and returns/caches frame-absolute geometry. Callers13 // translates both fields and returns/caches frame-absolute geometry. Callers
14 // must not translate a composed or cached value again.14 // must not translate a composed or cached value again.
15 type floatingGeometry struct {15 type floatingGeometry struct {
16+ Mode ui.PresentationMode
16 Bounds domain.Rect17 Bounds domain.Rect
17 Inner domain.Rect18 Inner domain.Rect
18 }19 }
@@ -21,6 +22,22 @@ func (g floatingGeometry) valid() bool {
21 return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 022 return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0
22 }23 }
23 24
25+// committable accepts a drawer with no terminal content. Its presentation must
26+// remain canonical even though the PTY and VT screen use a 1x1 fallback.
27+func (g floatingGeometry) committable() bool {
28+ if g.Mode == ui.PresentationDrawer {
29+ return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0
30+ }
31+ return g.valid()
32+}
33+
34+func (g floatingGeometry) ptyRect() domain.Rect {
35+ if g.Inner.Width > 0 && g.Inner.Height > 0 {
36+ return g.Inner
37+ }
38+ return domain.Rect{Width: 1, Height: 1}
39+}
40+
24 func (g floatingGeometry) translate(dx, dy int) floatingGeometry {41 func (g floatingGeometry) translate(dx, dy int) floatingGeometry {
25 g.Bounds.X += dx42 g.Bounds.X += dx
26 g.Bounds.Y += dy43 g.Bounds.Y += dy
@@ -33,7 +50,7 @@ func (g floatingGeometry) translate(dx, dy int) floatingGeometry {
33 // last committed. Newly constructed test panes fall back to the requested50 // last committed. Newly constructed test panes fall back to the requested
34 // geometry until their first resize. The caller holds p.mu.51 // geometry until their first resize. The caller holds p.mu.
35 func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {52 func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry {
36- if p.popupGeometry.valid() {53+ if p.popupGeometry.committable() {
37 return p.popupGeometry54 return p.popupGeometry
38 }55 }
39 return fallback56 return fallback
@@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry
58 return axis75 return axis
59 }76 }
60 77
61-// calculateContentFloatingGeometry returns origin-zero, tab-content-relative78+// calculatePreferredFloatingGeometry retains the canonical percentage and
62-// coordinates for a percentage-sized popup. Launch sizing derives from Inner79+// tiny-axis rules used by wide floating terminals.
63-// too, so rendering and PTY geometry share the same per-axis percentage and80+func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
64-// tiny-border rules.
65-func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
66 if !content.Valid() {81 if !content.Valid() {
67 return floatingGeometry{}82 return floatingGeometry{}
68 }83 }
@@ -75,6 +90,7 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
75 Height: y.BoundsSize,90 Height: y.BoundsSize,
76 }91 }
77 return floatingGeometry{92 return floatingGeometry{
93+ Mode: ui.PresentationFloating,
78 Bounds: bounds,94 Bounds: bounds,
79 Inner: domain.Rect{95 Inner: domain.Rect{
80 X: bounds.X + x.BorderOffset,96 X: bounds.X + x.BorderOffset,
@@ -85,12 +101,33 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
85 }101 }
86 }102 }
87 103
104+// calculateContentFloatingGeometry returns canonical tab-content-relative
105+// coordinates. Presentation resolution consumes complete-frame coordinates,
106+// so the preferred geometry crosses the top-bar boundary exactly once in each
107+// direction.
108+func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry {
109+ preferred := calculatePreferredFloatingGeometry(content, cfg)
110+ if !preferred.valid() {
111+ return floatingGeometry{}
112+ }
113+ framePreferred := preferred.translate(0, 1)
114+ presentation := ui.ResolvePresentation(
115+ domain.Size{Cols: content.Cols, Rows: content.Rows + 2},
116+ framePreferred.Bounds,
117+ framePreferred.Inner,
118+ )
119+ return floatingGeometry{
120+ Mode: presentation.Mode,
121+ Bounds: presentation.Bounds,
122+ Inner: presentation.Inner,
123+ }.translate(0, -1)
124+}
125+
88 type floatingComposeInput struct {126 type floatingComposeInput struct {
89 baseFrame renderer.Frame127 baseFrame renderer.Frame
90 baseDamage []renderer.Damage128 baseDamage []renderer.Damage
91 floating capturedFloatingRenderState129 floating capturedFloatingRenderState
92 content domain.Rect130 content domain.Rect
93- layout capturedTabLayout
94 theme themeui.Theme131 theme themeui.Theme
95 borderMuted renderer.Style132 borderMuted renderer.Style
96 borderActive renderer.Style133 borderActive renderer.Style
@@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
103 baseDamage := input.baseDamage140 baseDamage := input.baseDamage
104 floating := input.floating141 floating := input.floating
105 content := input.content142 content := input.content
106- layoutSnap := input.layout
107 theme := input.theme143 theme := input.theme
108 borderMuted := input.borderMuted144 borderMuted := input.borderMuted
109 borderActive := input.borderActive145 borderActive := input.borderActive
@@ -111,10 +147,9 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
111 full := input.full147 full := input.full
112 148
113 frame := base.Clone()149 frame := base.Clone()
114- layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}150+ applyOverlayBackdrop(frame, theme)
115- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme)
116 geometry := floating.geometry.translate(content.X, content.Y)151 geometry := floating.geometry.translate(content.X, content.Y)
117- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))152+ blitClippedFloatingPane(frame, geometry.Inner, floating.pane.frame)
118 damage := append([]renderer.Damage(nil), baseDamage...)153 damage := append([]renderer.Damage(nil), baseDamage...)
119 for _, d := range floating.pane.damage {154 for _, d := range floating.pane.damage {
120 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)155 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
@@ -125,39 +160,168 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [
125 if floating.focused {160 if floating.focused {
126 border = borderActive161 border = borderActive
127 }162 }
128- drawFloatingBorder(frame, geometry.Bounds, floating.title, border)163+ drawFloatingBorder(frame, geometry, floating.title, border)
129 if full || popupChanged {164 if full || popupChanged {
130 return frame, []renderer.Damage{renderer.FullRedraw()}165 return frame, []renderer.Damage{renderer.FullRedraw()}
131 }166 }
132- if titleChanged && geometry.Bounds.Height >= 3 {167+ hasTitleBorder := geometry.Bounds.Height >= 3
133- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})168+ if geometry.Mode == ui.PresentationDrawer {
169+ hasTitleBorder = geometry.Bounds.Height > 0
170+ }
171+ if titleChanged && hasTitleBorder {
172+ if titleDamage, ok := clippedFloatingTitleDamage(frame, geometry.Bounds); ok {
173+ damage = append(damage, titleDamage)
174+ }
134 }175 }
135 return frame, damage176 return frame, damage
136 }177 }
137 178
138-func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {179+type floatingSpan struct {
180+ source, destination, length int
181+}
182+
183+// clippedFloatingSpan intersects a source span placed at origin with the
184+// destination interval [0, limit). The source offset preserves pane cell
185+// mapping when the retained committed geometry starts above or left of frame.
186+func clippedFloatingSpan(origin, length, limit int) (floatingSpan, bool) {
187+ if length <= 0 || limit <= 0 {
188+ return floatingSpan{}, false
189+ }
190+ if origin < 0 {
191+ if origin <= -length {
192+ return floatingSpan{}, false
193+ }
194+ trim := -origin
195+ length -= trim
196+ origin = 0
197+ if length <= 0 {
198+ return floatingSpan{}, false
199+ }
200+ return floatingSpan{source: trim, destination: origin, length: min(length, limit)}, true
201+ }
202+ if origin >= limit {
203+ return floatingSpan{}, false
204+ }
205+ length = min(length, limit-origin)
206+ return floatingSpan{destination: origin, length: length}, length > 0
207+}
208+
209+func blitClippedFloatingPane(dst renderer.Frame, rect domain.Rect, src renderer.Frame) {
210+ x, ok := clippedFloatingSpan(rect.X, min(rect.Width, src.Width), dst.Width)
211+ if !ok {
212+ return
213+ }
214+ y, ok := clippedFloatingSpan(rect.Y, min(rect.Height, src.Height), dst.Height)
215+ if !ok {
216+ return
217+ }
218+ for dy := range y.length {
219+ for dx := range x.length {
220+ dst.Set(x.destination+dx, y.destination+dy, src.At(x.source+dx, y.source+dy))
221+ }
222+ }
223+}
224+
225+func clippedFloatingTitleDamage(frame renderer.Frame, bounds domain.Rect) (renderer.Damage, bool) {
226+ if bounds.Y < 0 || bounds.Y >= frame.Height {
227+ return renderer.Damage{}, false
228+ }
229+ x, ok := clippedFloatingSpan(bounds.X, bounds.Width, frame.Width)
230+ if !ok {
231+ return renderer.Damage{}, false
232+ }
233+ return renderer.Damage{Kind: renderer.DamageText, X: x.destination, Y: bounds.Y, Width: x.length, Height: 1}, true
234+}
235+
236+func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
237+ bounds := geometry.Bounds
139 if bounds.Width <= 0 || bounds.Height <= 0 {238 if bounds.Width <= 0 || bounds.Height <= 0 {
140 return239 return
141 }240 }
241+ if geometry.Mode == ui.PresentationDrawer {
242+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
243+ drawFloatingTitle(frame, bounds, title, style)
244+ return
245+ }
142 // Each axis omits its borders independently for tiny popups, matching246 // Each axis omits its borders independently for tiny popups, matching
143 // floatingInnerSize and leaving every cell available to the terminal.247 // floatingInnerSize and leaving every cell available to the terminal.
248+ right, hasRight := floatingLastCoordinate(bounds.X, bounds.Width)
249+ bottom, hasBottom := floatingLastCoordinate(bounds.Y, bounds.Height)
144 if bounds.Height >= 3 {250 if bounds.Height >= 3 {
145- for x := bounds.X; x < bounds.X+bounds.Width; x++ {251+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bounds.Y, '─', style)
146- frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})252+ if hasBottom {
147- frame.Set(x, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '─', Style: style})253+ drawFloatingHorizontalEdge(frame, bounds.X, bounds.Width, bottom, '─', style)
148 }254 }
149 }255 }
150 if bounds.Width >= 3 {256 if bounds.Width >= 3 {
151- for y := bounds.Y; y < bounds.Y+bounds.Height; y++ {257+ drawFloatingVerticalEdge(frame, bounds.X, bounds.Y, bounds.Height, '│', style)
152- frame.Set(bounds.X, y, renderer.Cell{Rune: '│', Style: style})258+ if hasRight {
153- frame.Set(bounds.X+bounds.Width-1, y, renderer.Cell{Rune: '│', Style: style})259+ drawFloatingVerticalEdge(frame, right, bounds.Y, bounds.Height, '│', style)
154 }260 }
155 }261 }
156- if bounds.Width >= 3 && bounds.Height >= 3 {262+ if bounds.Width >= 3 && bounds.Height >= 3 && hasRight && hasBottom {
157- frame.Set(bounds.X, bounds.Y, renderer.Cell{Rune: '┌', Style: style})263+ setFloatingCell(frame, bounds.X, bounds.Y, '┌', style)
158- frame.Set(bounds.X+bounds.Width-1, bounds.Y, renderer.Cell{Rune: '┐', Style: style})264+ setFloatingCell(frame, right, bounds.Y, '┐', style)
159- frame.Set(bounds.X, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '└', Style: style})265+ setFloatingCell(frame, bounds.X, bottom, '└', style)
160- frame.Set(bounds.X+bounds.Width-1, bounds.Y+bounds.Height-1, renderer.Cell{Rune: '┘', Style: style})266+ setFloatingCell(frame, right, bottom, '┘', style)
161- ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)267+ drawFloatingTitle(frame, bounds, title, style)
268+ }
269+}
270+
271+func drawFloatingHorizontalEdge(frame renderer.Frame, x, width, y int, glyph rune, style renderer.Style) {
272+ if y < 0 || y >= frame.Height {
273+ return
274+ }
275+ span, ok := clippedFloatingSpan(x, width, frame.Width)
276+ if !ok {
277+ return
278+ }
279+ for dx := range span.length {
280+ frame.Set(span.destination+dx, y, renderer.Cell{Rune: glyph, Style: style})
281+ }
282+}
283+
284+func drawFloatingVerticalEdge(frame renderer.Frame, x, y, height int, glyph rune, style renderer.Style) {
285+ if x < 0 || x >= frame.Width {
286+ return
287+ }
288+ span, ok := clippedFloatingSpan(y, height, frame.Height)
289+ if !ok {
290+ return
291+ }
292+ for dy := range span.length {
293+ frame.Set(x, span.destination+dy, renderer.Cell{Rune: glyph, Style: style})
294+ }
295+}
296+
297+func setFloatingCell(frame renderer.Frame, x, y int, glyph rune, style renderer.Style) {
298+ if x < 0 || x >= frame.Width || y < 0 || y >= frame.Height {
299+ return
300+ }
301+ frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
302+}
303+
304+func floatingLastCoordinate(origin, size int) (int, bool) {
305+ if size <= 0 {
306+ return 0, false
307+ }
308+ last := origin + size - 1
309+ if last < origin {
310+ return 0, false
311+ }
312+ return last, true
313+}
314+
315+func drawFloatingTitle(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
316+ const inset = 2
317+ titleWidth := bounds.Width - 2*inset
318+ if titleWidth <= 0 || bounds.X > int(^uint(0)>>1)-inset {
319+ return
320+ }
321+ titleX := bounds.X + inset
322+ span, ok := clippedFloatingSpan(titleX, titleWidth, frame.Width)
323+ if !ok {
324+ return
162 }325 }
326+ ui.DrawText(frame, titleX, bounds.Y, span.destination+span.length, title, style)
163 }327 }
M internal/usecase/daemon/floating_render_test.go +284 -13
@@ -12,6 +12,7 @@ import (
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
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/usecase/layout"13 "github.com/bnema/vev/internal/usecase/layout"
14 themeui "github.com/bnema/vev/internal/usecase/theme"14 themeui "github.com/bnema/vev/internal/usecase/theme"
15+ "github.com/bnema/vev/internal/usecase/ui"
15 "github.com/bnema/vev/pkg/renderer"16 "github.com/bnema/vev/pkg/renderer"
16 )17 )
17 18
@@ -39,21 +40,66 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr
39 40
40 func TestFloatingGeometryTranslate(t *testing.T) {41 func TestFloatingGeometryTranslate(t *testing.T) {
41 geometry := floatingGeometry{42 geometry := floatingGeometry{
43+ Mode: ui.PresentationDrawer,
42 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},44 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
43 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},45 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},
44 }46 }
45 47
46 translated := geometry.translate(11, 13)48 translated := geometry.translate(11, 13)
47 require.Equal(t, floatingGeometry{49 require.Equal(t, floatingGeometry{
50+ Mode: ui.PresentationDrawer,
48 Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8},51 Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8},
49 Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6},52 Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6},
50 }, translated)53 }, translated)
51 require.Equal(t, floatingGeometry{54 require.Equal(t, floatingGeometry{
55+ Mode: ui.PresentationDrawer,
52 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},56 Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8},
53 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},57 Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6},
54 }, geometry)58 }, geometry)
55 }59 }
56 60
61+func TestResponsiveFloatingGeometry(t *testing.T) {
62+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
63+ tests := []struct {
64+ name string
65+ content domain.Size
66+ want floatingGeometry
67+ }{
68+ {
69+ name: "narrow full height becomes content-relative drawer",
70+ content: domain.Size{Cols: 79, Rows: 22},
71+ want: floatingGeometry{
72+ Mode: ui.PresentationDrawer,
73+ Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 20},
74+ Inner: domain.Rect{X: 0, Y: 3, Width: 79, Height: 19},
75+ },
76+ },
77+ {
78+ name: "breakpoint preserves centered floating geometry",
79+ content: domain.Size{Cols: 80, Rows: 22},
80+ want: floatingGeometry{
81+ Mode: ui.PresentationFloating,
82+ Bounds: domain.Rect{Width: 80, Height: 22},
83+ Inner: domain.Rect{X: 1, Y: 1, Width: 78, Height: 20},
84+ },
85+ },
86+ {
87+ name: "tiny narrow frame retains zero-content drawer",
88+ content: domain.Size{Cols: 79, Rows: 2},
89+ want: floatingGeometry{
90+ Mode: ui.PresentationDrawer,
91+ Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0},
92+ Inner: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0},
93+ },
94+ },
95+ }
96+ for _, tt := range tests {
97+ t.Run(tt.name, func(t *testing.T) {
98+ require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, cfg))
99+ })
100+ }
101+}
102+
57 func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {103 func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {
58 const frameX, frameY = 7, 3104 const frameX, frameY = 7, 3
59 tests := []struct {105 tests := []struct {
@@ -93,26 +139,77 @@ func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) {
93 }139 }
94 }140 }
95 141
96-func TestCalculateFloatingGeometry(t *testing.T) {142+func TestCalculatePreferredFloatingGeometry(t *testing.T) {
97 tests := []struct {143 tests := []struct {
98 name string144 name string
99 content domain.Size145 content domain.Size
100 cfg domain.FloatingConfig146 cfg domain.FloatingConfig
101 want floatingGeometry147 want floatingGeometry
102 }{148 }{
103- {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}},149+ {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}},
104- {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}},150+ {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}},
105- {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}},151+ {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}},
106- {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}},152+ {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}},
107- {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},153+ {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}},
108 }154 }
109 for _, tt := range tests {155 for _, tt := range tests {
110 t.Run(tt.name, func(t *testing.T) {156 t.Run(tt.name, func(t *testing.T) {
111- require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, tt.cfg))157+ require.Equal(t, tt.want, calculatePreferredFloatingGeometry(tt.content, tt.cfg))
112 })158 })
113 }159 }
114 }160 }
115 161
162+func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testing.T) {
163+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
164+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)
165+ require.False(t, geometry.valid())
166+ require.True(t, geometry.committable())
167+ require.Equal(t, domain.Rect{Width: 1, Height: 1}, geometry.ptyRect())
168+
169+ d := newTestDaemon(t, nil, stubClock{})
170+ d.ApplyConfig(domain.Config{Floating: cfg})
171+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
172+ sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()}
173+ spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true)
174+ require.NoError(t, err)
175+ require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation")
176+ require.Equal(t, domain.Size{Cols: 1, Rows: 1}, spec.size)
177+
178+ floating := newPane("floating", nil, spec.size)
179+ floating.popupGeometry = spec.geometry
180+ installTestFloating(tb, floating, true)
181+ tb.mu.Lock()
182+ gotPane, gotGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
183+ require.Same(t, floating, tb.terminalTargetLocked())
184+ tb.mu.Unlock()
185+ require.True(t, visible)
186+ require.Same(t, floating, gotPane)
187+ require.Equal(t, geometry, gotGeometry)
188+}
189+
190+func TestResponsiveFloatingResizeCommitsZeroContentDrawerWithoutPhysicalResize(t *testing.T) {
191+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
192+ initial := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
193+ requested := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg)
194+ pty := &resizePTY{}
195+ floating := newPane("floating", pty, rectSize(initial.Inner))
196+ floating.rect = initial.Inner
197+ floating.popupGeometry = initial
198+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
199+ installTestFloating(tb, floating, true)
200+ d := newTestDaemon(t, nil, stubClock{})
201+ d.ApplyConfig(domain.Config{Floating: cfg})
202+
203+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
204+ require.True(t, ok)
205+ require.Empty(t, failed)
206+ require.Empty(t, pty.sizes(), "a zero-inner drawer must not issue a synthetic PTY resize")
207+ require.Equal(t, requested, floating.popupGeometry)
208+ require.Equal(t, initial.Inner, floating.rect, "physical geometry remains at the last usable size")
209+ require.Equal(t, initial.Inner.Width, floating.screen.Frame.Width)
210+ require.Equal(t, initial.Inner.Height, floating.screen.Frame.Height)
211+}
212+
116 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {213 func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
117 tests := []struct {214 tests := []struct {
118 name string215 name string
@@ -133,9 +230,9 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
133 }230 }
134 231
135 func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {232 func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
136- base := renderer.NewFrame(40, 12)233+ base := renderer.NewFrame(80, 12)
137 base.Set(2, 2, renderer.Cell{Rune: 'B'})234 base.Set(2, 2, renderer.Cell{Rune: 'B'})
138- content := domain.Rect{Y: 1, Width: 40, Height: 10}235+ content := domain.Rect{Y: 1, Width: 80, Height: 10}
139 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})236 geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
140 paneFrame := renderer.NewFrame(6, 3)237 paneFrame := renderer.NewFrame(6, 3)
141 paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})238 paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})
@@ -181,6 +278,138 @@ func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testi
181 require.Equal(t, 1, damage[0].Height)278 require.Equal(t, 1, damage[0].Height)
182 }279 }
183 280
281+func TestComposeCapturedFloatingFrameClipsRetainedGeometryAfterFailedShrink(t *testing.T) {
282+ base := renderer.NewFrame(15, 7)
283+ for y := range base.Height {
284+ for x := range base.Width {
285+ base.Set(x, y, renderer.Cell{Rune: '·'})
286+ }
287+ }
288+ content := domain.Rect{Y: 1, Width: 15, Height: 5}
289+ retained := calculateContentFloatingGeometry(
290+ domain.Size{Cols: 100, Rows: 30},
291+ domain.FloatingConfig{Width: 80, Height: 80},
292+ )
293+ pty := &resizePTY{err: errors.New("resize failed")}
294+ floatingPane := newPane("floating", pty, rectSize(retained.Inner))
295+ floatingPane.rect = retained.Inner
296+ floatingPane.popupGeometry = retained
297+ for y := range floatingPane.screen.Frame.Height {
298+ for x := range floatingPane.screen.Frame.Width {
299+ floatingPane.screen.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*10 + x)})
300+ }
301+ }
302+ requested := calculateContentFloatingGeometry(rectSize(content), domain.FloatingConfig{Width: 80, Height: 80})
303+ d := newTestDaemon(t, nil, stubClock{})
304+ require.False(t, applyFloatingResizePlanForTest(d, floatingPane, requested))
305+ require.Equal(t, retained, floatingPane.popupGeometry, "failed resize must retain the committed geometry")
306+ floatingPane.mu.Lock()
307+ captured := capturePaneRenderStateLocked(floatingPane, retained.Inner)
308+ floatingPane.mu.Unlock()
309+
310+ var frame renderer.Frame
311+ require.NotPanics(t, func() {
312+ frame, _ = composeCapturedFloatingFrame(floatingComposeInput{
313+ baseFrame: base,
314+ floating: capturedFloatingRenderState{
315+ visible: true,
316+ pane: captured,
317+ geometry: retained,
318+ generation: 1,
319+ },
320+ content: content,
321+ full: true,
322+ })
323+ })
324+
325+ for y := range 4 {
326+ require.Equal(t, "···············", rowText(frame.Row(y)), "preserved frame row %d must remain outside the retained popup", y)
327+ }
328+ require.Equal(t, "··········┌────", rowText(frame.Row(4)), "only the visible top-border intersection is painted")
329+ require.Equal(t, "··········│abcd", rowText(frame.Row(5)), "visible pane cells retain their source coordinates")
330+ require.Equal(t, "··········│klmn", rowText(frame.Row(6)), "bottom clipping must not shift the retained source")
331+}
332+
333+func TestComposeCapturedFloatingFrameOffsetsSourceForClippedTopLeft(t *testing.T) {
334+ base := renderer.NewFrame(4, 4)
335+ pane := renderer.NewFrame(5, 5)
336+ for y, text := range []string{"ABCDE", "FGHIJ", "KLMNO", "PQRST", "UVWXY"} {
337+ for x, r := range text {
338+ pane.Set(x, y, renderer.Cell{Rune: r})
339+ }
340+ }
341+ geometry := floatingGeometry{
342+ Mode: ui.PresentationFloating,
343+ Bounds: domain.Rect{X: -2, Y: -2, Width: 7, Height: 7},
344+ Inner: domain.Rect{X: -1, Y: -1, Width: 5, Height: 5},
345+ }
346+
347+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
348+ baseFrame: base,
349+ floating: capturedFloatingRenderState{
350+ visible: true,
351+ pane: capturedPaneRenderState{frame: pane},
352+ geometry: geometry,
353+ generation: 1,
354+ },
355+ full: true,
356+ })
357+
358+ require.Equal(t, []string{"GHIJ", "LMNO", "QRST", "VWXY"}, []string{
359+ rowText(frame.Row(0)), rowText(frame.Row(1)), rowText(frame.Row(2)), rowText(frame.Row(3)),
360+ })
361+}
362+
363+func TestDrawFloatingBorderClipsEveryEdge(t *testing.T) {
364+ tests := []struct {
365+ name string
366+ geometry floatingGeometry
367+ want []string
368+ }{
369+ {
370+ name: "negative top and left retain bottom and right edges",
371+ geometry: floatingGeometry{
372+ Mode: ui.PresentationFloating,
373+ Bounds: domain.Rect{X: -2, Y: -1, Width: 6, Height: 5},
374+ },
375+ want: []string{"···│", "···│", "···│", "───┘"},
376+ },
377+ {
378+ name: "overflowing bottom and right retain top and left edges",
379+ geometry: floatingGeometry{
380+ Mode: ui.PresentationFloating,
381+ Bounds: domain.Rect{X: 2, Y: 2, Width: 5, Height: 4},
382+ },
383+ want: []string{"····", "····", "··┌─", "··│·"},
384+ },
385+ {
386+ name: "drawer clips its separator",
387+ geometry: floatingGeometry{
388+ Mode: ui.PresentationDrawer,
389+ Bounds: domain.Rect{X: -2, Y: 2, Width: 6, Height: 3},
390+ },
391+ want: []string{"····", "····", "xy──", "····"},
392+ },
393+ }
394+ for _, tt := range tests {
395+ t.Run(tt.name, func(t *testing.T) {
396+ frame := renderer.NewFrame(4, 4)
397+ for y := range frame.Height {
398+ for x := range frame.Width {
399+ frame.Set(x, y, renderer.Cell{Rune: '·'})
400+ }
401+ }
402+
403+ require.NotPanics(t, func() {
404+ drawFloatingBorder(frame, tt.geometry, "xy", renderer.Style{})
405+ })
406+ for y, want := range tt.want {
407+ require.Equal(t, want, rowText(frame.Row(y)), "row %d", y)
408+ }
409+ })
410+ }
411+}
412+
184 func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {413 func TestComposeCapturedFloatingFrameUsesSemanticBorderWithoutTintingPaneCells(t *testing.T) {
185 base := renderer.NewFrame(20, 9)414 base := renderer.NewFrame(20, 9)
186 content := domain.Rect{Y: 1, Width: 20, Height: 7}415 content := domain.Rect{Y: 1, Width: 20, Height: 7}
@@ -401,12 +630,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
401 630
402 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {631 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
403 frame := renderer.NewFrame(2, 1)632 frame := renderer.NewFrame(2, 1)
404- drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{})633+ drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{})
405 for _, cell := range frame.Cells {634 for _, cell := range frame.Cells {
406 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)635 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
407 }636 }
408 }637 }
409 638
639+func TestResponsiveFloatingDrawerDrawsOnlyTopSeparator(t *testing.T) {
640+ frame := renderer.NewFrame(12, 6)
641+ geometry := floatingGeometry{
642+ Mode: ui.PresentationDrawer,
643+ Bounds: domain.Rect{Y: 2, Width: 12, Height: 4},
644+ Inner: domain.Rect{Y: 3, Width: 12, Height: 3},
645+ }
646+ drawFloatingBorder(frame, geometry, "float", renderer.Style{})
647+
648+ require.Equal(t, "──float─────", rowText(frame.Row(2)))
649+ for y := 3; y < frame.Height; y++ {
650+ require.Equal(t, " ", rowText(frame.Row(y)), "drawer must not draw side or bottom borders")
651+ }
652+}
653+
654+func TestResponsiveFloatingPresentationModeInvalidatesCache(t *testing.T) {
655+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
656+ bounds := domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}
657+ inner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}
658+ floating := floatingGeometry{Mode: ui.PresentationDrawer, Bounds: bounds, Inner: inner}
659+ _, damage := composeCapturedFloatingFrame(floatingComposeInput{
660+ baseFrame: renderer.NewFrame(12, 7),
661+ floating: capturedFloatingRenderState{
662+ visible: true,
663+ pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)},
664+ geometry: floating,
665+ generation: 1,
666+ },
667+ content: content,
668+ cache: composeCacheInput{
669+ valid: true,
670+ floatingGeneration: 1,
671+ floatingGeometry: floatingGeometry{
672+ Mode: ui.PresentationFloating,
673+ Bounds: bounds,
674+ Inner: inner,
675+ }.translate(content.X, content.Y),
676+ },
677+ })
678+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
679+}
680+
410 func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {681 func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
411 base := renderer.NewFrame(80, 24)682 base := renderer.NewFrame(80, 24)
412 content := domain.Rect{Y: 1, Width: 80, Height: 22}683 content := domain.Rect{Y: 1, Width: 80, Height: 22}
@@ -574,13 +845,13 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
574 require.Empty(t, pty.sizes())845 require.Empty(t, pty.sizes())
575 846
576 // Showing attempts the current size before paint. A failed resize retains847 // Showing attempts the current size before paint. A failed resize retains
577- // the old screen, but commits the new popup rect so capture clips/pads it.848+ // the complete previously committed render and input geometry.
578 require.NoError(t, d.toggleFloating(sess, nil))849 require.NoError(t, d.toggleFloating(sess, nil))
579 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())850 require.Equal(t, []domain.Size{rectSize(current)}, pty.sizes())
580- require.Equal(t, current, floating.rect)851+ require.Equal(t, initial, floating.rect)
581 require.Equal(t, initial.Width, floating.screen.Frame.Width)852 require.Equal(t, initial.Width, floating.screen.Frame.Width)
582 require.Equal(t, initial.Height, floating.screen.Frame.Height)853 require.Equal(t, initial.Height, floating.screen.Frame.Height)
583- require.Equal(t, currentGeometry, floating.popupGeometry)854+ require.Equal(t, initialGeometry, floating.popupGeometry)
584 855
585 require.NoError(t, d.toggleFloating(sess, nil)) // hide856 require.NoError(t, d.toggleFloating(sess, nil)) // hide
586 require.NoError(t, d.toggleFloating(sess, nil)) // retry show857 require.NoError(t, d.toggleFloating(sess, nil)) // retry show
M internal/usecase/daemon/floating_test.go +65 -0
@@ -184,6 +184,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
184184 d.sessWg.Wait()
185185 }
186186
187+func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
188+ initial := domain.Size{Cols: 80, Rows: 25}
189+ latest := domain.Size{Cols: 79, Rows: 25}
190+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
191+ latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
192+
193+ readerRelease := make(chan struct{})
194+ readerStarted := make(chan struct{})
195+ resized := make(chan struct{})
196+ floatingPTY := portsmocks.NewMockPTY(t)
197+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
198+ close(readerStarted)
199+ <-readerRelease
200+ return 0, io.EOF
201+ }).Once()
202+ floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()
203+ floatingPTY.EXPECT().Close().RunAndReturn(func() error {
204+ select {
205+ case <-readerRelease:
206+ default:
207+ close(readerRelease)
208+ }
209+ return nil
210+ }).Once()
211+ factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
212+ d := newTestDaemon(t, factory, stubClock{})
213+ d.ApplyConfig(domain.Config{Floating: cfg})
214+ tabCtx, cancelTab := context.WithCancel(t.Context())
215+ t.Cleanup(cancelTab)
216+ tb := newTab(&transactionalResizePTY{}, tabSize(initial))
217+ tb.ctx, tb.cancel = tabCtx, cancelTab
218+ sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()}
219+
220+ d.startFloating(sess, tb, true)
221+ openCall := <-opened
222+ require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
223+ require.True(t, d.requestTransactionalResize(sess, nil, latest, true))
224+ allowOpen()
225+ select {
226+ case <-resized:
227+ case <-time.After(time.Second):
228+ t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
229+ }
230+ select {
231+ case <-readerStarted:
232+ case <-time.After(time.Second):
233+ t.Fatal("floating reader did not start")
234+ }
235+
236+ require.Eventually(t, func() bool {
237+ tb.mu.Lock()
238+ p := tb.floating.pane
239+ visible := tb.floating.state == floatingVisible
240+ tb.mu.Unlock()
241+ if !visible || p == nil {
242+ return false
243+ }
244+ p.mu.Lock()
245+ defer p.mu.Unlock()
246+ return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
247+ }, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
248+ d.teardownFloating(tb, nil)
249+ d.sessWg.Wait()
250+}
251+
187252 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
188253 floatingPTY := portsmocks.NewMockPTY(t)
189254 readerStarted := make(chan struct{})
@@ -184,6 +184,71 @@ func TestFloatingLifecycleCapturesLaunchBeforeOpenAndDoesNotHoldTabLock(t *testi
184 d.sessWg.Wait()184 d.sessWg.Wait()
185 }185 }
186 186
187+func TestFloatingBlockedOpenReconcilesLatestResponsiveSize(t *testing.T) {
188+ initial := domain.Size{Cols: 80, Rows: 25}
189+ latest := domain.Size{Cols: 79, Rows: 25}
190+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
191+ latestGeometry := calculateContentFloatingGeometry(tabSize(latest), cfg)
192+
193+ readerRelease := make(chan struct{})
194+ readerStarted := make(chan struct{})
195+ resized := make(chan struct{})
196+ floatingPTY := portsmocks.NewMockPTY(t)
197+ floatingPTY.EXPECT().Read(mock.Anything).RunAndReturn(func([]byte) (int, error) {
198+ close(readerStarted)
199+ <-readerRelease
200+ return 0, io.EOF
201+ }).Once()
202+ floatingPTY.EXPECT().Resize(rectSize(latestGeometry.Inner)).Run(func(domain.Size) { close(resized) }).Return(nil).Once()
203+ floatingPTY.EXPECT().Close().RunAndReturn(func() error {
204+ select {
205+ case <-readerRelease:
206+ default:
207+ close(readerRelease)
208+ }
209+ return nil
210+ }).Once()
211+ factory, opened, allowOpen := newGatedOpenFactory(t, floatingPTY, nil)
212+ d := newTestDaemon(t, factory, stubClock{})
213+ d.ApplyConfig(domain.Config{Floating: cfg})
214+ tabCtx, cancelTab := context.WithCancel(t.Context())
215+ t.Cleanup(cancelTab)
216+ tb := newTab(&transactionalResizePTY{}, tabSize(initial))
217+ tb.ctx, tb.cancel = tabCtx, cancelTab
218+ sess := &session{id: "responsive-open", name: "work", tabs: []*tab{tb}, ctx: t.Context()}
219+
220+ d.startFloating(sess, tb, true)
221+ openCall := <-opened
222+ require.Equal(t, rectSize(calculateContentFloatingGeometry(tabSize(initial), cfg).Inner), openCall.size)
223+ require.True(t, d.requestTransactionalResize(sess, nil, latest, true))
224+ allowOpen()
225+ select {
226+ case <-resized:
227+ case <-time.After(time.Second):
228+ t.Fatal("floating install did not reconcile the resize committed while Open was blocked")
229+ }
230+ select {
231+ case <-readerStarted:
232+ case <-time.After(time.Second):
233+ t.Fatal("floating reader did not start")
234+ }
235+
236+ require.Eventually(t, func() bool {
237+ tb.mu.Lock()
238+ p := tb.floating.pane
239+ visible := tb.floating.state == floatingVisible
240+ tb.mu.Unlock()
241+ if !visible || p == nil {
242+ return false
243+ }
244+ p.mu.Lock()
245+ defer p.mu.Unlock()
246+ return p.popupGeometry == latestGeometry && p.rect == latestGeometry.Inner
247+ }, time.Second, time.Millisecond, "floating install retained the stale Open geometry")
248+ d.teardownFloating(tb, nil)
249+ d.sessWg.Wait()
250+}
251+
187 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {252 func TestFloatingLaunchOwnershipJoinsShutdown(t *testing.T) {
188 floatingPTY := portsmocks.NewMockPTY(t)253 floatingPTY := portsmocks.NewMockPTY(t)
189 readerStarted := make(chan struct{})254 readerStarted := make(chan struct{})
M internal/usecase/daemon/input.go +2 -2
@@ -61,7 +61,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
6161 }
6262
6363 tb.mu.Lock()
64- contentRow := ev.Row - 1
64+ contentRow := ev.Row - clientTopBarRows
6565 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
6666 if floatingVisible {
6767 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
@@ -170,7 +170,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
170170 if translated {
171171 d.writeToPane(sess, p, ev.Raw)
172172 } else {
173- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))
173+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
174174 }
175175 return
176176 }
@@ -61,7 +61,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
61 }61 }
62 62
63 tb.mu.Lock()63 tb.mu.Lock()
64- contentRow := ev.Row - 164+ contentRow := ev.Row - clientTopBarRows
65 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())65 floating, floatingGeometry, floatingVisible := tb.visibleFloatingSnapshotLocked(d.currentFloatingConfig())
66 if floatingVisible {66 if floatingVisible {
67 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {67 if !pointInRect(ev.Col, contentRow, floatingGeometry.Inner) {
@@ -170,7 +170,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
170 if translated {170 if translated {
171 d.writeToPane(sess, p, ev.Raw)171 d.writeToPane(sess, p, ev.Raw)
172 } else {172 } else {
173- d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -1))173+ d.writeToPane(sess, p, sgrRowOffset(ev.Raw, -clientTopBarRows))
174 }174 }
175 return175 return
176 }176 }
M internal/usecase/daemon/input_test.go +75 -2
@@ -21,6 +21,7 @@ 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
@@ -704,7 +705,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
704705 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
705706 installTestFloating(tb, floating, true)
706707 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
707- 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)
708709
709710 d.handleInput(sess, ac, raw)
710711 }
@@ -722,12 +723,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
722723 installTestFloating(tb, floating, true)
723724 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
724725
725- 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)
726727 outside := []byte("\x1b[<0;1;2M")
727728 d.handleInput(sess, ac, border)
728729 d.handleInput(sess, ac, outside)
729730 }
730731
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+
731804 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
732805 writes := make(chan []byte, 1)
733806 p, releasePTY := newBlockingPTYWithWrites(t, writes)
@@ -21,6 +21,7 @@ import (
21 "github.com/bnema/vev/internal/usecase/layout"21 "github.com/bnema/vev/internal/usecase/layout"
22 "github.com/bnema/vev/internal/usecase/mouse"22 "github.com/bnema/vev/internal/usecase/mouse"
23 "github.com/bnema/vev/internal/usecase/picker"23 "github.com/bnema/vev/internal/usecase/picker"
24+ "github.com/bnema/vev/internal/usecase/ui"
24 "github.com/bnema/vev/pkg/vt"25 "github.com/bnema/vev/pkg/vt"
25 )26 )
26 27
@@ -704,7 +705,7 @@ func TestFloatingMouseTranslatesSGRToInnerCoordinates(t *testing.T) {
704 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))705 floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
705 installTestFloating(tb, floating, true)706 installTestFloating(tb, floating, true)
706 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())707 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
707- 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)
708 709
709 d.handleInput(sess, ac, raw)710 d.handleInput(sess, ac, raw)
710 }711 }
@@ -722,12 +723,84 @@ func TestFloatingMouseIgnoresBorderAndOutside(t *testing.T) {
722 installTestFloating(tb, floating, true)723 installTestFloating(tb, floating, true)
723 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())724 geometry := calculateContentFloatingGeometry(domain.Size{Cols: 100, Rows: 20}, d.currentFloatingConfig())
724 725
725- 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)
726 outside := []byte("\x1b[<0;1;2M")727 outside := []byte("\x1b[<0;1;2M")
727 d.handleInput(sess, ac, border)728 d.handleInput(sess, ac, border)
728 d.handleInput(sess, ac, outside)729 d.handleInput(sess, ac, outside)
729 }730 }
730 731
732+func TestResponsiveDrawerFloatingMouseRoutesOnlyInnerClientCells(t *testing.T) {
733+ normal := portsmocks.NewMockPTY(t)
734+ floatingPTY := portsmocks.NewMockPTY(t)
735+ floatingPTY.EXPECT().Write([]byte("\x1b[<0;1;1M")).Return(len("\x1b[<0;1;1M"), nil).Once()
736+ d, sess, ac, _ := newManualSessionWithPTYs(t, normal)
737+ tb := sess.activeTab()
738+ complete := domain.Size{Cols: 79, Rows: 25}
739+ content := tabSize(complete)
740+ geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
741+ require.Equal(t, ui.PresentationDrawer, geometry.Mode)
742+ tb.mu.Lock()
743+ tb.size = content
744+ tb.mu.Unlock()
745+ floating := newPane("floating", floatingPTY, rectSize(geometry.Inner))
746+ floating.popupGeometry = geometry
747+ floating.screen.Write([]byte("\x1b[?1000h\x1b[?1006h"))
748+ installTestFloating(tb, floating, true)
749+
750+ firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
751+ d.handleInput(sess, ac, firstInner)
752+ for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
753+ d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
754+ }
755+}
756+
757+func TestResponsiveDrawerCopyMouseMapsInnerAndPinsDragAwayFromChrome(t *testing.T) {
758+ p, release := newBlockingPTY(t)
759+ defer release()
760+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
761+ tb := sess.activeTab()
762+ complete := domain.Size{Cols: 79, Rows: 25}
763+ content := tabSize(complete)
764+ geometry := calculateContentFloatingGeometry(content, d.currentFloatingConfig())
765+ tb.mu.Lock()
766+ tb.size = content
767+ tb.mu.Unlock()
768+ floating := newPane("floating", nil, rectSize(geometry.Inner))
769+ floating.popupGeometry = geometry
770+ floating.screen.Write([]byte("drawer row zero\r\nsecond row"))
771+ installTestFloating(tb, floating, true)
772+
773+ for _, frameRow := range []int{0, 1, 2, geometry.Bounds.Y + clientTopBarRows, complete.Rows - 1} {
774+ d.handleInput(sess, ac, fmt.Appendf(nil, "\x1b[<0;1;%dM", frameRow+1))
775+ ac.overlays.copyMu.Lock()
776+ require.False(t, ac.overlays.copyPointer.valid, "frame row %d is drawer chrome, not copy content", frameRow)
777+ ac.overlays.copyMu.Unlock()
778+ }
779+
780+ firstInner := fmt.Appendf(nil, "\x1b[<0;%d;%dM", geometry.Inner.X+1, geometry.Inner.Y+clientTopBarRows+1)
781+ d.handleInput(sess, ac, firstInner)
782+ ac.overlays.copyMu.Lock()
783+ pointer := ac.overlays.copyPointer
784+ ac.overlays.copyMu.Unlock()
785+ require.True(t, pointer.valid)
786+ require.Same(t, floating, pointer.pane)
787+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, pointer.press)
788+ require.Equal(t, copyContentToClientRect(geometry.Inner), pointer.geometry.content)
789+
790+ // Motion onto the protected top row clamps against the press-owned drawer
791+ // geometry instead of retargeting the underlying tiled terminal.
792+ d.handleInput(sess, ac, []byte("\x1b[<32;79;1M"))
793+ ac.overlays.copyMu.Lock()
794+ require.NotNil(t, ac.overlays.copyMode)
795+ require.Same(t, floating, ac.overlays.copyPane)
796+ require.Same(t, floating, ac.overlays.copyPointer.pane)
797+ require.Equal(t, pointer.geometry, ac.overlays.copyPointer.geometry)
798+ selection := ac.overlays.copyMode.Selection()
799+ ac.overlays.copyMu.Unlock()
800+ require.Equal(t, scopy.Pos{Row: 0, Col: 0}, selection.Anchor)
801+ require.Equal(t, 0, selection.Active.Row, "drag above the drawer must stay pinned to its first document row")
802+}
803+
731 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {804 func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
732 writes := make(chan []byte, 1)805 writes := make(chan []byte, 1)
733 p, releasePTY := newBlockingPTYWithWrites(t, writes)806 p, releasePTY := newBlockingPTYWithWrites(t, writes)
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 }
@@ -8,12 +8,9 @@ import (
8 )8 )
9 9
10 func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {10 func composeModalClientFrame(base renderer.Frame, modal ui.Modal, styles themeui.Styles, renderInner func(domain.Size) renderer.Frame) (renderer.Frame, []renderer.Damage) {
11- inner := modal.Composite(base, styles.BorderMuted, styles.PickerBase)11+ presentation := modal.Resolve(domain.Size{Cols: base.Width, Rows: base.Height})
12- modalFrame := renderInner(domain.Size{Cols: inner.Width, Rows: inner.Height})12+ modal.CompositePresentation(base, presentation, styles.BorderMuted, styles.PickerBase)
13- for y := range min(inner.Height, modalFrame.Height) {13+ modalFrame := renderInner(rectSize(presentation.Inner))
14- for x := range min(inner.Width, modalFrame.Width) {14+ copyModalInner(base, presentation.Inner, modalFrame)
15- base.Set(inner.X+x, inner.Y+y, modalFrame.At(x, y))
16- }
17- }
18 return base, []renderer.Damage{renderer.FullRedraw()}15 return base, []renderer.Damage{renderer.FullRedraw()}
19 }16 }
M internal/usecase/daemon/mouse_panes.go +1 -1
@@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
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
@@ -187,7 +187,7 @@ func setExpandedLocked(n *layout.Node, id layout.PaneID) bool {
187 func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {187 func translateMouseEvent(ev mouse.Event, colOffset, contentYOffset int) mouse.Event {
188 ev.Col -= colOffset188 ev.Col -= colOffset
189 ev.Row -= contentYOffset189 ev.Row -= contentYOffset
190- ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-1)190+ ev.Raw = sgrOffset(ev.Raw, -colOffset, -contentYOffset-clientTopBarRows)
191 return ev191 return ev
192 }192 }
193 193
M internal/usecase/daemon/overlay_backdrop.go +5 -27
@@ -6,33 +6,11 @@ import (
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) {
@@ -6,33 +6,11 @@ import (
6 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
7 )7 )
8 8
9-// overlayBackdrop controls optional dimming of pane content behind an overlay.9+// applyOverlayBackdrop dims every cell already composed into the complete
10-// Chrome (bars, dividers, and pane titles) is deliberately not part of the10+// renderer frame. Modal composition calls it immediately before painting each
11-// backdrop.11+// active layer so bars, panes, toasts, and lower overlays share one backdrop.
12-type overlayBackdrop struct {12+func applyOverlayBackdrop(frame renderer.Frame, theme themeui.Theme) {
13- DimPaneContents bool13+ dimFrameRect(frame, domain.Rect{Width: frame.Width, Height: frame.Height}, theme)
14-}
15-
16-// apply dims the content rectangles from a tab-content-relative layout
17-// snapshot. A failed solve follows the tab renderer's focused-pane fallback by
18-// dimming the complete content area.
19-func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) {
20- if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 {
21- return
22- }
23- if !layoutSnap.ok {
24- dimFrameRect(frame, contentArea, theme)
25- return
26- }
27- for _, placement := range layoutSnap.placements {
28- if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 {
29- continue
30- }
31- r := placement.Content
32- r.X += contentArea.X
33- r.Y += contentArea.Y
34- dimFrameRect(frame, r, theme)
35- }
36 }14 }
37 15
38 func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {16 func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
M internal/usecase/daemon/overlay_backdrop_test.go +27 -71
@@ -6,7 +6,6 @@ import (
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 }
@@ -6,7 +6,6 @@ import (
6 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
7 7
8 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
9- "github.com/bnema/vev/internal/usecase/layout"
10 themeui "github.com/bnema/vev/internal/usecase/theme"9 themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
12 )11 )
@@ -15,82 +14,39 @@ func backdropTheme() themeui.Theme {
15 return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}14 return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
16 }15 }
17 16
18-func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) {17+func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) {
19- frame := renderer.NewFrame(8, 6)18+ frame := renderer.NewFrame(4, 3)
20 original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}19 original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()}
21- frame.Set(2, 2, original)20+ for y := range frame.Height {
22- frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})21+ for x := range frame.Width {
23- frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title22+ frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*frame.Width + x), Style: renderer.DefaultStyle()})
24- frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar23+ }
25- frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar
26- frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed
27-
28- snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{
29- {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}},
30- {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}},
31- }}
32- (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme())
33-
34- require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin")
35- require.True(t, frame.At(2, 2).Style.HasForegroundRGB)
36- require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming")
37- require.Zero(t, frame.At(3, 2).Rune)
38- require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp")
39- require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp")
40- require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp")
41- require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed")
42-}
43-
44-func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
45- tests := []struct {
46- name string
47- content domain.Rect
48- affected [][2]int
49- unaffected [][2]int
50- }{
51- {
52- name: "negative origin",
53- content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3},
54- affected: [][2]int{{0, 0}, {1, 1}},
55- unaffected: [][2]int{{2, 2}, {3, 3}},
56- },
57- {
58- name: "right and bottom overflow",
59- content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4},
60- affected: [][2]int{{2, 2}, {3, 3}},
61- unaffected: [][2]int{{0, 0}, {1, 1}},
62- },
63 }24 }
64-25+ frame.Set(1, 1, original)
65- for _, tt := range tests {26+ frame.Set(2, 1, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()})
66- t.Run(tt.name, func(t *testing.T) {27+
67- frame := renderer.NewFrame(4, 4)28+ applyOverlayBackdrop(frame, backdropTheme())
68- original := renderer.DefaultStyle()29+ dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
69- snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}}30+ for y := range frame.Height {
70-31+ for x := range frame.Width {
71- (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())32+ require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y)
72-33+ }
73- for _, point := range tt.affected {
74- require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), frame.At(point[0], point[1]).Style)
75- }
76- for _, point := range tt.unaffected {
77- require.Equal(t, original, frame.At(point[0], point[1]).Style)
78- }
79- })
80 }34 }
35+ require.Equal(t, '界', frame.At(1, 1).Rune)
36+ require.True(t, frame.At(2, 1).Continuation)
37+ require.Zero(t, frame.At(2, 1).Rune)
81 }38 }
82 39
83-func TestOverlayBackdropDisabledAndFallback(t *testing.T) {40+func TestDimFrameRectClipsToFrame(t *testing.T) {
84 frame := renderer.NewFrame(4, 4)41 frame := renderer.NewFrame(4, 4)
85- frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
86 before := frame.Clone()42 before := frame.Clone()
87- area := domain.Rect{Y: 1, Width: 4, Height: 2}43+ dimFrameRect(frame, domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, backdropTheme())
88- (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())44+ dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle())
89- require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default")
90 45
91- (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme())46+ for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} {
92- require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area")47+ require.Equal(t, dimmed, frame.At(point[0], point[1]).Style)
93- require.True(t, frame.At(3, 2).Style.HasForegroundRGB)48+ }
94- require.False(t, frame.At(0, 0).Style.HasForegroundRGB)49+ for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} {
95- require.False(t, frame.At(0, 3).Style.HasForegroundRGB)50+ require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style)
51+ }
96 }52 }
M internal/usecase/daemon/palette_test.go +33 -3
@@ -743,10 +743,12 @@ func TestPaletteModalGeometry(t *testing.T) {
743743
744744 auto := domain.PaletteConfig{}
745745 tests := []testCase{
746+ {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}},
747+ {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}},
746748 {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}},
747749 {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}},
748750 {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}},
749- {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}},
751+ {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}},
750752 }
751753 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
752754 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]
@@ -760,7 +762,15 @@ func TestPaletteModalGeometry(t *testing.T) {
760762 for _, tt := range tests {
761763 t.Run(tt.name, func(t *testing.T) {
762764 modal := paletteModalFor(tt.size, tt.cfg)
763- require.Equal(t, tt.want, modal.Bounds(tt.size))
765+ presentation := modal.Resolve(tt.size)
766+ require.Equal(t, tt.want, presentation.Bounds)
767+ if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
768+ require.Equal(t, ui.PresentationDrawer, presentation.Mode)
769+ require.Equal(t, ui.BorderTop, presentation.Borders)
770+ } else {
771+ require.Equal(t, ui.PresentationFloating, presentation.Mode)
772+ require.Equal(t, ui.BorderAll, presentation.Borders)
773+ }
764774 require.Equal(t, " Commands ", modal.Title)
765775 require.Equal(t, 11, modal.FixedHeight)
766776 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
@@ -768,6 +778,26 @@ func TestPaletteModalGeometry(t *testing.T) {
768778 }
769779 }
770780
781+func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
782+ model := palette.New(nil)
783+ for _, tt := range []struct {
784+ name string
785+ width int
786+ topLeft rune
787+ innerLeft rune
788+ }{
789+ {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'},
790+ {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'},
791+ } {
792+ t.Run(tt.name, func(t *testing.T) {
793+ frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
794+
795+ require.Equal(t, tt.topLeft, frame.At(0, 28).Rune)
796+ require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune)
797+ })
798+ }
799+}
800+
771801 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
772802 model := palette.New(nil)
773803 base := renderer.NewFrame(120, 40)
@@ -793,7 +823,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
793823 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
794824
795825 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
796- inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size)
826+ inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner
797827
798828 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
799829 }
@@ -743,10 +743,12 @@ func TestPaletteModalGeometry(t *testing.T) {
743 743
744 auto := domain.PaletteConfig{}744 auto := domain.PaletteConfig{}
745 tests := []testCase{745 tests := []testCase{
746+ {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}},
747+ {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}},
746 {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}},748 {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}},
747 {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}},749 {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}},
748 {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}},750 {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}},
749- {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}},751+ {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}},
750 }752 }
751 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {753 for _, anchor := range []domain.Anchor{domain.AnchorTopLeft, domain.AnchorTop, domain.AnchorTopRight, domain.AnchorLeft, domain.AnchorCenter, domain.AnchorRight, domain.AnchorBottomLeft, domain.AnchorBottom, domain.AnchorBottomRight} {
752 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]754 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]
@@ -760,7 +762,15 @@ func TestPaletteModalGeometry(t *testing.T) {
760 for _, tt := range tests {762 for _, tt := range tests {
761 t.Run(tt.name, func(t *testing.T) {763 t.Run(tt.name, func(t *testing.T) {
762 modal := paletteModalFor(tt.size, tt.cfg)764 modal := paletteModalFor(tt.size, tt.cfg)
763- require.Equal(t, tt.want, modal.Bounds(tt.size))765+ presentation := modal.Resolve(tt.size)
766+ require.Equal(t, tt.want, presentation.Bounds)
767+ if tt.size.Cols < ui.ResponsiveDrawerBreakpoint {
768+ require.Equal(t, ui.PresentationDrawer, presentation.Mode)
769+ require.Equal(t, ui.BorderTop, presentation.Borders)
770+ } else {
771+ require.Equal(t, ui.PresentationFloating, presentation.Mode)
772+ require.Equal(t, ui.BorderAll, presentation.Borders)
773+ }
764 require.Equal(t, " Commands ", modal.Title)774 require.Equal(t, " Commands ", modal.Title)
765 require.Equal(t, 11, modal.FixedHeight)775 require.Equal(t, 11, modal.FixedHeight)
766 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)776 require.Equal(t, ui.Margins{Top: 1, Right: 1, Bottom: 1, Left: 1}, modal.Margins)
@@ -768,6 +778,26 @@ func TestPaletteModalGeometry(t *testing.T) {
768 }778 }
769 }779 }
770 780
781+func TestComposePaletteClientFrameUsesResponsivePresentation(t *testing.T) {
782+ model := palette.New(nil)
783+ for _, tt := range []struct {
784+ name string
785+ width int
786+ topLeft rune
787+ innerLeft rune
788+ }{
789+ {name: "79 column drawer", width: 79, topLeft: '─', innerLeft: '>'},
790+ {name: "80 column shelf", width: 80, topLeft: '┌', innerLeft: '│'},
791+ } {
792+ t.Run(tt.name, func(t *testing.T) {
793+ frame, _ := composePaletteClientFrame(model, renderer.NewFrame(tt.width, 40), domain.PaletteConfig{}, "")
794+
795+ require.Equal(t, tt.topLeft, frame.At(0, 28).Rune)
796+ require.Equal(t, tt.innerLeft, frame.At(0, 29).Rune)
797+ })
798+ }
799+}
800+
771 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {801 func TestComposePaletteClientFrameUsesConfiguredPosition(t *testing.T) {
772 model := palette.New(nil)802 model := palette.New(nil)
773 base := renderer.NewFrame(120, 40)803 base := renderer.NewFrame(120, 40)
@@ -793,7 +823,7 @@ func TestComposePaletteClientFrameUsesSelectedDescriptionTheme(t *testing.T) {
793 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles823 styles := themeui.Resolve(themeui.BuiltinDark, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
794 824
795 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)825 frame, _ := composePaletteClientFrame(model, base, domain.PaletteConfig{}, "", styles)
796- inner := paletteModalFor(size, domain.PaletteConfig{}).Inner(size)826+ inner := paletteModalFor(size, domain.PaletteConfig{}).Resolve(size).Inner
797 827
798 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))828 require.True(t, frame.At(inner.X+4, inner.Y+1).Style.Equal(styles.PickerSelectionMuted))
799 }829 }
M internal/usecase/daemon/picker_test.go +1 -1
@@ -1016,7 +1016,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
10161016 func TestPickerModalGeometry(t *testing.T) {
10171017 base := domain.Size{Cols: 100, Rows: 40}
10181018
1019- require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))
1019+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds)
10201020 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
10211021 }
10221022
@@ -1016,7 +1016,7 @@ func TestPickerPreviewMultiPaneComposesTabFrame(t *testing.T) {
1016 func TestPickerModalGeometry(t *testing.T) {1016 func TestPickerModalGeometry(t *testing.T) {
1017 base := domain.Size{Cols: 100, Rows: 40}1017 base := domain.Size{Cols: 100, Rows: 40}
1018 1018
1019- require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Bounds(base))1019+ require.Equal(t, domain.Rect{X: 10, Y: 4, Width: 80, Height: 32}, pickerModal.Resolve(base).Bounds)
1020 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)1020 require.Equal(t, domain.AnchorCenter, pickerModal.Anchor)
1021 }1021 }
1022 1022
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) {
@@ -20,7 +20,7 @@ func (f failingPTYFactory) Open(context.Context, string, []string, []string, str
20 func TestPromptModalGeometry(t *testing.T) {20 func TestPromptModalGeometry(t *testing.T) {
21 base := domain.Size{Cols: 100, Rows: 40}21 base := domain.Size{Cols: 100, Rows: 40}
22 22
23- require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Bounds(base))23+ require.Equal(t, domain.Rect{X: 0, Y: 35, Width: 100, Height: 4}, promptModalFor(" Prompt ").Resolve(base).Bounds)
24 }24 }
25 25
26 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {26 func TestEnterPromptRendersTitleAndPrefill(t *testing.T) {
M internal/usecase/daemon/render_capture.go +5 -3
@@ -102,9 +102,11 @@ type capturedOverlayRenderState struct {
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 {
@@ -102,9 +102,11 @@ type capturedOverlayRenderState struct {
102 }102 }
103 103
104 type capturedModal struct {104 type capturedModal struct {
105- modal ui.Modal105+ active bool
106- inner renderer.Frame106+ title string
107- focused bool107+ presentation ui.Presentation
108+ inner renderer.Frame
109+ focused bool
108 }110 }
109 111
110 func (o capturedOverlayRenderState) active() bool {112 func (o capturedOverlayRenderState) active() bool {
M internal/usecase/daemon/render_pipeline.go +91 -69
@@ -126,30 +126,43 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
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
@@ -126,30 +126,43 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
126 damage = append(damage, d)126 damage = append(damage, d)
127 }127 }
128 }128 }
129- // Keep the committed cache unadorned: floating composition clones this129+ // Keep the committed cache unadorned. Toasts are composed first so floating
130- // base, so closing or moving a popup cannot retain dimmed/bordered cells.130+ // terminals and scoped modals dim them with the rest of the complete frame.
131 baseFrame := frame131 baseFrame := frame
132+ overlaysActive := state.overlays.active()
133+ toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
134+ var toastFootprints []domain.Rect
135+ if toastsVisible {
136+ frame = baseFrame.Clone()
137+ toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
138+ }
132 if state.floating.visible {139 if state.floating.visible {
133 var floatingDamage []renderer.Damage140 var floatingDamage []renderer.Damage
134 frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{141 frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
135- baseFrame: baseFrame,142+ baseFrame: frame,
136 baseDamage: damage,143 baseDamage: damage,
137 floating: state.floating,144 floating: state.floating,
138 content: content,145 content: content,
139- layout: state.layout,
140 theme: state.theme,146 theme: state.theme,
141 borderMuted: styles.BorderMuted,147 borderMuted: styles.BorderMuted,
142 borderActive: styles.BorderActive,148 borderActive: styles.BorderActive,
143 cache: in,149 cache: in,
144- full: full || state.overlays.active(),150+ full: full || overlaysActive,
145 })151 })
146 damage = floatingDamage152 damage = floatingDamage
147 }153 }
154+ if overlaysActive {
155+ if !toastsVisible && !state.floating.visible {
156+ frame = baseFrame.Clone()
157+ }
158+ frame, damage = composeCapturedCopyMode(state, frame, damage, content)
159+ frame, damage = composeCapturedOverlays(state, frame, damage)
160+ }
148 if !full {161 if !full {
149- if !sameCells(in.bars.top, frame.Row(0)) {162+ if !sameCells(in.bars.top, baseFrame.Row(0)) {
150 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})163 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
151 }164 }
152- if !sameCells(in.bars.bottom, frame.Row(rows+1)) {165+ if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) {
153 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})166 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
154 }167 }
155 }168 }
@@ -157,17 +170,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
157 // toasts, so every render damages both the last and current toast coverage.170 // toasts, so every render damages both the last and current toast coverage.
158 // This restores cells exposed by dismissal and redraws a stable toast over171 // This restores cells exposed by dismissal and redraws a stable toast over
159 // any underlying pane update without promoting either case to a full frame.172 // any underlying pane update without promoting either case to a full frame.
160- overlaysActive := state.overlays.active()
161- toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
162- if overlaysActive || toastsVisible {
163- // Without a floating frame, overlay composition would otherwise mutate
164- // the base cache in place. Floating composition already owns a clone.
165- if !state.floating.visible {
166- baseFrame = frame.Clone()
167- }
168- frame, damage = composeCapturedOverlays(state, frame, damage, content)
169- }
170- toastFootprints := composeCapturedNotices(state.overlays, frame, state.styles)
171 if full || overlaysActive {173 if full || overlaysActive {
172 damage = []renderer.Damage{renderer.FullRedraw()}174 damage = []renderer.Damage{renderer.FullRedraw()}
173 } else {175 } else {
@@ -222,91 +224,111 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
222 }224 }
223 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}225 size := domain.Size{Cols: state.layout.area.Width, Rows: state.layout.area.Height + 2}
224 if snap.copySearchModel != nil {226 if snap.copySearchModel != nil {
225- o.copySearch.modal = copySearchModal227+ presentation := copySearchModal.Resolve(size)
226- o.copySearch.focused = true228+ o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
227- o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(copySearchModal.Inner(size)), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})229+ o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
228 }230 }
229 if snap.pickerActive && snap.pickerModel != nil {231 if snap.pickerActive && snap.pickerModel != nil {
230- o.picker.modal = pickerModal232+ presentation := pickerModal.Resolve(size)
231- o.picker.focused = true233+ o.picker = capturedModal{active: true, title: pickerModal.Title, presentation: presentation, focused: true}
232 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}234 renderStyles := picker.RenderStyles{Background: styles.PickerBase, Selection: styles.PickerSelection, SelectionName: styles.PickerSelectionName, SelectionMuted: styles.PickerSelectionMuted, Name: styles.PickerName, Detail: styles.PickerDescription, Base: styles.PickerBase, Separator: styles.PickerSeparator}
233- o.picker.inner = snap.pickerModel.Render(rectSize(pickerModal.Inner(size)), state.preview, renderStyles)235+ o.picker.inner = snap.pickerModel.Render(rectSize(presentation.Inner), state.preview, renderStyles)
234 }236 }
235 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {237 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
236- o.noticesOverlay.modal = noticesModal238+ presentation := noticesModal.Resolve(size)
237- o.noticesOverlay.focused = true239+ o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
238 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}240 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
239- o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(noticesModal.Inner(size)), renderStyles)241+ o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
240 }242 }
241 if snap.paletteActive && snap.paletteModel != nil {243 if snap.paletteActive && snap.paletteModel != nil {
242- o.palette.modal = paletteModalFor(size, paletteCfg)244+ modal := paletteModalFor(size, paletteCfg)
243- o.palette.focused = true245+ presentation := modal.Resolve(size)
246+ o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
244 guidance := ""247 guidance := ""
245 if snap.paletteHints != nil {248 if snap.paletteHints != nil {
246 guidance = snap.paletteHints.Feedback249 guidance = snap.paletteHints.Feedback
247 }250 }
248 o.paletteGuidance = snap.paletteFeedback251 o.paletteGuidance = snap.paletteFeedback
249- o.palette.inner = snap.paletteModel.Render(rectSize(o.palette.modal.Inner(size)), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})252+ o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Feedback: snap.paletteFeedback})
250 }253 }
251 if snap.promptActive && snap.promptModel != nil {254 if snap.promptActive && snap.promptModel != nil {
252- o.prompt.modal = promptModalFor(snap.promptModel.Title())255+ modal := promptModalFor(snap.promptModel.Title())
253- o.prompt.focused = true256+ presentation := modal.Resolve(size)
254- o.prompt.inner = snap.promptModel.RenderStyled(rectSize(o.prompt.modal.Inner(size)), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})257+ o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
258+ o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: styles.SurfaceActive})
255 }259 }
256 state.cursor.hiddenByOverlay = o.active()260 state.cursor.hiddenByOverlay = o.active()
257 }261 }
258 262
259-func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {263+func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
260 o := state.overlays264 o := state.overlays
261- if o.copyActive {265+ if !o.copyActive {
262- target := domain.Rect{}266+ return frame, damage
263- if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {267+ }
264- target = state.floating.geometry.translate(content.X, content.Y).Inner268+ target := domain.Rect{}
269+ if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
270+ target = state.floating.geometry.translate(content.X, content.Y).Inner
271+ }
272+ if target.Width == 0 {
273+ id := o.copyPaneID
274+ if id == "" {
275+ id = state.layout.focus
265 }276 }
266- if target.Width == 0 {277+ for _, p := range state.panes {
267- id := o.copyPaneID278+ if p.id == id && !p.placement.Collapsed {
268- if id == "" {279+ target = p.placement.Content
269- id = state.layout.focus280+ target.Y += content.Y
270- }281+ break
271- for _, p := range state.panes {
272- if p.id == id && !p.placement.Collapsed {
273- target = p.placement.Content
274- target.Y += content.Y
275- break
276- }
277 }282 }
278 }283 }
279- frame, damage = composeCopyClientFrame(o.copyMode, target, frame, state.styles)284+ }
280- }285+ return composeCopyClientFrame(o.copyMode, target, frame, state.styles)
281- layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}286+}
282- if o.paletteActive && !state.floating.visible {287+
283- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)288+func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
284- }289+ o := state.overlays
285- // This paint order intentionally differs from HandleInput's keyboard290+ // Paint in reverse keyboard priority so the same layer that owns input is
286- // priority (prompt > palette > picker > notices > copy, see291+ // visually topmost: prompt > palette > picker > notices > copy search.
287- // overlay_runtime.go), which paints the picker under notices instead of292+ for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.picker, o.palette, o.prompt} {
288- // over it. Currently unreachable: notices only opens via the palette, and293+ if !modal.active {
289- // HandleInput short-circuits to the first active overlay, so picker and
290- // notices are never simultaneously active. If that ever changes, this
291- // mismatch would let the picker own the keyboard while notices visually
292- // covers it.
293- for _, modal := range []capturedModal{o.copySearch, o.picker, o.noticesOverlay, o.palette, o.prompt} {
294- if modal.inner.Width == 0 && modal.inner.Height == 0 {
295 continue294 continue
296 }295 }
296+ applyOverlayBackdrop(frame, state.theme)
297 border := state.styles.BorderMuted297 border := state.styles.BorderMuted
298 if modal.focused {298 if modal.focused {
299 border = state.styles.BorderActive299 border = state.styles.BorderActive
300 }300 }
301- inner := modal.modal.Composite(frame, border, state.styles.PickerBase)301+ (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
302- for y := range min(inner.Height, modal.inner.Height) {302+ copyModalInner(frame, modal.presentation.Inner, modal.inner)
303- copy(frame.Row(inner.Y + y)[inner.X:inner.X+min(inner.Width, modal.inner.Width)], modal.inner.Row(y)[:min(inner.Width, modal.inner.Width)])
304- }
305 damage = []renderer.Damage{renderer.FullRedraw()}303 damage = []renderer.Damage{renderer.FullRedraw()}
306 }304 }
307 return frame, damage305 return frame, damage
308 }306 }
309 307
308+// copyModalInner copies a captured model frame into its resolved destination.
309+// Both source and destination are clipped so degenerate presentations remain
310+// safe immutable composition inputs.
311+func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
312+ left := max(target.X, 0)
313+ top := max(target.Y, 0)
314+ right := min(target.X+target.Width, dst.Width)
315+ bottom := min(target.Y+target.Height, dst.Height)
316+ if left >= right || top >= bottom {
317+ return
318+ }
319+
320+ sourceX := left - target.X
321+ sourceY := top - target.Y
322+ width := min(right-left, src.Width-sourceX)
323+ height := min(bottom-top, src.Height-sourceY)
324+ if width <= 0 || height <= 0 {
325+ return
326+ }
327+ for y := range height {
328+ copy(dst.Row(top + y)[left:left+width], src.Row(sourceY + y)[sourceX:sourceX+width])
329+ }
330+}
331+
310 func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {332 func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
311 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {333 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
312 return nil334 return nil
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -1
@@ -10,6 +10,7 @@ import (
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 }
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12 themeui "github.com/bnema/vev/internal/usecase/theme"12 themeui "github.com/bnema/vev/internal/usecase/theme"
13+ "github.com/bnema/vev/internal/usecase/ui"
13 "github.com/bnema/vev/pkg/renderer"14 "github.com/bnema/vev/pkg/renderer"
14 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
15 )16 )
@@ -326,7 +327,7 @@ func cacheState(title string, generation uint64) capturedRenderState {
326 reset: false,327 reset: false,
327 layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},328 layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"},
328 panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},329 panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
329- floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},330+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation},
330 bars: barState{topRight: title, bottomRight: title},331 bars: barState{topRight: title, bottomRight: title},
331 styles: resolveStyles(nil),332 styles: resolveStyles(nil),
332 }333 }
M internal/usecase/daemon/render_pipeline_test.go +253 -7
@@ -9,13 +9,20 @@ import (
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, "", 0),
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}
@@ -9,13 +9,20 @@ import (
9 "path/filepath"9 "path/filepath"
10 "strings"10 "strings"
11 "testing"11 "testing"
12+ "time"
12 13
13 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"15 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/testutil/replaytest"16 "github.com/bnema/vev/internal/testutil/replaytest"
17+ scopy "github.com/bnema/vev/internal/usecase/copy"
16 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/internal/usecase/notices"
20+ "github.com/bnema/vev/internal/usecase/palette"
21+ "github.com/bnema/vev/internal/usecase/picker"
22+ promptui "github.com/bnema/vev/internal/usecase/prompt"
17 themeui "github.com/bnema/vev/internal/usecase/theme"23 themeui "github.com/bnema/vev/internal/usecase/theme"
18 "github.com/bnema/vev/internal/usecase/ui"24 "github.com/bnema/vev/internal/usecase/ui"
25+ "github.com/bnema/vev/internal/usecase/visualsearch"
19 "github.com/bnema/vev/pkg/renderer"26 "github.com/bnema/vev/pkg/renderer"
20 "github.com/bnema/vev/pkg/vt"27 "github.com/bnema/vev/pkg/vt"
21 "github.com/stretchr/testify/require"28 "github.com/stretchr/testify/require"
@@ -220,21 +227,21 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
220 reset: true,227 reset: true,
221 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},228 layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 5}, valid: true, focus: "p", placements: []layout.Placement{{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}}},
222 panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},229 panes: []capturedPaneRenderState{{id: "p", frame: paneFrame, placement: layout.Placement{ID: "p", Content: domain.Rect{Width: 12, Height: 5}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
223- floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},230+ floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{id: "f", frame: floatingFrame}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 3, Y: 1, Width: 6, Height: 3}, Inner: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}}, title: "float", generation: 1},
224 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},231 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
225- overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{modal: ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}, inner: modalInner}},232+ overlays: capturedOverlayRenderState{promptActive: true, prompt: capturedModal{active: true, title: "Prompt", presentation: (ui.Modal{FixedWidth: 8, FixedHeight: 3, Title: "Prompt"}).Resolve(domain.Size{Cols: 12, Rows: 7}), inner: modalInner}},
226 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},233 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
227 styles: resolveStyles(nil),234 styles: resolveStyles(nil),
228 }235 }
229 composed := composeFrame(state, composeCacheInput{})236 composed := composeFrame(state, composeCacheInput{})
230- require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BB┌Prompt┐BB", "CC│PROMPT│CC", "DD└──────┘DD", "EEEEEEEEEEEE", " sess B"}, frameRows(composed.frame))237+ require.Equal(t, []string{" tab R", "AAAAAAAAAAAA", "BBB┌─fl─┐BBB", "───Prompt───", "PROMPT ", " ", " sess B"}, frameRows(composed.frame))
231 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")238 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
232 239
233 stream := newOutputStateStream()240 stream := newOutputStateStream()
234 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)241 prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
235 require.NoError(t, err)242 require.NoError(t, err)
236 terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)243 terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).encodeCursorTail(composed.cursor, true)...)
237- require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBB┌Prompt┐BB\x1b[4;1HCC│PROMPT│CC\x1b[5;1HDD└──────┘DD\x1b[6;1HEEEEEEEEEEEE\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))244+ require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
238 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)245 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
239 client.Write(terminalBytes)246 client.Write(terminalBytes)
240 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))247 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
@@ -298,6 +305,7 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
298 active := renderer.Style{Foreground: 3, Background: 4}305 active := renderer.Style{Foreground: 3, Background: 4}
299 interior := renderer.Style{Foreground: 5, Background: 6}306 interior := renderer.Style{Foreground: 5, Background: 6}
300 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}307 modal := ui.Modal{FixedWidth: 6, FixedHeight: 4, Title: "x"}
308+ presentation := modal.Resolve(domain.Size{Cols: 10, Rows: 8})
301 309
302 for _, tt := range []struct {310 for _, tt := range []struct {
303 name string311 name string
@@ -310,16 +318,254 @@ func TestComposeCapturedOverlaysUsesCachedModalRoles(t *testing.T) {
310 t.Run(tt.name, func(t *testing.T) {318 t.Run(tt.name, func(t *testing.T) {
311 state := capturedRenderState{319 state := capturedRenderState{
312 styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},320 styles: themeui.Styles{PickerBase: interior, BorderMuted: muted, BorderActive: active},
313- overlays: capturedOverlayRenderState{prompt: capturedModal{modal: modal, inner: renderer.NewFrame(1, 1), focused: tt.focused}},321+ overlays: capturedOverlayRenderState{prompt: capturedModal{active: true, title: modal.Title, presentation: presentation, inner: renderer.NewFrame(1, 1), focused: tt.focused}},
314 }322 }
315- frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil, domain.Rect{})323+ frame, _ := composeCapturedOverlays(state, renderer.NewFrame(10, 8), nil)
316- bounds := modal.Bounds(domain.Size{Cols: 10, Rows: 8})324+ bounds := presentation.Bounds
317 require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")325 require.True(t, frame.At(bounds.X, bounds.Y).Style.Equal(tt.border), "border uses cached focused role")
318 require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")326 require.True(t, frame.At(bounds.X+2, bounds.Y+2).Style.Equal(interior), "unrendered modal interior uses cached chrome role")
319 })327 })
320 }328 }
321 }329 }
322 330
331+func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
332+ const (
333+ width = 100
334+ contentRows = 6
335+ )
336+ styles := resolveStyles(nil)
337+ theme := backdropTheme()
338+ leftPlacement := layout.Placement{
339+ ID: "left",
340+ TitleBar: domain.Rect{Width: 49, Height: 1},
341+ Content: domain.Rect{Y: 1, Width: 49, Height: 5},
342+ }
343+ rightPlacement := layout.Placement{
344+ ID: "right",
345+ TitleBar: domain.Rect{X: 50, Width: 50, Height: 1},
346+ Content: domain.Rect{X: 50, Y: 1, Width: 50, Height: 5},
347+ }
348+ state := capturedRenderState{
349+ reset: true,
350+ layout: capturedTabLayout{
351+ area: domain.Rect{Width: width, Height: contentRows},
352+ focus: "left",
353+ placements: []layout.Placement{leftPlacement, rightPlacement},
354+ dividers: []layout.Divider{{Rect: domain.Rect{X: 49, Width: 1, Height: contentRows}, Dir: layout.Horizontal}},
355+ fingerprint: "modal-backdrop",
356+ valid: true,
357+ },
358+ panes: []capturedPaneRenderState{
359+ {id: "left", title: "left", frame: cachePaneFrame(49, 5, 'L'), placement: leftPlacement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}},
360+ {id: "right", title: "right", frame: cachePaneFrame(50, 5, 'R'), placement: rightPlacement, damage: []renderer.Damage{renderer.FullRedraw()}},
361+ },
362+ overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
363+ styles: styles,
364+ theme: theme,
365+ }
366+ base := composeFrame(state, composeCacheInput{})
367+ require.NotEmpty(t, base.cache.toastFootprints)
368+
369+ state.reset = false
370+ state.panes[0].damage = nil
371+ state.panes[1].damage = nil
372+ state.overlays.promptActive = true
373+ state.overlays.prompt = capturedModal{
374+ active: true,
375+ title: "Prompt",
376+ presentation: ui.Presentation{
377+ Bounds: domain.Rect{X: 80, Y: 5, Width: 10, Height: 2},
378+ Inner: domain.Rect{X: 81, Y: 6, Width: 8, Height: 1},
379+ Borders: ui.BorderAll,
380+ },
381+ inner: renderer.NewFrame(8, 1),
382+ focused: true,
383+ }
384+ composed := composeFrame(state, base.cache)
385+ dimmer := themeui.NewDimmer(theme)
386+ toast := base.cache.toastFootprints[0]
387+
388+ for name, point := range map[string][2]int{
389+ "top bar": {0, 0},
390+ "pane chrome": {0, 1},
391+ "pane content": {0, 2},
392+ "divider": {49, 2},
393+ "bottom bar": {0, contentRows + 1},
394+ "toast": {toast.X, toast.Y},
395+ } {
396+ t.Run(name, func(t *testing.T) {
397+ x, y := point[0], point[1]
398+ require.Equal(t, dimmer.Dim(base.frame.At(x, y).Style), composed.frame.At(x, y).Style)
399+ })
400+ }
401+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, composed.damage)
402+ require.False(t, composed.cache.valid)
403+ require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned")
404+}
405+
406+func TestComposeFrameFloatingBackdropDimsCompleteFrameIncludingToasts(t *testing.T) {
407+ const (
408+ width = 100
409+ contentRows = 6
410+ )
411+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: width, Height: contentRows}}
412+ theme := backdropTheme()
413+ state := capturedRenderState{
414+ reset: true,
415+ layout: capturedTabLayout{area: domain.Rect{Width: width, Height: contentRows}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "floating-toast-backdrop", valid: true},
416+ panes: []capturedPaneRenderState{{
417+ id: "pane", frame: cachePaneFrame(width, contentRows, 'P'), placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
418+ }},
419+ overlays: capturedOverlayRenderState{notices: []domain.Notification{{Code: domain.NoticeClipboard, Severity: domain.NoticeInfo, Message: "copied", Count: 1}}},
420+ styles: resolveStyles(nil),
421+ theme: theme,
422+ }
423+ toastOnly := composeFrame(state, composeCacheInput{})
424+ require.NotEmpty(t, toastOnly.cache.toastFootprints)
425+ toast := toastOnly.cache.toastFootprints[0]
426+
427+ state.floating = capturedFloatingRenderState{
428+ visible: true,
429+ focused: true,
430+ pane: capturedPaneRenderState{id: "floating", frame: renderer.NewFrame(8, 1)},
431+ geometry: floatingGeometry{
432+ Mode: ui.PresentationFloating,
433+ Bounds: domain.Rect{X: 40, Y: 2, Width: 10, Height: 3},
434+ Inner: domain.Rect{X: 41, Y: 3, Width: 8, Height: 1},
435+ },
436+ generation: 1,
437+ }
438+ composed := composeFrame(state, composeCacheInput{})
439+ dimmer := themeui.NewDimmer(theme)
440+
441+ for name, point := range map[string][2]int{
442+ "top bar": {0, 0},
443+ "bottom bar": {0, contentRows + 1},
444+ "toast": {toast.X, toast.Y},
445+ } {
446+ t.Run(name, func(t *testing.T) {
447+ x, y := point[0], point[1]
448+ require.Equal(t, dimmer.Dim(toastOnly.frame.At(x, y).Style), composed.frame.At(x, y).Style)
449+ })
450+ }
451+ require.Equal(t, toastOnly.cache.frame.Cells, composed.cache.frame.Cells, "floating decoration must not enter the reusable base cache")
452+}
453+
454+func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
455+ theme := backdropTheme()
456+ lowerStyle := renderer.Style{Foreground: 1, Background: 2}
457+ higherStyle := renderer.Style{Foreground: 3, Background: 4}
458+ modalAt := func(x int, r rune, style renderer.Style) capturedModal {
459+ inner := renderer.NewFrame(1, 1)
460+ inner.Set(0, 0, renderer.Cell{Rune: r, Style: style})
461+ bounds := domain.Rect{X: x, Y: 1, Width: 1, Height: 1}
462+ return capturedModal{active: true, presentation: ui.Presentation{Bounds: bounds, Inner: bounds}, inner: inner}
463+ }
464+
465+ for _, tt := range []struct {
466+ name string
467+ lower string
468+ higher string
469+ }{
470+ {name: "notices above copy search", lower: "copy search", higher: "notices"},
471+ {name: "picker above notices", lower: "notices", higher: "picker"},
472+ {name: "palette above picker", lower: "picker", higher: "palette"},
473+ {name: "prompt above palette", lower: "palette", higher: "prompt"},
474+ } {
475+ t.Run(tt.name, func(t *testing.T) {
476+ state := capturedRenderState{theme: theme, styles: themeui.Styles{PickerBase: renderer.DefaultStyle()}}
477+ setModal := func(name string, modal capturedModal) {
478+ switch name {
479+ case "copy search":
480+ state.overlays.copySearch = modal
481+ case "notices":
482+ state.overlays.noticesOverlay = modal
483+ case "picker":
484+ state.overlays.picker = modal
485+ case "palette":
486+ state.overlays.palette = modal
487+ case "prompt":
488+ state.overlays.prompt = modal
489+ }
490+ }
491+ setModal(tt.lower, modalAt(1, 'L', lowerStyle))
492+ setModal(tt.higher, modalAt(3, 'H', higherStyle))
493+
494+ frame, damage := composeCapturedOverlays(state, renderer.NewFrame(5, 3), nil)
495+
496+ require.Equal(t, 'L', frame.At(1, 1).Rune)
497+ require.Equal(t, themeui.NewDimmer(theme).Dim(lowerStyle), frame.At(1, 1).Style, "the lower-priority modal is part of the higher modal backdrop")
498+ require.Equal(t, 'H', frame.At(3, 1).Rune)
499+ require.Equal(t, higherStyle, frame.At(3, 1).Style, "the keyboard owner remains visually topmost")
500+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
501+ })
502+ }
503+}
504+
505+func TestCaptureOverlayLayersRendersEveryModelToResolvedInner(t *testing.T) {
506+ state := capturedRenderState{
507+ layout: capturedTabLayout{area: domain.Rect{Width: 79, Height: 38}},
508+ styles: fallbackChromeStyles,
509+ }
510+ snapshot := scopy.NewSnapshotFromRows(nil, 79, 38)
511+ snap := &overlayRenderSnapshot{
512+ copySearchModel: visualsearch.New(snapshot),
513+ pickerActive: true,
514+ pickerModel: picker.New(nil, "", 0),
515+ noticesOverlayActive: true,
516+ noticesOverlayModel: notices.New(nil, time.Time{}),
517+ paletteActive: true,
518+ paletteModel: palette.New(nil),
519+ promptActive: true,
520+ promptModel: promptui.New(" Prompt ", ""),
521+ }
522+
523+ captureOverlayLayers(&state, snap, domain.PaletteConfig{})
524+
525+ for name, modal := range map[string]capturedModal{
526+ "copy search": state.overlays.copySearch,
527+ "picker": state.overlays.picker,
528+ "notices": state.overlays.noticesOverlay,
529+ "palette": state.overlays.palette,
530+ "prompt": state.overlays.prompt,
531+ } {
532+ t.Run(name, func(t *testing.T) {
533+ require.True(t, modal.active)
534+ require.Equal(t, ui.PresentationDrawer, modal.presentation.Mode)
535+ require.Equal(t, rectSize(modal.presentation.Inner), domain.Size{Cols: modal.inner.Width, Rows: modal.inner.Height})
536+ })
537+ }
538+}
539+
540+func TestCopyModalInnerClipsDestinationAndSource(t *testing.T) {
541+ base := renderer.NewFrame(4, 3)
542+ inner := renderer.NewFrame(4, 2)
543+ for y, text := range []string{"ABCD", "EFGH"} {
544+ for x, r := range text {
545+ inner.Set(x, y, renderer.Cell{Rune: r})
546+ }
547+ }
548+
549+ copyModalInner(base, domain.Rect{X: -1, Y: 2, Width: 5, Height: 2}, inner)
550+
551+ require.Equal(t, "BCD ", rowText(base.Row(2)))
552+}
553+
554+func TestComposeCapturedOverlaysKeepsZeroHeightModalActive(t *testing.T) {
555+ presentation := (ui.Modal{FixedHeight: 11, Title: "Search"}).Resolve(domain.Size{Cols: 79, Rows: 4})
556+ require.Zero(t, presentation.Bounds.Height)
557+ state := capturedRenderState{
558+ styles: themeui.Styles{PickerBase: renderer.DefaultStyle()},
559+ overlays: capturedOverlayRenderState{
560+ copySearch: capturedModal{active: true, title: "Search", presentation: presentation},
561+ },
562+ }
563+
564+ _, damage := composeCapturedOverlays(state, renderer.NewFrame(79, 4), nil)
565+
566+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
567+}
568+
323 func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {569 func TestNoticeStylesFromMapsWarnToDedicatedRoleDistinctFromInfo(t *testing.T) {
324 terminal := renderer.DefaultStyle()570 terminal := renderer.DefaultStyle()
325 muted := renderer.Style{Foreground: 1}571 muted := renderer.Style{Foreground: 1}
M internal/usecase/daemon/render_test.go +14 -5
@@ -48,7 +48,10 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
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))
@@ -48,7 +48,10 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
48 dimmed := client.Frame.At(0, 1)48 dimmed := client.Frame.At(0, 1)
49 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")49 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
50 require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")50 require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")
51- require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp")51+ for x, cell := range copyBar {
52+ cell.Style = themeui.NewDimmer(theme).Dim(cell.Style)
53+ require.Equal(t, cell, client.Frame.At(x, client.Frame.Height-1), "copy status bar must be part of the complete backdrop")
54+ }
52 paletteVisible := false55 paletteVisible := false
53 for y := range client.Frame.Height {56 for y := range client.Frame.Height {
54 paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")57 paletteVisible = paletteVisible || strings.Contains(rowText(client.Frame.Row(y)), "Commands")
@@ -93,7 +96,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
93 }96 }
94 }97 }
95 98
96-func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {99+func TestPaletteBackdropDimsSimultaneousPicker(t *testing.T) {
97 p, release := newBlockingPTY(t)100 p, release := newBlockingPTY(t)
98 defer release()101 defer release()
99 d, sess, ac, sends := newManualSessionWithPTYs(t, p)102 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
@@ -110,7 +113,9 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
110 113
111 d.enterPalette(sess, ac)114 d.enterPalette(sess, ac)
112 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))115 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
113- require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")116+ dimmedPickerTitle := pickerTitle
117+ dimmedPickerTitle.Style = themeui.NewDimmer(backdropTheme()).Dim(pickerTitle.Style)
118+ require.Equal(t, dimmedPickerTitle, client.Frame.At(36, 2), "the lower-priority picker must be part of the palette backdrop")
114 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")119 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
115 }120 }
116 121
@@ -133,8 +138,12 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
133 dimmed := client.Frame.At(0, 1)138 dimmed := client.Frame.At(0, 1)
134 require.Equal(t, 'X', dimmed.Rune)139 require.Equal(t, 'X', dimmed.Rune)
135 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")140 require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")
136- require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")141+ dimmedTopBar := topBar
137- require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")142+ dimmedTopBar.Style = themeui.NewDimmer(backdropTheme()).Dim(topBar.Style)
143+ dimmedBottomBar := bottomBar
144+ dimmedBottomBar.Style = themeui.NewDimmer(backdropTheme()).Dim(bottomBar.Style)
145+ require.Equal(t, dimmedTopBar, client.Frame.At(0, 0), "top chrome is part of the complete backdrop")
146+ require.Equal(t, dimmedBottomBar, client.Frame.At(0, 24), "bottom chrome is part of the complete backdrop")
138 147
139 d.handleInput(sess, ac, []byte("\x1b"))148 d.handleInput(sess, ac, []byte("\x1b"))
140 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))149 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
M internal/usecase/daemon/transactional_resize.go +29 -8
@@ -366,7 +366,30 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
366366 size := tb.size
367367 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
368368 tb.mu.Unlock()
369- if !geometry.valid() {
369+ if !geometry.committable() {
370+ return nil, true
371+ }
372+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
373+ // A drawer can validly reserve only its separator row. Publish that
374+ // presentation without inventing a physical PTY size; the last usable
375+ // rectangle and screen remain committed until a later resize has content.
376+ tb.mu.Lock()
377+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
378+ tb.floating.pane == p && tb.size == size
379+ if current != nil && !current() {
380+ currentSlot = false
381+ }
382+ if currentSlot {
383+ p.mu.Lock()
384+ p.popupGeometry = geometry
385+ p.mu.Unlock()
386+ tb.mu.Unlock()
387+ return nil, true
388+ }
389+ tb.mu.Unlock()
390+ if current != nil && !current() {
391+ return nil, false
392+ }
370393 return nil, true
371394 }
372395
@@ -374,7 +397,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
374397 // floating slot and tab size are revalidated. A newer client resize may
375398 // otherwise publish this obsolete popup geometry after its PTY call returns.
376399 plan := preparedTabLayout{members: []resizeMember{{
377- session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,
400+ session: sess, tab: tb, pane: p, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,
378401 }}}
379402 d.applyPreparedTabMembers(&plan)
380403
@@ -385,14 +408,12 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
385408 if current != nil && !current() {
386409 currentSlot = false
387410 }
388- if currentSlot {
411+ if currentSlot && plan.members[0].ok {
389412 p.mu.Lock()
390- p.rect = geometry.Inner
413+ p.rect = plan.members[0].rect
391414 p.popupGeometry = geometry
392- if plan.members[0].ok {
393- p.resizeRetry = false
394- p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
395- }
415+ p.resizeRetry = false
416+ p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
396417 p.mu.Unlock()
397418 }
398419 tb.mu.Unlock()
@@ -366,7 +366,30 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
366 size := tb.size366 size := tb.size
367 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())367 geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig())
368 tb.mu.Unlock()368 tb.mu.Unlock()
369- if !geometry.valid() {369+ if !geometry.committable() {
370+ return nil, true
371+ }
372+ if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 {
373+ // A drawer can validly reserve only its separator row. Publish that
374+ // presentation without inventing a physical PTY size; the last usable
375+ // rectangle and screen remain committed until a later resize has content.
376+ tb.mu.Lock()
377+ currentSlot := tb.floating.state == floatingVisible && tb.floating.generation == generation &&
378+ tb.floating.pane == p && tb.size == size
379+ if current != nil && !current() {
380+ currentSlot = false
381+ }
382+ if currentSlot {
383+ p.mu.Lock()
384+ p.popupGeometry = geometry
385+ p.mu.Unlock()
386+ tb.mu.Unlock()
387+ return nil, true
388+ }
389+ tb.mu.Unlock()
390+ if current != nil && !current() {
391+ return nil, false
392+ }
370 return nil, true393 return nil, true
371 }394 }
372 395
@@ -374,7 +397,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
374 // floating slot and tab size are revalidated. A newer client resize may397 // floating slot and tab size are revalidated. A newer client resize may
375 // otherwise publish this obsolete popup geometry after its PTY call returns.398 // otherwise publish this obsolete popup geometry after its PTY call returns.
376 plan := preparedTabLayout{members: []resizeMember{{399 plan := preparedTabLayout{members: []resizeMember{{
377- session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,400+ session: sess, tab: tb, pane: p, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,
378 }}}401 }}}
379 d.applyPreparedTabMembers(&plan)402 d.applyPreparedTabMembers(&plan)
380 403
@@ -385,14 +408,12 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
385 if current != nil && !current() {408 if current != nil && !current() {
386 currentSlot = false409 currentSlot = false
387 }410 }
388- if currentSlot {411+ if currentSlot && plan.members[0].ok {
389 p.mu.Lock()412 p.mu.Lock()
390- p.rect = geometry.Inner413+ p.rect = plan.members[0].rect
391 p.popupGeometry = geometry414 p.popupGeometry = geometry
392- if plan.members[0].ok {415+ p.resizeRetry = false
393- p.resizeRetry = false416+ p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
394- p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
395- }
396 p.mu.Unlock()417 p.mu.Unlock()
397 }418 }
398 tb.mu.Unlock()419 tb.mu.Unlock()
M internal/usecase/daemon/transactional_resize_test.go +188 -0
@@ -12,6 +12,7 @@ import (
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
1414 "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/internal/usecase/ui"
1516 "github.com/bnema/vev/internal/usecase/visualsearch"
1617 )
1718
@@ -483,6 +484,193 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
483484 tb.mu.Unlock()
484485 }
485486
487+func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
488+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
489+ for _, tc := range []struct {
490+ name string
491+ from domain.Size
492+ to domain.Size
493+ wantMode ui.PresentationMode
494+ }{
495+ {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
496+ {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
497+ } {
498+ t.Run(tc.name, func(t *testing.T) {
499+ d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
500+ d.ApplyConfig(domain.Config{Floating: cfg})
501+ require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
502+
503+ tb := sess.activeTab()
504+ fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
505+ toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
506+ entered, release := make(chan struct{}), make(chan struct{})
507+ var once sync.Once
508+ popupPTY := &transactionalResizePTY{onResize: func() {
509+ once.Do(func() { close(entered) })
510+ <-release
511+ }}
512+ popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
513+ popup.rect = fromGeometry.Inner
514+ popup.popupGeometry = fromGeometry
515+ installTestFloating(tb, popup, true)
516+
517+ done := make(chan bool, 1)
518+ go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }()
519+ <-entered
520+ popup.mu.Lock()
521+ require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
522+ require.Equal(t, fromGeometry.Inner, popup.rect)
523+ require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
524+ popup.mu.Unlock()
525+ close(release)
526+ require.True(t, <-done)
527+
528+ require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
529+ popup.mu.Lock()
530+ require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
531+ require.Equal(t, toGeometry, popup.popupGeometry)
532+ require.Equal(t, toGeometry.Inner, popup.rect)
533+ require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
534+ popup.mu.Unlock()
535+ })
536+ }
537+}
538+
539+func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
540+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
541+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
542+ requestedSize := domain.Size{Cols: 79, Rows: 40}
543+ requested := calculateContentFloatingGeometry(requestedSize, cfg)
544+ require.Equal(t, ui.PresentationFloating, committed.Mode)
545+ require.Equal(t, ui.PresentationDrawer, requested.Mode)
546+ resizeErr := errors.New("floating resize failed")
547+ pty := &transactionalResizePTY{errs: []error{resizeErr}}
548+ popup := newPane("floating", pty, rectSize(committed.Inner))
549+ popup.rect = committed.Inner
550+ popup.popupGeometry = committed
551+ tb := newTab(nil, requestedSize)
552+ installTestFloating(tb, popup, true)
553+ d := newTestDaemon(t, nil, stubClock{})
554+ d.ApplyConfig(domain.Config{Floating: cfg})
555+ var tabUnlocked, paneUnlocked bool
556+ pty.onResize = func() {
557+ tabUnlocked = tb.mu.TryLock()
558+ if tabUnlocked {
559+ tb.mu.Unlock()
560+ }
561+ paneUnlocked = popup.mu.TryLock()
562+ if paneUnlocked {
563+ popup.mu.Unlock()
564+ }
565+ }
566+
567+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
568+
569+ require.True(t, ok)
570+ require.Len(t, failed, 1)
571+ require.ErrorIs(t, failed[0].err, resizeErr)
572+ require.Equal(t, []domain.Size{rectSize(requested.Inner)}, pty.requested())
573+ require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
574+ require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
575+ popup.mu.Lock()
576+ require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle")
577+ require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry")
578+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
579+ require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable")
580+ require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate")
581+ popup.mu.Unlock()
582+ tb.mu.Lock()
583+ _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
584+ tb.mu.Unlock()
585+ require.True(t, visible)
586+ require.Equal(t, committed, inputGeometry, "input mapping must retain committed geometry")
587+}
588+
589+func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *testing.T) {
590+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
591+ size := domain.Size{Cols: 79, Rows: 20}
592+ requested := calculateContentFloatingGeometry(size, cfg)
593+ require.Equal(t, ui.PresentationDrawer, requested.Mode)
594+ committed := requested
595+ committed.Mode = ui.PresentationFloating
596+ committed.Bounds.X++
597+ committed.Inner.X++
598+ pty := &transactionalResizePTY{}
599+ popup := newPane("floating", pty, rectSize(committed.Inner))
600+ popup.rect = committed.Inner
601+ popup.popupGeometry = committed
602+ tb := newTab(nil, size)
603+ installTestFloating(tb, popup, true)
604+ d := newTestDaemon(t, nil, stubClock{})
605+ d.ApplyConfig(domain.Config{Floating: cfg})
606+
607+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
608+
609+ require.True(t, ok)
610+ require.Empty(t, failed)
611+ require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY")
612+ popup.mu.Lock()
613+ require.Equal(t, requested.Inner, popup.rect)
614+ require.Equal(t, requested, popup.popupGeometry)
615+ require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
616+ require.False(t, popup.resizeRetry)
617+ popup.mu.Unlock()
618+}
619+
620+func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
621+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
622+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
623+ pty := &transactionalResizePTY{}
624+ popup := newPane("floating", pty, rectSize(committed.Inner))
625+ popup.rect = committed.Inner
626+ popup.popupGeometry = committed
627+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
628+ installTestFloating(tb, popup, true)
629+ d := newTestDaemon(t, nil, stubClock{})
630+ d.ApplyConfig(domain.Config{Floating: cfg})
631+ calls := 0
632+
633+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool {
634+ calls++
635+ return calls == 1
636+ })
637+
638+ require.True(t, ok, "a publication accepted while current must not later report stale")
639+ require.Equal(t, 1, calls, "current must not be rechecked after publication")
640+ require.Empty(t, failed)
641+ require.Empty(t, pty.requested())
642+ requested := calculateContentFloatingGeometry(tb.size, cfg)
643+ popup.mu.Lock()
644+ require.Equal(t, requested, popup.popupGeometry)
645+ require.Equal(t, committed.Inner, popup.rect)
646+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
647+ popup.mu.Unlock()
648+}
649+
650+func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
651+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
652+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
653+ pty := &transactionalResizePTY{}
654+ popup := newPane("floating", pty, rectSize(committed.Inner))
655+ popup.rect = committed.Inner
656+ popup.popupGeometry = committed
657+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
658+ installTestFloating(tb, popup, true)
659+ d := newTestDaemon(t, nil, stubClock{})
660+ d.ApplyConfig(domain.Config{Floating: cfg})
661+
662+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false })
663+
664+ require.False(t, ok)
665+ require.Empty(t, failed)
666+ require.Empty(t, pty.requested())
667+ popup.mu.Lock()
668+ require.Equal(t, committed, popup.popupGeometry)
669+ require.Equal(t, committed.Inner, popup.rect)
670+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
671+ popup.mu.Unlock()
672+}
673+
486674 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
487675 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
488676 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
@@ -12,6 +12,7 @@ import (
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/usecase/layout"14 "github.com/bnema/vev/internal/usecase/layout"
15+ "github.com/bnema/vev/internal/usecase/ui"
15 "github.com/bnema/vev/internal/usecase/visualsearch"16 "github.com/bnema/vev/internal/usecase/visualsearch"
16 )17 )
17 18
@@ -483,6 +484,193 @@ func TestHeadlessResizeRetriesInvalidatedPlan(t *testing.T) {
483 tb.mu.Unlock()484 tb.mu.Unlock()
484 }485 }
485 486
487+func TestTransactionalResizeFloatingBreakpointRace(t *testing.T) {
488+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
489+ for _, tc := range []struct {
490+ name string
491+ from domain.Size
492+ to domain.Size
493+ wantMode ui.PresentationMode
494+ }{
495+ {name: "80 to 79", from: domain.Size{Cols: 80, Rows: 25}, to: domain.Size{Cols: 79, Rows: 25}, wantMode: ui.PresentationDrawer},
496+ {name: "79 to 80", from: domain.Size{Cols: 79, Rows: 25}, to: domain.Size{Cols: 80, Rows: 25}, wantMode: ui.PresentationFloating},
497+ } {
498+ t.Run(tc.name, func(t *testing.T) {
499+ d, sess, _, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
500+ d.ApplyConfig(domain.Config{Floating: cfg})
501+ require.True(t, d.requestTransactionalResize(sess, nil, tc.from, true))
502+
503+ tb := sess.activeTab()
504+ fromGeometry := calculateContentFloatingGeometry(tabSize(tc.from), cfg)
505+ toGeometry := calculateContentFloatingGeometry(tabSize(tc.to), cfg)
506+ entered, release := make(chan struct{}), make(chan struct{})
507+ var once sync.Once
508+ popupPTY := &transactionalResizePTY{onResize: func() {
509+ once.Do(func() { close(entered) })
510+ <-release
511+ }}
512+ popup := newPane("floating", popupPTY, rectSize(fromGeometry.Inner))
513+ popup.rect = fromGeometry.Inner
514+ popup.popupGeometry = fromGeometry
515+ installTestFloating(tb, popup, true)
516+
517+ done := make(chan bool, 1)
518+ go func() { done <- d.requestTransactionalResize(sess, nil, tc.to, true) }()
519+ <-entered
520+ popup.mu.Lock()
521+ require.Equal(t, fromGeometry, popup.popupGeometry, "in-flight resize exposed an uncommitted mode")
522+ require.Equal(t, fromGeometry.Inner, popup.rect)
523+ require.Equal(t, rectSize(fromGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
524+ popup.mu.Unlock()
525+ close(release)
526+ require.True(t, <-done)
527+
528+ require.Equal(t, []domain.Size{rectSize(toGeometry.Inner)}, popupPTY.requested())
529+ popup.mu.Lock()
530+ require.Equal(t, tc.wantMode, popup.popupGeometry.Mode)
531+ require.Equal(t, toGeometry, popup.popupGeometry)
532+ require.Equal(t, toGeometry.Inner, popup.rect)
533+ require.Equal(t, rectSize(toGeometry.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
534+ popup.mu.Unlock()
535+ })
536+ }
537+}
538+
539+func TestApplyVisibleFloatingLayoutPreservesCommittedGeometryOnPTYResizeFailure(t *testing.T) {
540+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
541+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 24}, cfg)
542+ requestedSize := domain.Size{Cols: 79, Rows: 40}
543+ requested := calculateContentFloatingGeometry(requestedSize, cfg)
544+ require.Equal(t, ui.PresentationFloating, committed.Mode)
545+ require.Equal(t, ui.PresentationDrawer, requested.Mode)
546+ resizeErr := errors.New("floating resize failed")
547+ pty := &transactionalResizePTY{errs: []error{resizeErr}}
548+ popup := newPane("floating", pty, rectSize(committed.Inner))
549+ popup.rect = committed.Inner
550+ popup.popupGeometry = committed
551+ tb := newTab(nil, requestedSize)
552+ installTestFloating(tb, popup, true)
553+ d := newTestDaemon(t, nil, stubClock{})
554+ d.ApplyConfig(domain.Config{Floating: cfg})
555+ var tabUnlocked, paneUnlocked bool
556+ pty.onResize = func() {
557+ tabUnlocked = tb.mu.TryLock()
558+ if tabUnlocked {
559+ tb.mu.Unlock()
560+ }
561+ paneUnlocked = popup.mu.TryLock()
562+ if paneUnlocked {
563+ popup.mu.Unlock()
564+ }
565+ }
566+
567+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
568+
569+ require.True(t, ok)
570+ require.Len(t, failed, 1)
571+ require.ErrorIs(t, failed[0].err, resizeErr)
572+ require.Equal(t, []domain.Size{rectSize(requested.Inner)}, pty.requested())
573+ require.True(t, tabUnlocked, "PTY.Resize must run outside tab.mu")
574+ require.True(t, paneUnlocked, "PTY.Resize must run outside pane.mu")
575+ popup.mu.Lock()
576+ require.Equal(t, committed.Inner, popup.rect, "failed PTY resize published a speculative rectangle")
577+ require.Equal(t, committed, popup.popupGeometry, "failed PTY resize published speculative popup geometry")
578+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
579+ require.True(t, popup.resizeRetry, "failed PTY resize must remain retryable")
580+ require.False(t, popup.resizeApplying, "failed PTY resize must reopen the parser gate")
581+ popup.mu.Unlock()
582+ tb.mu.Lock()
583+ _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
584+ tb.mu.Unlock()
585+ require.True(t, visible)
586+ require.Equal(t, committed, inputGeometry, "input mapping must retain committed geometry")
587+}
588+
589+func TestApplyVisibleFloatingLayoutPublishesSameSizeGeometryWithoutPTYResize(t *testing.T) {
590+ cfg := domain.FloatingConfig{Width: 50, Height: 50}
591+ size := domain.Size{Cols: 79, Rows: 20}
592+ requested := calculateContentFloatingGeometry(size, cfg)
593+ require.Equal(t, ui.PresentationDrawer, requested.Mode)
594+ committed := requested
595+ committed.Mode = ui.PresentationFloating
596+ committed.Bounds.X++
597+ committed.Inner.X++
598+ pty := &transactionalResizePTY{}
599+ popup := newPane("floating", pty, rectSize(committed.Inner))
600+ popup.rect = committed.Inner
601+ popup.popupGeometry = committed
602+ tb := newTab(nil, size)
603+ installTestFloating(tb, popup, true)
604+ d := newTestDaemon(t, nil, stubClock{})
605+ d.ApplyConfig(domain.Config{Floating: cfg})
606+
607+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, nil)
608+
609+ require.True(t, ok)
610+ require.Empty(t, failed)
611+ require.Empty(t, pty.requested(), "same-size geometry publication must not resize the PTY")
612+ popup.mu.Lock()
613+ require.Equal(t, requested.Inner, popup.rect)
614+ require.Equal(t, requested, popup.popupGeometry)
615+ require.Equal(t, rectSize(requested.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
616+ require.False(t, popup.resizeRetry)
617+ popup.mu.Unlock()
618+}
619+
620+func TestZeroInnerFloatingDrawerPublicationDoesNotRecheckCurrent(t *testing.T) {
621+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
622+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
623+ pty := &transactionalResizePTY{}
624+ popup := newPane("floating", pty, rectSize(committed.Inner))
625+ popup.rect = committed.Inner
626+ popup.popupGeometry = committed
627+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
628+ installTestFloating(tb, popup, true)
629+ d := newTestDaemon(t, nil, stubClock{})
630+ d.ApplyConfig(domain.Config{Floating: cfg})
631+ calls := 0
632+
633+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool {
634+ calls++
635+ return calls == 1
636+ })
637+
638+ require.True(t, ok, "a publication accepted while current must not later report stale")
639+ require.Equal(t, 1, calls, "current must not be rechecked after publication")
640+ require.Empty(t, failed)
641+ require.Empty(t, pty.requested())
642+ requested := calculateContentFloatingGeometry(tb.size, cfg)
643+ popup.mu.Lock()
644+ require.Equal(t, requested, popup.popupGeometry)
645+ require.Equal(t, committed.Inner, popup.rect)
646+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
647+ popup.mu.Unlock()
648+}
649+
650+func TestZeroInnerFloatingDrawerRejectsStaleRequestWithoutPhysicalPublication(t *testing.T) {
651+ cfg := domain.FloatingConfig{Width: 100, Height: 100}
652+ committed := calculateContentFloatingGeometry(domain.Size{Cols: 80, Rows: 22}, cfg)
653+ pty := &transactionalResizePTY{}
654+ popup := newPane("floating", pty, rectSize(committed.Inner))
655+ popup.rect = committed.Inner
656+ popup.popupGeometry = committed
657+ tb := newTab(nil, domain.Size{Cols: 79, Rows: 2})
658+ installTestFloating(tb, popup, true)
659+ d := newTestDaemon(t, nil, stubClock{})
660+ d.ApplyConfig(domain.Config{Floating: cfg})
661+
662+ failed, ok := d.applyVisibleFloatingLayout(&session{tabs: []*tab{tb}}, tb, func() bool { return false })
663+
664+ require.False(t, ok)
665+ require.Empty(t, failed)
666+ require.Empty(t, pty.requested())
667+ popup.mu.Lock()
668+ require.Equal(t, committed, popup.popupGeometry)
669+ require.Equal(t, committed.Inner, popup.rect)
670+ require.Equal(t, rectSize(committed.Inner), domain.Size{Cols: popup.screen.Frame.Width, Rows: popup.screen.Frame.Height})
671+ popup.mu.Unlock()
672+}
673+
486 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {674 func TestTransactionalResizeRetriesAcceptedFloatingSlotByIdentity(t *testing.T) {
487 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}675 popupPTY := &transactionalResizePTY{errs: []error{errors.New("first popup resize fails")}}
488 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})676 d, sess, ac, _ := newManualSessionWithPTYs(t, &transactionalResizePTY{})
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
@@ -18,8 +18,7 @@ type Modal struct {
18 FixedHeight int18 FixedHeight int
19 }19 }
20 20
21-// Bounds returns the modal rectangle positioned within base and clamped to base.21+func (m Modal) preferredBounds(base domain.Size) domain.Rect {
22-func (m Modal) Bounds(base domain.Size) domain.Rect {
23 width := percentOf(base.Cols, m.WidthPct)22 width := percentOf(base.Cols, m.WidthPct)
24 height := percentOf(base.Rows, m.HeightPct)23 height := percentOf(base.Rows, m.HeightPct)
25 if m.FixedWidth > 0 {24 if m.FixedWidth > 0 {
@@ -37,38 +36,41 @@ func (m Modal) Bounds(base domain.Size) domain.Rect {
37 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)36 return Place(base, domain.Size{Cols: width, Rows: height}, m.Anchor, m.Margins)
38 }37 }
39 38
40-// Inner returns the modal content rectangle after removing a one-cell border.39+// Resolve computes the modal's responsive presentation from one preferred
41-func (m Modal) Inner(base domain.Size) domain.Rect {40+// bounds and inner pair.
42- bounds := m.Bounds(base)41+func (m Modal) Resolve(base domain.Size) Presentation {
43- return domain.Rect{42+ bounds := m.preferredBounds(base)
44- X: bounds.X + 1,43+ return ResolvePresentation(base, bounds, modalInner(bounds))
45- Y: bounds.Y + 1,44+}
46- Width: max(0, bounds.Width-2),45+
47- Height: max(0, bounds.Height-2),46+// CompositePresentation draws an already-resolved modal presentation and
47+// returns its inner content rectangle.
48+func (m Modal) CompositePresentation(f renderer.Frame, p Presentation, border, interior renderer.Style) domain.Rect {
49+ FillRect(f, p.Inner, renderer.Cell{Rune: ' ', Style: interior})
50+ drawBorderEdges(f, p.Bounds, p.Borders, border)
51+ if p.Borders&BorderTop != 0 {
52+ m.drawTitle(f, p.Bounds, border)
53+ }
54+ return p.Inner
55+}
56+
57+func (m Modal) drawTitle(f renderer.Frame, bounds domain.Rect, style renderer.Style) {
58+ if bounds.Width <= 2 || bounds.Height <= 0 || m.Title == "" {
59+ return
48 }60 }
61+ left := bounds.X + 1
62+ right := bounds.X + bounds.Width - 1
63+ start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2)
64+ DrawText(f, start, bounds.Y, right, m.Title, style)
49 }65 }
50 66
51-// Composite draws the modal border and title, fills its interior, and returns67+func modalInner(bounds domain.Rect) domain.Rect {
52-// the inner content rectangle. Border and interior styles are deliberately68+ return domain.Rect{
53-// independent so unfocused structure and chrome surfaces retain their roles.
54-// Cells outside the modal bounds are not changed.
55-func (m Modal) Composite(f renderer.Frame, border, interior renderer.Style) domain.Rect {
56- bounds := m.Bounds(domain.Size{Cols: f.Width, Rows: f.Height})
57- inner := domain.Rect{
58 X: bounds.X + 1,69 X: bounds.X + 1,
59 Y: bounds.Y + 1,70 Y: bounds.Y + 1,
60 Width: max(0, bounds.Width-2),71 Width: max(0, bounds.Width-2),
61 Height: max(0, bounds.Height-2),72 Height: max(0, bounds.Height-2),
62 }73 }
63- DrawBox(f, bounds, border)
64- FillRect(f, inner, renderer.Cell{Rune: ' ', Style: interior})
65- if bounds.Width > 2 && bounds.Height > 0 && m.Title != "" {
66- left := bounds.X + 1
67- right := bounds.X + bounds.Width - 1
68- start := max(left, bounds.X+(bounds.Width-textWidth(m.Title))/2)
69- DrawText(f, start, bounds.Y, right, m.Title, border)
70- }
71- return inner
72 }74 }
73 75
74 // FillRect fills rect with cell, clipped to the frame bounds.76 // FillRect fills rect with cell, clipped to the frame bounds.
@@ -86,7 +88,11 @@ func FillRect(f renderer.Frame, rect domain.Rect, cell renderer.Cell) {
86 88
87 // DrawBox draws a single-cell box clipped to the frame bounds.89 // DrawBox draws a single-cell box clipped to the frame bounds.
88 func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {90 func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
89- if rect.Width <= 0 || rect.Height <= 0 {91+ drawBorderEdges(f, rect, BorderAll, style)
92+}
93+
94+func drawBorderEdges(f renderer.Frame, rect domain.Rect, edges BorderEdges, style renderer.Style) {
95+ if rect.Width <= 0 || rect.Height <= 0 || edges == 0 {
90 return96 return
91 }97 }
92 left := rect.X98 left := rect.X
@@ -94,18 +100,38 @@ func DrawBox(f renderer.Frame, rect domain.Rect, style renderer.Style) {
94 right := rect.X + rect.Width - 1100 right := rect.X + rect.Width - 1
95 bottom := rect.Y + rect.Height - 1101 bottom := rect.Y + rect.Height - 1
96 102
97- for x := left; x <= right; x++ {103+ if edges&BorderTop != 0 {
98- setCell(f, x, top, '─', style)104+ for x := left; x <= right; x++ {
99- setCell(f, x, bottom, '─', style)105+ setCell(f, x, top, '─', style)
100- }106+ }
101- for y := top; y <= bottom; y++ {107+ }
102- setCell(f, left, y, '│', style)108+ if edges&BorderBottom != 0 {
103- setCell(f, right, y, '│', style)109+ for x := left; x <= right; x++ {
104- }110+ setCell(f, x, bottom, '─', style)
105- setCell(f, left, top, '┌', style)111+ }
106- setCell(f, right, top, '┐', style)112+ }
107- setCell(f, left, bottom, '└', style)113+ if edges&BorderLeft != 0 {
108- setCell(f, right, bottom, '┘', style)114+ for y := top; y <= bottom; y++ {
115+ setCell(f, left, y, '│', style)
116+ }
117+ }
118+ if edges&BorderRight != 0 {
119+ for y := top; y <= bottom; y++ {
120+ setCell(f, right, y, '│', style)
121+ }
122+ }
123+ if edges&(BorderTop|BorderLeft) == BorderTop|BorderLeft {
124+ setCell(f, left, top, '┌', style)
125+ }
126+ if edges&(BorderTop|BorderRight) == BorderTop|BorderRight {
127+ setCell(f, right, top, '┐', style)
128+ }
129+ if edges&(BorderBottom|BorderLeft) == BorderBottom|BorderLeft {
130+ setCell(f, left, bottom, '└', style)
131+ }
132+ if edges&(BorderBottom|BorderRight) == BorderBottom|BorderRight {
133+ setCell(f, right, bottom, '┘', style)
134+ }
109 }135 }
110 136
111 // DrawText draws text starting at x,y before exclusive clipX and returns the137 // DrawText draws text starting at x,y before exclusive clipX and returns the
M internal/usecase/ui/modal_test.go +178 -72
@@ -8,7 +8,7 @@ import (
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 }
@@ -8,7 +8,7 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11-func TestModalBounds(t *testing.T) {11+func TestModalResolveBounds(t *testing.T) {
12 tests := []struct {12 tests := []struct {
13 name string13 name string
14 base domain.Size14 base domain.Size
@@ -22,22 +22,22 @@ func TestModalBounds(t *testing.T) {
22 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},22 want: domain.Rect{X: 10, Y: 4, Width: 80, Height: 32},
23 },23 },
24 {24 {
25- name: "minimum clamps to base",25+ name: "minimum clamps to drawer availability",
26 base: domain.Size{Cols: 20, Rows: 6},26 base: domain.Size{Cols: 20, Rows: 6},
27 m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},27 m: Modal{WidthPct: 50, HeightPct: 50, MinWidth: 40, MinHeight: 12},
28- want: domain.Rect{X: 0, Y: 0, Width: 20, Height: 6},28+ want: domain.Rect{X: 0, Y: 3, Width: 20, Height: 2},
29 },29 },
30 {30 {
31- name: "odd centering rounds down",31+ name: "narrow preferred height becomes drawer height",
32 base: domain.Size{Cols: 11, Rows: 7},32 base: domain.Size{Cols: 11, Rows: 7},
33 m: Modal{WidthPct: 50, HeightPct: 50},33 m: Modal{WidthPct: 50, HeightPct: 50},
34- want: domain.Rect{X: 3, Y: 2, Width: 5, Height: 3},34+ want: domain.Rect{X: 0, Y: 3, Width: 11, Height: 3},
35 },35 },
36 {36 {
37- name: "zero value fills base centered",37+ name: "zero value fills available drawer area",
38 base: domain.Size{Cols: 12, Rows: 8},38 base: domain.Size{Cols: 12, Rows: 8},
39 m: Modal{},39 m: Modal{},
40- want: domain.Rect{X: 0, Y: 0, Width: 12, Height: 8},40+ want: domain.Rect{X: 0, Y: 3, Width: 12, Height: 4},
41 },41 },
42 {42 {
43 name: "bottom anchor honors bottom margin",43 name: "bottom anchor honors bottom margin",
@@ -63,7 +63,7 @@ func TestModalBounds(t *testing.T) {
63 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},63 want: domain.Rect{X: 67, Y: 14, Width: 30, Height: 12},
64 },64 },
65 {65 {
66- name: "right margin clamps to screen",66+ name: "drawer ignores right margin",
67 base: domain.Size{Cols: 20, Rows: 10},67 base: domain.Size{Cols: 20, Rows: 10},
68 m: Modal{68 m: Modal{
69 FixedWidth: 8,69 FixedWidth: 8,
@@ -71,10 +71,10 @@ func TestModalBounds(t *testing.T) {
71 Anchor: domain.AnchorRight,71 Anchor: domain.AnchorRight,
72 Margins: Margins{Right: 20},72 Margins: Margins{Right: 20},
73 },73 },
74- want: domain.Rect{X: 0, Y: 3, Width: 8, Height: 4},74+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
75 },75 },
76 {76 {
77- name: "right anchored fixed dimensions clamp on small terminal",77+ name: "right anchored drawer can have zero height",
78 base: domain.Size{Cols: 10, Rows: 4},78 base: domain.Size{Cols: 10, Rows: 4},
79 m: Modal{79 m: Modal{
80 FixedWidth: 30,80 FixedWidth: 30,
@@ -82,19 +82,19 @@ func TestModalBounds(t *testing.T) {
82 Anchor: domain.AnchorRight,82 Anchor: domain.AnchorRight,
83 Margins: Margins{Right: 3},83 Margins: Margins{Right: 3},
84 },84 },
85- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},85+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
86 },86 },
87 {87 {
88- name: "fixed dimensions clamp on small terminal",88+ name: "fixed drawer dimensions clamp on small terminal",
89 base: domain.Size{Cols: 10, Rows: 4},89 base: domain.Size{Cols: 10, Rows: 4},
90 m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},90 m: Modal{FixedWidth: 30, FixedHeight: 12, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 2}},
91- want: domain.Rect{X: 0, Y: 0, Width: 10, Height: 4},91+ want: domain.Rect{X: 0, Y: 3, Width: 10, Height: 0},
92 },92 },
93 {93 {
94- name: "bottom margin clamps to screen",94+ name: "drawer ignores bottom margin",
95 base: domain.Size{Cols: 20, Rows: 10},95 base: domain.Size{Cols: 20, Rows: 10},
96 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},96 m: Modal{FixedWidth: 8, FixedHeight: 4, Anchor: domain.AnchorBottom, Margins: Margins{Bottom: 20}},
97- want: domain.Rect{X: 6, Y: 0, Width: 8, Height: 4},97+ want: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
98 },98 },
99 {99 {
100 name: "bottom right anchor honors combined margins",100 name: "bottom right anchor honors combined margins",
@@ -110,58 +110,171 @@ func TestModalBounds(t *testing.T) {
110 }110 }
111 for _, tt := range tests {111 for _, tt := range tests {
112 t.Run(tt.name, func(t *testing.T) {112 t.Run(tt.name, func(t *testing.T) {
113- if got := tt.m.Bounds(tt.base); got != tt.want {113+ if got := tt.m.Resolve(tt.base).Bounds; got != tt.want {
114- t.Fatalf("Bounds() = %+v, want %+v", got, tt.want)114+ t.Fatalf("Resolve().Bounds = %+v, want %+v", got, tt.want)
115 }115 }
116 })116 })
117 }117 }
118 }118 }
119 119
120-func TestModalInnerDegenerate(t *testing.T) {120+func TestModalResolveInnerDegenerate(t *testing.T) {
121- m := Modal{WidthPct: 100, HeightPct: 100}121+ got := (Modal{WidthPct: 100, HeightPct: 100}).Resolve(domain.Size{Cols: 2, Rows: 2}).Inner
122- got := m.Inner(domain.Size{Cols: 2, Rows: 2})122+ require.Equal(t, domain.Rect{X: 0, Y: 1, Width: 2, Height: 0}, got)
123- want := domain.Rect{X: 1, Y: 1, Width: 0, Height: 0}
124- if got != want {
125- t.Fatalf("Inner() = %+v, want %+v", got, want)
126- }
127 }123 }
128 124
129-func TestCompositeDrawsModalAndPreservesExterior(t *testing.T) {125+func TestModalResolve(t *testing.T) {
130- f := renderer.NewFrame(10, 6)126+ tests := []struct {
131- exterior := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}127+ name string
132- FillRect(f, domain.Rect{X: 0, Y: 0, Width: 10, Height: 6}, exterior)128+ base domain.Size
129+ want Presentation
130+ }{
131+ {
132+ name: "narrow modal resolves to drawer",
133+ base: domain.Size{Cols: 20, Rows: 10},
134+ want: Presentation{
135+ Mode: PresentationDrawer,
136+ Bounds: domain.Rect{X: 0, Y: 5, Width: 20, Height: 4},
137+ Inner: domain.Rect{X: 0, Y: 6, Width: 20, Height: 3},
138+ Borders: BorderTop,
139+ },
140+ },
141+ {
142+ name: "complete frame preserves floating modal geometry",
143+ base: domain.Size{Cols: 80, Rows: 10},
144+ want: Presentation{
145+ Mode: PresentationFloating,
146+ Bounds: domain.Rect{X: 20, Y: 3, Width: 40, Height: 4},
147+ Inner: domain.Rect{X: 21, Y: 4, Width: 38, Height: 2},
148+ Borders: BorderAll,
149+ },
150+ },
151+ }
152+
153+ modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
154+ for _, tt := range tests {
155+ t.Run(tt.name, func(t *testing.T) {
156+ require.Equal(t, tt.want, modal.Resolve(tt.base))
157+ })
158+ }
159+}
133 160
161+func TestCompositePresentationDrawsFloatingChrome(t *testing.T) {
162+ modal := Modal{WidthPct: 50, FixedHeight: 4, Title: " Rename "}
163+ presentation := modal.Resolve(domain.Size{Cols: 80, Rows: 10})
164+ frame := renderer.NewFrame(80, 10)
165+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
166+ FillRect(frame, domain.Rect{Width: 80, Height: 10}, exterior)
134 border := renderer.Style{Bold: true, Foreground: 2, Background: -1}167 border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
135 interior := renderer.Style{Foreground: 3, Background: 4}168 interior := renderer.Style{Foreground: 3, Background: 4}
136- m := Modal{WidthPct: 60, HeightPct: 80, Title: "Hi"}169+
137- inner := m.Composite(f, border, interior)170+ inner := modal.CompositePresentation(frame, presentation, border, interior)
138- wantInner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 2}171+ require.Equal(t, presentation.Inner, inner)
139- if inner != wantInner {172+ require.Equal(t, '┌', frame.At(presentation.Bounds.X, presentation.Bounds.Y).Rune)
140- t.Fatalf("Composite() inner = %+v, want %+v", inner, wantInner)173+ require.Equal(t, '┐', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y).Rune)
174+ require.Equal(t, '└', frame.At(presentation.Bounds.X, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
175+ require.Equal(t, '┘', frame.At(presentation.Bounds.X+presentation.Bounds.Width-1, presentation.Bounds.Y+presentation.Bounds.Height-1).Rune)
176+ require.Equal(t, 'R', frame.At(37, presentation.Bounds.Y).Rune)
177+ require.True(t, frame.At(inner.X, inner.Y).Style.Equal(interior))
178+ assertCell(t, frame, presentation.Bounds.X-1, presentation.Bounds.Y, exterior)
179+}
180+
181+func TestCompositePresentationUsesBordersRatherThanMode(t *testing.T) {
182+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
183+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
184+ bounds := domain.Rect{X: 1, Y: 1, Width: 8, Height: 4}
185+ inner := domain.Rect{X: 2, Y: 2, Width: 6, Height: 2}
186+
187+ tests := []struct {
188+ name string
189+ presentation Presentation
190+ assert func(*testing.T, renderer.Frame)
191+ }{
192+ {
193+ name: "all edges render for drawer mode",
194+ presentation: Presentation{
195+ Mode: PresentationDrawer, Bounds: bounds, Inner: inner, Borders: BorderAll,
196+ },
197+ assert: func(t *testing.T, frame renderer.Frame) {
198+ require.Equal(t, '┌', frame.At(1, 1).Rune)
199+ require.Equal(t, '│', frame.At(1, 2).Rune)
200+ require.Equal(t, '┘', frame.At(8, 4).Rune)
201+ require.Equal(t, 'T', frame.At(2, 1).Rune)
202+ },
203+ },
204+ {
205+ name: "top edge alone renders for floating mode",
206+ presentation: Presentation{
207+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderTop,
208+ },
209+ assert: func(t *testing.T, frame renderer.Frame) {
210+ require.Equal(t, '─', frame.At(1, 1).Rune)
211+ require.Equal(t, 'T', frame.At(2, 1).Rune)
212+ assertCell(t, frame, 1, 2, exterior)
213+ assertCell(t, frame, 8, 4, exterior)
214+ },
215+ },
216+ {
217+ name: "title is hidden without top edge",
218+ presentation: Presentation{
219+ Mode: PresentationFloating, Bounds: bounds, Inner: inner, Borders: BorderLeft,
220+ },
221+ assert: func(t *testing.T, frame renderer.Frame) {
222+ require.Equal(t, '│', frame.At(1, 1).Rune)
223+ require.Equal(t, '│', frame.At(1, 4).Rune)
224+ assertCell(t, frame, 4, 1, exterior)
225+ assertCell(t, frame, 8, 4, exterior)
226+ },
227+ },
141 }228 }
142 229
143- assertRune(t, f, 2, 1, '┌')230+ for _, tt := range tests {
144- assertRune(t, f, 7, 1, '┐')231+ t.Run(tt.name, func(t *testing.T) {
145- assertRune(t, f, 2, 4, '└')232+ frame := renderer.NewFrame(10, 6)
146- assertRune(t, f, 7, 4, '┘')233+ FillRect(frame, domain.Rect{Width: 10, Height: 6}, exterior)
147- assertRune(t, f, 3, 1, '─')234+ (Modal{Title: "Title"}).CompositePresentation(frame, tt.presentation, border, renderer.DefaultStyle())
148- assertRune(t, f, 2, 2, '│')235+ tt.assert(t, frame)
149- assertRune(t, f, 4, 1, 'H')236+ })
150- assertRune(t, f, 5, 1, 'i')
151-
152- for y := inner.Y; y < inner.Y+inner.Height; y++ {
153- for x := inner.X; x < inner.X+inner.Width; x++ {
154- want := renderer.Cell{Rune: ' ', Style: interior}
155- if got := f.At(x, y); !got.Equal(want) {
156- t.Fatalf("interior cell (%d,%d) = %+v, want %+v", x, y, got, want)
157- }
158- }
159 }237 }
160- if got := f.At(2, 1).Style; !got.Equal(border) {238+}
161- t.Fatalf("border style = %+v, want %+v", got, border)239+
240+func TestCompositeDrawerChromeAndPreservesExterior(t *testing.T) {
241+ modal := Modal{FixedHeight: 4, Title: " Rename "}
242+ presentation := modal.Resolve(domain.Size{Cols: 20, Rows: 10})
243+ frame := renderer.NewFrame(20, 10)
244+ exterior := renderer.Cell{Rune: 'x', Style: renderer.Style{Foreground: 7, Background: 8}}
245+ FillRect(frame, domain.Rect{Width: 20, Height: 10}, exterior)
246+ border := renderer.Style{Bold: true, Foreground: 2, Background: -1}
247+ interior := renderer.Style{Foreground: 3, Background: 4}
248+
249+ inner := modal.CompositePresentation(frame, presentation, border, interior)
250+ require.Equal(t, presentation.Inner, inner)
251+ require.Equal(t, '─', frame.At(0, 5).Rune)
252+ require.True(t, frame.At(0, 5).Style.Equal(border))
253+ require.Equal(t, 'R', frame.At(7, 5).Rune)
254+ require.NotEqual(t, '│', frame.At(0, 6).Rune)
255+ require.Equal(t, ' ', frame.At(0, 6).Rune)
256+ require.NotEqual(t, '─', frame.At(0, 8).Rune)
257+ require.Equal(t, ' ', frame.At(0, 8).Rune)
258+
259+ for _, y := range []int{0, 1, 2, 3, 4, 9} {
260+ for x := range frame.Width {
261+ assertCell(t, frame, x, y, exterior)
262+ }
162 }263 }
163- assertCell(t, f, 0, 0, exterior)264+}
164- assertCell(t, f, 9, 5, exterior)265+
266+func TestCompositeDrawerClipsDoubleWidthTitle(t *testing.T) {
267+ modal := Modal{FixedHeight: 4, Title: "你你"}
268+ presentation := modal.Resolve(domain.Size{Cols: 4, Rows: 10})
269+ frame := renderer.NewFrame(4, 10)
270+ border := renderer.Style{Foreground: 2, Background: -1}
271+
272+ modal.CompositePresentation(frame, presentation, border, renderer.DefaultStyle())
273+
274+ require.Equal(t, '你', frame.At(1, presentation.Bounds.Y).Rune)
275+ require.True(t, frame.At(2, presentation.Bounds.Y).Continuation)
276+ require.Equal(t, '─', frame.At(3, presentation.Bounds.Y).Rune)
277+ require.False(t, frame.At(3, presentation.Bounds.Y).Continuation, "wide title must not leave an orphan continuation at the right clip")
165 }278 }
166 279
167 func TestFillRectClips(t *testing.T) {280 func TestFillRectClips(t *testing.T) {
@@ -221,26 +334,19 @@ func assertCell(t *testing.T, f renderer.Frame, x, y int, want renderer.Cell) {
221 }334 }
222 }335 }
223 336
224-func TestCompositePreservesTinyAndClippedGeometry(t *testing.T) {337+func TestCompositePresentationPreservesZeroHeightDrawer(t *testing.T) {
225 border := renderer.Style{Foreground: 1, Background: 2}338 border := renderer.Style{Foreground: 1, Background: 2}
226 interior := renderer.Style{Foreground: 3, Background: 4}339 interior := renderer.Style{Foreground: 3, Background: 4}
227- for _, tt := range []struct {340+ frame := renderer.NewFrame(2, 2)
228- name string341+ modal := Modal{WidthPct: 100, HeightPct: 100}
229- size domain.Size342+ presentation := modal.Resolve(domain.Size{Cols: 2, Rows: 2})
230- want domain.Rect343+
231- }{344+ got := modal.CompositePresentation(frame, presentation, border, interior)
232- {name: "one cell", size: domain.Size{Cols: 1, Rows: 1}, want: domain.Rect{X: 1, Y: 1}},345+
233- {name: "two cells", size: domain.Size{Cols: 2, Rows: 2}, want: domain.Rect{X: 1, Y: 1}},346+ require.Equal(t, presentation.Inner, got)
234- } {347+ for y := range frame.Height {
235- t.Run(tt.name, func(t *testing.T) {348+ for x := range frame.Width {
236- frame := renderer.NewFrame(tt.size.Cols, tt.size.Rows)349+ require.Equal(t, renderer.BlankCell(), frame.At(x, y))
237- got := (Modal{WidthPct: 100, HeightPct: 100}).Composite(frame, border, interior)350+ }
238- require.Equal(t, tt.want, got)
239- for y := range frame.Height {
240- for x := range frame.Width {
241- require.True(t, frame.At(x, y).Style.Equal(border), "tiny border remains clipped in bounds")
242- }
243- }
244- })
245 }351 }
246 }352 }
A internal/usecase/ui/presentation.go +75 -0
@@ -0,0 +1,75 @@
1+package ui
2+
3+import "github.com/bnema/vev/internal/domain"
4+
5+const (
6+ ResponsiveDrawerBreakpoint = 80
7+ DrawerContextRows = 3
8+ DrawerBottomRows = 1
9+)
10+
11+// PresentationMode describes whether an overlay uses its preferred floating
12+// geometry or becomes a narrow-terminal drawer.
13+type PresentationMode uint8
14+
15+const (
16+ PresentationFloating PresentationMode = iota
17+ PresentationDrawer
18+)
19+
20+// BorderEdges identifies the edges drawn around a presentation.
21+type BorderEdges uint8
22+
23+const (
24+ BorderTop BorderEdges = 1 << iota
25+ BorderRight
26+ BorderBottom
27+ BorderLeft
28+ BorderAll = BorderTop | BorderRight | BorderBottom | BorderLeft
29+)
30+
31+// Presentation is the resolved outer and content geometry for an overlay.
32+type Presentation struct {
33+ Mode PresentationMode
34+ Bounds domain.Rect
35+ Inner domain.Rect
36+ Borders BorderEdges
37+}
38+
39+// ResolvePresentation preserves preferred geometry on complete frames at or
40+// above the responsive breakpoint. Narrow frames use a full-width bottom
41+// drawer while reserving frame rows 0-2 and the bottom bar.
42+func ResolvePresentation(base domain.Size, preferredBounds, preferredInner domain.Rect) Presentation {
43+ if !base.Valid() {
44+ return Presentation{}
45+ }
46+ if base.Cols >= ResponsiveDrawerBreakpoint {
47+ return Presentation{
48+ Mode: PresentationFloating,
49+ Bounds: preferredBounds,
50+ Inner: preferredInner,
51+ Borders: BorderAll,
52+ }
53+ }
54+
55+ available := max(0, base.Rows-DrawerContextRows-DrawerBottomRows)
56+ height := min(max(preferredBounds.Height, 0), available)
57+ bounds := domain.Rect{
58+ X: 0,
59+ Y: base.Rows - DrawerBottomRows - height,
60+ Width: base.Cols,
61+ Height: height,
62+ }
63+ inner := domain.Rect{
64+ X: 0,
65+ Y: bounds.Y + min(1, height),
66+ Width: base.Cols,
67+ Height: max(0, height-1),
68+ }
69+ return Presentation{
70+ Mode: PresentationDrawer,
71+ Bounds: bounds,
72+ Inner: inner,
73+ Borders: BorderTop,
74+ }
75+}
@@ -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+}
@@ -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