detached e15edb0

feat(floating): render narrow terminals as drawers

commit
e15edb0d634088ba0e5dd4ca2848637e0ecda0a5
author
brice · · 2026-07-27 16:02 UTC
committer
brice · · 2026-07-27 17:06 UTC
parent
80d8cb6
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +314 -156
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 +60 -13
@@ -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,8 +147,7 @@ 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)
117152 blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
118153 damage := append([]renderer.Damage(nil), baseDamage...)
@@ -125,20 +160,32 @@ 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 {
167+ hasTitleBorder := geometry.Bounds.Height >= 3
168+ if geometry.Mode == ui.PresentationDrawer {
169+ hasTitleBorder = geometry.Bounds.Height > 0
170+ }
171+ if titleChanged && hasTitleBorder {
133172 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
134173 }
135174 return frame, damage
136175 }
137176
138-func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {
177+func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
178+ bounds := geometry.Bounds
139179 if bounds.Width <= 0 || bounds.Height <= 0 {
140180 return
141181 }
182+ if geometry.Mode == ui.PresentationDrawer {
183+ for x := bounds.X; x < bounds.X+bounds.Width; x++ {
184+ frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})
185+ }
186+ ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
187+ return
188+ }
142189 // Each axis omits its borders independently for tiny popups, matching
143190 // floatingInnerSize and leaving every cell available to the terminal.
144191 if bounds.Height >= 3 {
@@ -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,8 +147,7 @@ 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 blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
118 damage := append([]renderer.Damage(nil), baseDamage...)153 damage := append([]renderer.Damage(nil), baseDamage...)
@@ -125,20 +160,32 @@ 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
168+ if geometry.Mode == ui.PresentationDrawer {
169+ hasTitleBorder = geometry.Bounds.Height > 0
170+ }
171+ if titleChanged && hasTitleBorder {
133 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})172 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
134 }173 }
135 return frame, damage174 return frame, damage
136 }175 }
137 176
138-func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, style renderer.Style) {177+func drawFloatingBorder(frame renderer.Frame, geometry floatingGeometry, title string, style renderer.Style) {
178+ bounds := geometry.Bounds
139 if bounds.Width <= 0 || bounds.Height <= 0 {179 if bounds.Width <= 0 || bounds.Height <= 0 {
140 return180 return
141 }181 }
182+ if geometry.Mode == ui.PresentationDrawer {
183+ for x := bounds.X; x < bounds.X+bounds.Width; x++ {
184+ frame.Set(x, bounds.Y, renderer.Cell{Rune: '─', Style: style})
185+ }
186+ ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style)
187+ return
188+ }
142 // Each axis omits its borders independently for tiny popups, matching189 // Each axis omits its borders independently for tiny popups, matching
143 // floatingInnerSize and leaving every cell available to the terminal.190 // floatingInnerSize and leaving every cell available to the terminal.
144 if bounds.Height >= 3 {191 if bounds.Height >= 3 {
M internal/usecase/daemon/floating_render_test.go +149 -10
@@ -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 TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(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.Equal(t, []domain.Size{{Cols: 1, Rows: 1}}, pty.sizes())
207+ require.Equal(t, requested, floating.popupGeometry)
208+ require.Equal(t, domain.Rect{Width: 1, Height: 1}, floating.rect)
209+ require.Equal(t, 1, floating.screen.Frame.Width)
210+ require.Equal(t, 1, 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'})
@@ -401,12 +498,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
401498
402499 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
403500 frame := renderer.NewFrame(2, 1)
404- drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{})
501+ drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{})
405502 for _, cell := range frame.Cells {
406503 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
407504 }
408505 }
409506
507+func TestResponsiveFloatingDrawerDrawsOnlyTopSeparator(t *testing.T) {
508+ frame := renderer.NewFrame(12, 6)
509+ geometry := floatingGeometry{
510+ Mode: ui.PresentationDrawer,
511+ Bounds: domain.Rect{Y: 2, Width: 12, Height: 4},
512+ Inner: domain.Rect{Y: 3, Width: 12, Height: 3},
513+ }
514+ drawFloatingBorder(frame, geometry, "float", renderer.Style{})
515+
516+ require.Equal(t, "──float─────", rowText(frame.Row(2)))
517+ for y := 3; y < frame.Height; y++ {
518+ require.Equal(t, " ", rowText(frame.Row(y)), "drawer must not draw side or bottom borders")
519+ }
520+}
521+
522+func TestResponsiveFloatingPresentationModeInvalidatesCache(t *testing.T) {
523+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
524+ bounds := domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}
525+ inner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}
526+ floating := floatingGeometry{Mode: ui.PresentationDrawer, Bounds: bounds, Inner: inner}
527+ _, damage := composeCapturedFloatingFrame(floatingComposeInput{
528+ baseFrame: renderer.NewFrame(12, 7),
529+ floating: capturedFloatingRenderState{
530+ visible: true,
531+ pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)},
532+ geometry: floating,
533+ generation: 1,
534+ },
535+ content: content,
536+ cache: composeCacheInput{
537+ valid: true,
538+ floatingGeneration: 1,
539+ floatingGeometry: floatingGeometry{
540+ Mode: ui.PresentationFloating,
541+ Bounds: bounds,
542+ Inner: inner,
543+ }.translate(content.X, content.Y),
544+ },
545+ })
546+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
547+}
548+
410549 func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
411550 base := renderer.NewFrame(80, 24)
412551 content := domain.Rect{Y: 1, Width: 80, Height: 22}
@@ -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 TestResponsiveFloatingResizeCommitsZeroContentDrawerWithPTYFallback(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.Equal(t, []domain.Size{{Cols: 1, Rows: 1}}, pty.sizes())
207+ require.Equal(t, requested, floating.popupGeometry)
208+ require.Equal(t, domain.Rect{Width: 1, Height: 1}, floating.rect)
209+ require.Equal(t, 1, floating.screen.Frame.Width)
210+ require.Equal(t, 1, 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'})
@@ -401,12 +498,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
401 498
402 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {499 func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
403 frame := renderer.NewFrame(2, 1)500 frame := renderer.NewFrame(2, 1)
404- drawFloatingBorder(frame, domain.Rect{Width: 2, Height: 1}, "title", renderer.Style{})501+ drawFloatingBorder(frame, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}}, "title", renderer.Style{})
405 for _, cell := range frame.Cells {502 for _, cell := range frame.Cells {
406 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)503 require.Equal(t, renderer.BlankCell().Rune, cell.Rune)
407 }504 }
408 }505 }
409 506
507+func TestResponsiveFloatingDrawerDrawsOnlyTopSeparator(t *testing.T) {
508+ frame := renderer.NewFrame(12, 6)
509+ geometry := floatingGeometry{
510+ Mode: ui.PresentationDrawer,
511+ Bounds: domain.Rect{Y: 2, Width: 12, Height: 4},
512+ Inner: domain.Rect{Y: 3, Width: 12, Height: 3},
513+ }
514+ drawFloatingBorder(frame, geometry, "float", renderer.Style{})
515+
516+ require.Equal(t, "──float─────", rowText(frame.Row(2)))
517+ for y := 3; y < frame.Height; y++ {
518+ require.Equal(t, " ", rowText(frame.Row(y)), "drawer must not draw side or bottom borders")
519+ }
520+}
521+
522+func TestResponsiveFloatingPresentationModeInvalidatesCache(t *testing.T) {
523+ content := domain.Rect{Y: 1, Width: 12, Height: 5}
524+ bounds := domain.Rect{X: 2, Y: 1, Width: 6, Height: 3}
525+ inner := domain.Rect{X: 3, Y: 2, Width: 4, Height: 1}
526+ floating := floatingGeometry{Mode: ui.PresentationDrawer, Bounds: bounds, Inner: inner}
527+ _, damage := composeCapturedFloatingFrame(floatingComposeInput{
528+ baseFrame: renderer.NewFrame(12, 7),
529+ floating: capturedFloatingRenderState{
530+ visible: true,
531+ pane: capturedPaneRenderState{frame: renderer.NewFrame(4, 1)},
532+ geometry: floating,
533+ generation: 1,
534+ },
535+ content: content,
536+ cache: composeCacheInput{
537+ valid: true,
538+ floatingGeneration: 1,
539+ floatingGeometry: floatingGeometry{
540+ Mode: ui.PresentationFloating,
541+ Bounds: bounds,
542+ Inner: inner,
543+ }.translate(content.X, content.Y),
544+ },
545+ })
546+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
547+}
548+
410 func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {549 func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
411 base := renderer.NewFrame(80, 24)550 base := renderer.NewFrame(80, 24)
412 content := domain.Rect{Y: 1, Width: 80, Height: 22}551 content := domain.Rect{Y: 1, Width: 80, Height: 22}
M internal/usecase/daemon/overlay_backdrop.go +0 -29
@@ -6,35 +6,6 @@ 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- }
36-}
37-
389 // applyOverlayBackdrop dims every cell already composed into the complete
3910 // renderer frame. Modal composition calls it immediately before painting each
4011 // active layer so bars, panes, toasts, and lower overlays share one backdrop.
@@ -6,35 +6,6 @@ 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.
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- }
36-}
37-
38 // applyOverlayBackdrop dims every cell already composed into the complete9 // applyOverlayBackdrop dims every cell already composed into the complete
39 // renderer frame. Modal composition calls it immediately before painting each10 // renderer frame. Modal composition calls it immediately before painting each
40 // active layer so bars, panes, toasts, and lower overlays share one backdrop.11 // active layer so bars, panes, toasts, and lower overlays share one backdrop.
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/render_pipeline.go +20 -20
@@ -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,19 +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- var toastFootprints []domain.Rect
163- if overlaysActive || toastsVisible {
164- // Without a floating frame, overlay composition would otherwise mutate
165- // the base cache in place. Floating composition already owns a clone.
166- if !state.floating.visible {
167- baseFrame = frame.Clone()
168- }
169- frame, damage = composeCapturedCopyMode(state, frame, damage, content)
170- toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
171- frame, damage = composeCapturedOverlays(state, frame, damage)
172- }
173173 if full || overlaysActive {
174174 damage = []renderer.Damage{renderer.FullRedraw()}
175175 } else {
@@ -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,19 +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- var toastFootprints []domain.Rect
163- if overlaysActive || toastsVisible {
164- // Without a floating frame, overlay composition would otherwise mutate
165- // the base cache in place. Floating composition already owns a clone.
166- if !state.floating.visible {
167- baseFrame = frame.Clone()
168- }
169- frame, damage = composeCapturedCopyMode(state, frame, damage, content)
170- toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
171- frame, damage = composeCapturedOverlays(state, frame, damage)
172- }
173 if full || overlaysActive {173 if full || overlaysActive {
174 damage = []renderer.Damage{renderer.FullRedraw()}174 damage = []renderer.Damage{renderer.FullRedraw()}
175 } else {175 } else {
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 +49 -1
@@ -227,7 +227,7 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
227227 reset: true,
228228 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}}}},
229229 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()}}},
230- 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},
231231 bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"},
232232 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}},
233233 cursor: capturedCursorInputs{row: 1, col: 2, visible: true, renderable: true, content: domain.Rect{X: 4, Y: 2, Width: 4, Height: 1}},
@@ -403,6 +403,54 @@ func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T)
403403 require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned")
404404 }
405405
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+
406454 func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
407455 theme := backdropTheme()
408456 lowerStyle := renderer.Style{Foreground: 1, Background: 2}
@@ -227,7 +227,7 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
227 reset: true,227 reset: true,
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}}}},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}}}},
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()}}},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()}}},
230- 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},
231 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"},
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}},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}},
233 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}},
@@ -403,6 +403,54 @@ func TestComposeFrameModalBackdropDimsCompleteFrameIncludingToasts(t *testing.T)
403 require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned")403 require.Equal(t, base.cache.frame.Cells, composed.cache.frame.Cells, "the reusable cache remains toast-free and unadorned")
404 }404 }
405 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+
406 func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {454 func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) {
407 theme := backdropTheme()455 theme := backdropTheme()
408 lowerStyle := renderer.Style{Foreground: 1, Background: 2}456 lowerStyle := renderer.Style{Foreground: 1, Background: 2}
M internal/usecase/daemon/transactional_resize.go +4 -4
@@ -366,7 +366,7 @@ 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() {
370370 return nil, true
371371 }
372372
@@ -374,7 +374,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
374374 // floating slot and tab size are revalidated. A newer client resize may
375375 // otherwise publish this obsolete popup geometry after its PTY call returns.
376376 plan := preparedTabLayout{members: []resizeMember{{
377- session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,
377+ session: sess, tab: tb, pane: p, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,
378378 }}}
379379 d.applyPreparedTabMembers(&plan)
380380
@@ -387,11 +387,11 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
387387 }
388388 if currentSlot {
389389 p.mu.Lock()
390- p.rect = geometry.Inner
390+ p.rect = plan.members[0].rect
391391 p.popupGeometry = geometry
392392 if plan.members[0].ok {
393393 p.resizeRetry = false
394- p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)
394+ p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
395395 }
396396 p.mu.Unlock()
397397 }
@@ -366,7 +366,7 @@ 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, true370 return nil, true
371 }371 }
372 372
@@ -374,7 +374,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
374 // floating slot and tab size are revalidated. A newer client resize may374 // floating slot and tab size are revalidated. A newer client resize may
375 // otherwise publish this obsolete popup geometry after its PTY call returns.375 // otherwise publish this obsolete popup geometry after its PTY call returns.
376 plan := preparedTabLayout{members: []resizeMember{{376 plan := preparedTabLayout{members: []resizeMember{{
377- session: sess, tab: tb, pane: p, rect: geometry.Inner, floating: geometry, isFloating: true, floatingGeneration: generation,377+ session: sess, tab: tb, pane: p, rect: geometry.ptyRect(), floating: geometry, isFloating: true, floatingGeneration: generation,
378 }}}378 }}}
379 d.applyPreparedTabMembers(&plan)379 d.applyPreparedTabMembers(&plan)
380 380
@@ -387,11 +387,11 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur
387 }387 }
388 if currentSlot {388 if currentSlot {
389 p.mu.Lock()389 p.mu.Lock()
390- p.rect = geometry.Inner390+ p.rect = plan.members[0].rect
391 p.popupGeometry = geometry391 p.popupGeometry = geometry
392 if plan.members[0].ok {392 if plan.members[0].ok {
393 p.resizeRetry = false393 p.resizeRetry = false
394- p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height)394+ p.screen.Resize(plan.members[0].rect.Width, plan.members[0].rect.Height)
395 }395 }
396 p.mu.Unlock()396 p.mu.Unlock()
397 }397 }

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