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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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, | |||
| 136 | 136 | p.mu.Lock() | |
| 137 | 137 | geometry := p.committedFloatingGeometryLocked(desired) | |
| 138 | 138 | p.mu.Unlock() | |
| 139 | - | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |
| 139 | + | if !geometry.committable() { | |
| 140 | 140 | return nil, floatingGeometry{}, false | |
| 141 | 141 | } | |
| 142 | 142 | return p, geometry, true | |
| @@ -316,11 +316,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 316 | 316 | sess.mu.Unlock() | |
| 317 | 317 | tb.mu.Lock() | |
| 318 | 318 | 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()) | |
| 324 | 320 | focused := tb.focusedPane() | |
| 325 | 321 | tabStableID, tabCtx := tb.stableID, tb.ctx | |
| 326 | 322 | tb.mu.Unlock() | |
| @@ -385,7 +381,7 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 385 | 381 | return | |
| 386 | 382 | } | |
| 387 | 383 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | |
| 388 | - | p.rect = spec.geometry.Inner | |
| 384 | + | p.rect = spec.geometry.ptyRect() | |
| 389 | 385 | p.popupGeometry = spec.geometry | |
| 390 | 386 | p.title.displayFallback = spec.fallback | |
| 391 | 387 | // 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{}, false | 140 | return nil, floatingGeometry{}, false | ||
| 141 | } | 141 | } | ||
| 142 | return p, geometry, true | 142 | 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.ctx | 321 | 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 | return | 381 | 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.Inner | 384 | + | p.rect = spec.geometry.ptyRect() |
| 389 | p.popupGeometry = spec.geometry | 385 | p.popupGeometry = spec.geometry | ||
| 390 | p.title.displayFallback = spec.fallback | 386 | p.title.displayFallback = spec.fallback | ||
| 391 | // CommandContext owns the child through openCtx, so retain both this context | 387 | // 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 ( | |||
| 13 | 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | |
| 14 | 14 | // must not translate a composed or cached value again. | |
| 15 | 15 | type floatingGeometry struct { | |
| 16 | + | Mode ui.PresentationMode | |
| 16 | 17 | Bounds domain.Rect | |
| 17 | 18 | Inner domain.Rect | |
| 18 | 19 | } | |
| @@ -21,6 +22,22 @@ func (g floatingGeometry) valid() bool { | |||
| 21 | 22 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 25 | + | // committable accepts a drawer with no terminal content. Its presentation must | |
| 26 | + | // remain canonical even though the PTY and VT screen use a 1x1 fallback. | |
| 27 | + | func (g floatingGeometry) committable() bool { | |
| 28 | + | if g.Mode == ui.PresentationDrawer { | |
| 29 | + | return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0 | |
| 30 | + | } | |
| 31 | + | return g.valid() | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (g floatingGeometry) ptyRect() domain.Rect { | |
| 35 | + | if g.Inner.Width > 0 && g.Inner.Height > 0 { | |
| 36 | + | return g.Inner | |
| 37 | + | } | |
| 38 | + | return domain.Rect{Width: 1, Height: 1} | |
| 39 | + | } | |
| 40 | + | ||
| 24 | 41 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |
| 25 | 42 | g.Bounds.X += dx | |
| 26 | 43 | g.Bounds.Y += dy | |
| @@ -33,7 +50,7 @@ func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | |||
| 33 | 50 | // last committed. Newly constructed test panes fall back to the requested | |
| 34 | 51 | // geometry until their first resize. The caller holds p.mu. | |
| 35 | 52 | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | |
| 36 | - | if p.popupGeometry.valid() { | |
| 53 | + | if p.popupGeometry.committable() { | |
| 37 | 54 | return p.popupGeometry | |
| 38 | 55 | } | |
| 39 | 56 | return fallback | |
| @@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry | |||
| 58 | 75 | return axis | |
| 59 | 76 | } | |
| 60 | 77 | ||
| 61 | - | // calculateContentFloatingGeometry returns origin-zero, tab-content-relative | |
| 62 | - | // coordinates for a percentage-sized popup. Launch sizing derives from Inner | |
| 63 | - | // too, so rendering and PTY geometry share the same per-axis percentage and | |
| 64 | - | // tiny-border rules. | |
| 65 | - | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |
| 78 | + | // calculatePreferredFloatingGeometry retains the canonical percentage and | |
| 79 | + | // tiny-axis rules used by wide floating terminals. | |
| 80 | + | func calculatePreferredFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |
| 66 | 81 | if !content.Valid() { | |
| 67 | 82 | return floatingGeometry{} | |
| 68 | 83 | } | |
| @@ -75,6 +90,7 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||
| 75 | 90 | Height: y.BoundsSize, | |
| 76 | 91 | } | |
| 77 | 92 | return floatingGeometry{ | |
| 93 | + | Mode: ui.PresentationFloating, | |
| 78 | 94 | Bounds: bounds, | |
| 79 | 95 | Inner: domain.Rect{ | |
| 80 | 96 | X: bounds.X + x.BorderOffset, | |
| @@ -85,12 +101,33 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo | |||
| 85 | 101 | } | |
| 86 | 102 | } | |
| 87 | 103 | ||
| 104 | + | // calculateContentFloatingGeometry returns canonical tab-content-relative | |
| 105 | + | // coordinates. Presentation resolution consumes complete-frame coordinates, | |
| 106 | + | // so the preferred geometry crosses the top-bar boundary exactly once in each | |
| 107 | + | // direction. | |
| 108 | + | func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingConfig) floatingGeometry { | |
| 109 | + | preferred := calculatePreferredFloatingGeometry(content, cfg) | |
| 110 | + | if !preferred.valid() { | |
| 111 | + | return floatingGeometry{} | |
| 112 | + | } | |
| 113 | + | framePreferred := preferred.translate(0, 1) | |
| 114 | + | presentation := ui.ResolvePresentation( | |
| 115 | + | domain.Size{Cols: content.Cols, Rows: content.Rows + 2}, | |
| 116 | + | framePreferred.Bounds, | |
| 117 | + | framePreferred.Inner, | |
| 118 | + | ) | |
| 119 | + | return floatingGeometry{ | |
| 120 | + | Mode: presentation.Mode, | |
| 121 | + | Bounds: presentation.Bounds, | |
| 122 | + | Inner: presentation.Inner, | |
| 123 | + | }.translate(0, -1) | |
| 124 | + | } | |
| 125 | + | ||
| 88 | 126 | type floatingComposeInput struct { | |
| 89 | 127 | baseFrame renderer.Frame | |
| 90 | 128 | baseDamage []renderer.Damage | |
| 91 | 129 | floating capturedFloatingRenderState | |
| 92 | 130 | content domain.Rect | |
| 93 | - | layout capturedTabLayout | |
| 94 | 131 | theme themeui.Theme | |
| 95 | 132 | borderMuted renderer.Style | |
| 96 | 133 | borderActive renderer.Style | |
| @@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 103 | 140 | baseDamage := input.baseDamage | |
| 104 | 141 | floating := input.floating | |
| 105 | 142 | content := input.content | |
| 106 | - | layoutSnap := input.layout | |
| 107 | 143 | theme := input.theme | |
| 108 | 144 | borderMuted := input.borderMuted | |
| 109 | 145 | borderActive := input.borderActive | |
| @@ -111,8 +147,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 111 | 147 | full := input.full | |
| 112 | 148 | ||
| 113 | 149 | frame := base.Clone() | |
| 114 | - | layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid} | |
| 115 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme) | |
| 150 | + | applyOverlayBackdrop(frame, theme) | |
| 116 | 151 | geometry := floating.geometry.translate(content.X, content.Y) | |
| 117 | 152 | blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme)) | |
| 118 | 153 | damage := append([]renderer.Damage(nil), baseDamage...) | |
| @@ -125,20 +160,32 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||
| 125 | 160 | if floating.focused { | |
| 126 | 161 | border = borderActive | |
| 127 | 162 | } | |
| 128 | - | drawFloatingBorder(frame, geometry.Bounds, floating.title, border) | |
| 163 | + | drawFloatingBorder(frame, geometry, floating.title, border) | |
| 129 | 164 | if full || popupChanged { | |
| 130 | 165 | return frame, []renderer.Damage{renderer.FullRedraw()} | |
| 131 | 166 | } | |
| 132 | - | if titleChanged && geometry.Bounds.Height >= 3 { | |
| 167 | + | hasTitleBorder := geometry.Bounds.Height >= 3 | |
| 168 | + | if geometry.Mode == ui.PresentationDrawer { | |
| 169 | + | hasTitleBorder = geometry.Bounds.Height > 0 | |
| 170 | + | } | |
| 171 | + | if titleChanged && hasTitleBorder { | |
| 133 | 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 | 174 | 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 | 179 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 140 | 180 | 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 | 189 | // Each axis omits its borders independently for tiny popups, matching | |
| 143 | 190 | // floatingInnerSize and leaving every cell available to the terminal. | |
| 144 | 191 | if bounds.Height >= 3 { | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | 13 | // 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.Rect | 17 | Bounds domain.Rect | ||
| 17 | Inner domain.Rect | 18 | 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 > 0 | 22 | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | ||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 25 | + | // committable accepts a drawer with no terminal content. Its presentation must | |||
| 26 | + | // remain canonical even though the PTY and VT screen use a 1x1 fallback. | |||
| 27 | + | func (g floatingGeometry) committable() bool { | |||
| 28 | + | if g.Mode == ui.PresentationDrawer { | |||
| 29 | + | return g.Bounds.Width > 0 && g.Bounds.Height >= 0 && g.Inner.Width > 0 && g.Inner.Height >= 0 | |||
| 30 | + | } | |||
| 31 | + | return g.valid() | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (g floatingGeometry) ptyRect() domain.Rect { | |||
| 35 | + | if g.Inner.Width > 0 && g.Inner.Height > 0 { | |||
| 36 | + | return g.Inner | |||
| 37 | + | } | |||
| 38 | + | return domain.Rect{Width: 1, Height: 1} | |||
| 39 | + | } | |||
| 40 | + | ||||
| 24 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | 41 | func (g floatingGeometry) translate(dx, dy int) floatingGeometry { | ||
| 25 | g.Bounds.X += dx | 42 | g.Bounds.X += dx | ||
| 26 | g.Bounds.Y += dy | 43 | 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 requested | 50 | // 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.popupGeometry | 54 | return p.popupGeometry | ||
| 38 | } | 55 | } | ||
| 39 | return fallback | 56 | return fallback | ||
| @@ -58,11 +75,9 @@ func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry | |||||
| 58 | return axis | 75 | return axis | ||
| 59 | } | 76 | } | ||
| 60 | 77 | ||||
| 61 | - | // calculateContentFloatingGeometry returns origin-zero, tab-content-relative | 78 | + | // calculatePreferredFloatingGeometry retains the canonical percentage and |
| 62 | - | // coordinates for a percentage-sized popup. Launch sizing derives from Inner | 79 | + | // tiny-axis rules used by wide floating terminals. |
| 63 | - | // too, so rendering and PTY geometry share the same per-axis percentage and | 80 | + | 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.Frame | 127 | baseFrame renderer.Frame | ||
| 90 | baseDamage []renderer.Damage | 128 | baseDamage []renderer.Damage | ||
| 91 | floating capturedFloatingRenderState | 129 | floating capturedFloatingRenderState | ||
| 92 | content domain.Rect | 130 | content domain.Rect | ||
| 93 | - | layout capturedTabLayout | |||
| 94 | theme themeui.Theme | 131 | theme themeui.Theme | ||
| 95 | borderMuted renderer.Style | 132 | borderMuted renderer.Style | ||
| 96 | borderActive renderer.Style | 133 | borderActive renderer.Style | ||
| @@ -103,7 +140,6 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 103 | baseDamage := input.baseDamage | 140 | baseDamage := input.baseDamage | ||
| 104 | floating := input.floating | 141 | floating := input.floating | ||
| 105 | content := input.content | 142 | content := input.content | ||
| 106 | - | layoutSnap := input.layout | |||
| 107 | theme := input.theme | 143 | theme := input.theme | ||
| 108 | borderMuted := input.borderMuted | 144 | borderMuted := input.borderMuted | ||
| 109 | borderActive := input.borderActive | 145 | borderActive := input.borderActive | ||
| @@ -111,8 +147,7 @@ func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, [ | |||||
| 111 | full := input.full | 147 | 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 = borderActive | 161 | 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, damage | 174 | 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 | return | 180 | 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, matching | 189 | // 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 ( | |||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/usecase/layout" | |
| 14 | 14 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 15 | 16 | "github.com/bnema/vev/pkg/renderer" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| @@ -39,21 +40,66 @@ func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometr | |||
| 39 | 40 | ||
| 40 | 41 | func TestFloatingGeometryTranslate(t *testing.T) { | |
| 41 | 42 | geometry := floatingGeometry{ | |
| 43 | + | Mode: ui.PresentationDrawer, | |
| 42 | 44 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| 43 | 45 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | 48 | translated := geometry.translate(11, 13) | |
| 47 | 49 | require.Equal(t, floatingGeometry{ | |
| 50 | + | Mode: ui.PresentationDrawer, | |
| 48 | 51 | Bounds: domain.Rect{X: 14, Y: 18, Width: 10, Height: 8}, | |
| 49 | 52 | Inner: domain.Rect{X: 15, Y: 19, Width: 8, Height: 6}, | |
| 50 | 53 | }, translated) | |
| 51 | 54 | require.Equal(t, floatingGeometry{ | |
| 55 | + | Mode: ui.PresentationDrawer, | |
| 52 | 56 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| 53 | 57 | Inner: domain.Rect{X: 4, Y: 6, Width: 8, Height: 6}, | |
| 54 | 58 | }, geometry) | |
| 55 | 59 | } | |
| 56 | 60 | ||
| 61 | + | func TestResponsiveFloatingGeometry(t *testing.T) { | |
| 62 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 63 | + | tests := []struct { | |
| 64 | + | name string | |
| 65 | + | content domain.Size | |
| 66 | + | want floatingGeometry | |
| 67 | + | }{ | |
| 68 | + | { | |
| 69 | + | name: "narrow full height becomes content-relative drawer", | |
| 70 | + | content: domain.Size{Cols: 79, Rows: 22}, | |
| 71 | + | want: floatingGeometry{ | |
| 72 | + | Mode: ui.PresentationDrawer, | |
| 73 | + | Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 20}, | |
| 74 | + | Inner: domain.Rect{X: 0, Y: 3, Width: 79, Height: 19}, | |
| 75 | + | }, | |
| 76 | + | }, | |
| 77 | + | { | |
| 78 | + | name: "breakpoint preserves centered floating geometry", | |
| 79 | + | content: domain.Size{Cols: 80, Rows: 22}, | |
| 80 | + | want: floatingGeometry{ | |
| 81 | + | Mode: ui.PresentationFloating, | |
| 82 | + | Bounds: domain.Rect{Width: 80, Height: 22}, | |
| 83 | + | Inner: domain.Rect{X: 1, Y: 1, Width: 78, Height: 20}, | |
| 84 | + | }, | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | name: "tiny narrow frame retains zero-content drawer", | |
| 88 | + | content: domain.Size{Cols: 79, Rows: 2}, | |
| 89 | + | want: floatingGeometry{ | |
| 90 | + | Mode: ui.PresentationDrawer, | |
| 91 | + | Bounds: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0}, | |
| 92 | + | Inner: domain.Rect{X: 0, Y: 2, Width: 79, Height: 0}, | |
| 93 | + | }, | |
| 94 | + | }, | |
| 95 | + | } | |
| 96 | + | for _, tt := range tests { | |
| 97 | + | t.Run(tt.name, func(t *testing.T) { | |
| 98 | + | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, cfg)) | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | ||
| 57 | 103 | func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |
| 58 | 104 | const frameX, frameY = 7, 3 | |
| 59 | 105 | tests := []struct { | |
| @@ -93,26 +139,77 @@ func TestFloatingFrameGeometryPreservesBorderRules(t *testing.T) { | |||
| 93 | 139 | } | |
| 94 | 140 | } | |
| 95 | 141 | ||
| 96 | - | func TestCalculateFloatingGeometry(t *testing.T) { | |
| 142 | + | func TestCalculatePreferredFloatingGeometry(t *testing.T) { | |
| 97 | 143 | tests := []struct { | |
| 98 | 144 | name string | |
| 99 | 145 | content domain.Size | |
| 100 | 146 | cfg domain.FloatingConfig | |
| 101 | 147 | want floatingGeometry | |
| 102 | 148 | }{ | |
| 103 | - | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, | |
| 104 | - | {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}}, | |
| 105 | - | {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}}, | |
| 106 | - | {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}}, | |
| 107 | - | {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, | |
| 149 | + | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, | |
| 150 | + | {"one percent clamps to one", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 1, Height: 1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}, Inner: domain.Rect{X: 49, Y: 9, Width: 1, Height: 1}}}, | |
| 151 | + | {"full size", domain.Size{Cols: 100, Rows: 20}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 100, Height: 20}, Inner: domain.Rect{X: 1, Y: 1, Width: 98, Height: 18}}}, | |
| 152 | + | {"tiny axes omit borders", domain.Size{Cols: 2, Rows: 1}, domain.FloatingConfig{Width: 100, Height: 100}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 2, Height: 1}, Inner: domain.Rect{Width: 2, Height: 1}}}, | |
| 153 | + | {"percent clamps", domain.Size{Cols: 10, Rows: 10}, domain.FloatingConfig{Width: 101, Height: -1}, floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{Width: 10, Height: 1, Y: 4}, Inner: domain.Rect{X: 1, Width: 8, Y: 4, Height: 1}}}, | |
| 108 | 154 | } | |
| 109 | 155 | for _, tt := range tests { | |
| 110 | 156 | t.Run(tt.name, func(t *testing.T) { | |
| 111 | - | require.Equal(t, tt.want, calculateContentFloatingGeometry(tt.content, tt.cfg)) | |
| 157 | + | require.Equal(t, tt.want, calculatePreferredFloatingGeometry(tt.content, tt.cfg)) | |
| 112 | 158 | }) | |
| 113 | 159 | } | |
| 114 | 160 | } | |
| 115 | 161 | ||
| 162 | + | func TestResponsiveFloatingZeroContentDrawerRemainsCommittedAndTargeted(t *testing.T) { | |
| 163 | + | cfg := domain.FloatingConfig{Width: 100, Height: 100} | |
| 164 | + | geometry := calculateContentFloatingGeometry(domain.Size{Cols: 79, Rows: 2}, cfg) | |
| 165 | + | require.False(t, geometry.valid()) | |
| 166 | + | require.True(t, geometry.committable()) | |
| 167 | + | require.Equal(t, domain.Rect{Width: 1, Height: 1}, geometry.ptyRect()) | |
| 168 | + | ||
| 169 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 170 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 171 | + | tb := newTab(nil, domain.Size{Cols: 79, Rows: 2}) | |
| 172 | + | sess := &session{name: "test", tabs: []*tab{tb}, ctx: t.Context()} | |
| 173 | + | spec, err := d.newFloatingLaunchSpec(sess, tb, cfg, true) | |
| 174 | + | require.NoError(t, err) | |
| 175 | + | require.Equal(t, geometry, spec.geometry, "the 1x1 PTY fallback must not replace drawer presentation") | |
| 176 | + | require.Equal(t, domain.Size{Cols: 1, Rows: 1}, spec.size) | |
| 177 | + | ||
| 178 | + | floating := newPane("floating", nil, spec.size) | |
| 179 | + | floating.popupGeometry = spec.geometry | |
| 180 | + | installTestFloating(tb, floating, true) | |
| 181 | + | tb.mu.Lock() | |
| 182 | + | gotPane, gotGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 183 | + | require.Same(t, floating, tb.terminalTargetLocked()) | |
| 184 | + | tb.mu.Unlock() | |
| 185 | + | require.True(t, visible) | |
| 186 | + | require.Same(t, floating, gotPane) | |
| 187 | + | require.Equal(t, geometry, gotGeometry) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func 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 | 213 | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |
| 117 | 214 | tests := []struct { | |
| 118 | 215 | name string | |
| @@ -133,9 +230,9 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |||
| 133 | 230 | } | |
| 134 | 231 | ||
| 135 | 232 | func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |
| 136 | - | base := renderer.NewFrame(40, 12) | |
| 233 | + | base := renderer.NewFrame(80, 12) | |
| 137 | 234 | base.Set(2, 2, renderer.Cell{Rune: 'B'}) | |
| 138 | - | content := domain.Rect{Y: 1, Width: 40, Height: 10} | |
| 235 | + | content := domain.Rect{Y: 1, Width: 80, Height: 10} | |
| 139 | 236 | geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}) | |
| 140 | 237 | paneFrame := renderer.NewFrame(6, 3) | |
| 141 | 238 | paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |
| @@ -401,12 +498,54 @@ func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) { | |||
| 401 | 498 | ||
| 402 | 499 | func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |
| 403 | 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 | 502 | for _, cell := range frame.Cells { | |
| 406 | 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 | 549 | func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) { | |
| 411 | 550 | base := renderer.NewFrame(80, 24) | |
| 412 | 551 | 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, 3 | 104 | 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 string | 144 | name string | ||
| 99 | content domain.Size | 145 | content domain.Size | ||
| 100 | cfg domain.FloatingConfig | 146 | cfg domain.FloatingConfig | ||
| 101 | want floatingGeometry | 147 | want floatingGeometry | ||
| 102 | }{ | 148 | }{ | ||
| 103 | - | {"eighty percent centered", domain.Size{Cols: 101, Rows: 51}, domain.FloatingConfig{Width: 80, Height: 80}, floatingGeometry{Bounds: domain.Rect{X: 10, Y: 5, Width: 80, Height: 40}, Inner: domain.Rect{X: 11, Y: 6, Width: 78, Height: 38}}}, | 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 string | 215 | 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 ( | |||
| 6 | 6 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | // overlayBackdrop controls optional dimming of pane content behind an overlay. | |
| 10 | - | // Chrome (bars, dividers, and pane titles) is deliberately not part of the | |
| 11 | - | // backdrop. | |
| 12 | - | type overlayBackdrop struct { | |
| 13 | - | DimPaneContents bool | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | // apply dims the content rectangles from a tab-content-relative layout | |
| 17 | - | // snapshot. A failed solve follows the tab renderer's focused-pane fallback by | |
| 18 | - | // dimming the complete content area. | |
| 19 | - | func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, layoutSnap tabLayoutSnapshot, theme themeui.Theme) { | |
| 20 | - | if !b.DimPaneContents || contentArea.Width <= 0 || contentArea.Height <= 0 { | |
| 21 | - | return | |
| 22 | - | } | |
| 23 | - | if !layoutSnap.ok { | |
| 24 | - | dimFrameRect(frame, contentArea, theme) | |
| 25 | - | return | |
| 26 | - | } | |
| 27 | - | for _, placement := range layoutSnap.placements { | |
| 28 | - | if placement.Collapsed || placement.Content.Width <= 0 || placement.Content.Height <= 0 { | |
| 29 | - | continue | |
| 30 | - | } | |
| 31 | - | r := placement.Content | |
| 32 | - | r.X += contentArea.X | |
| 33 | - | r.Y += contentArea.Y | |
| 34 | - | dimFrameRect(frame, r, theme) | |
| 35 | - | } | |
| 36 | - | } | |
| 37 | - | ||
| 38 | 9 | // applyOverlayBackdrop dims every cell already composed into the complete | |
| 39 | 10 | // renderer frame. Modal composition calls it immediately before painting each | |
| 40 | 11 | // 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 complete | 9 | // applyOverlayBackdrop dims every cell already composed into the complete | ||
| 39 | // renderer frame. Modal composition calls it immediately before painting each | 10 | // 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 ( | |||
| 6 | 6 | "github.com/stretchr/testify/require" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/domain" | |
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |
| 10 | 9 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 11 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 11 | ) | |
| @@ -15,82 +14,39 @@ func backdropTheme() themeui.Theme { | |||
| 15 | 14 | return themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 220, G: 220, B: 220}, Background: renderer.RGB{R: 10, G: 10, B: 10}} | |
| 16 | 15 | } | |
| 17 | 16 | ||
| 18 | - | func TestOverlayBackdropDimsOnlyTranslatedPaneContents(t *testing.T) { | |
| 19 | - | frame := renderer.NewFrame(8, 6) | |
| 17 | + | func TestApplyOverlayBackdropDimsCompleteFrame(t *testing.T) { | |
| 18 | + | frame := renderer.NewFrame(4, 3) | |
| 20 | 19 | original := renderer.Cell{Rune: '界', Style: renderer.DefaultStyle()} | |
| 21 | - | frame.Set(2, 2, original) | |
| 22 | - | frame.Set(3, 2, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | |
| 23 | - | frame.Set(2, 1, renderer.Cell{Rune: 'T', Style: renderer.DefaultStyle()}) // title | |
| 24 | - | frame.Set(0, 0, renderer.Cell{Rune: 'B', Style: renderer.DefaultStyle()}) // top bar | |
| 25 | - | frame.Set(0, 5, renderer.Cell{Rune: 'S', Style: renderer.DefaultStyle()}) // bottom bar | |
| 26 | - | frame.Set(6, 3, renderer.Cell{Rune: 'C', Style: renderer.DefaultStyle()}) // collapsed | |
| 27 | - | ||
| 28 | - | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{ | |
| 29 | - | {ID: "visible", TitleBar: domain.Rect{X: 1, Y: 0, Width: 4, Height: 1}, Content: domain.Rect{X: 1, Y: 1, Width: 4, Height: 2}}, | |
| 30 | - | {ID: "collapsed", Collapsed: true, Content: domain.Rect{X: 6, Y: 2, Width: 1, Height: 1}}, | |
| 31 | - | }} | |
| 32 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Y: 1, Width: 8, Height: 4}, snap, backdropTheme()) | |
| 33 | - | ||
| 34 | - | require.Equal(t, '界', frame.At(2, 2).Rune, "content is translated by the content-area origin") | |
| 35 | - | require.True(t, frame.At(2, 2).Style.HasForegroundRGB) | |
| 36 | - | require.True(t, frame.At(3, 2).Continuation, "continuation markers must survive dimming") | |
| 37 | - | require.Zero(t, frame.At(3, 2).Rune) | |
| 38 | - | require.False(t, frame.At(2, 1).Style.HasForegroundRGB, "pane title must remain crisp") | |
| 39 | - | require.False(t, frame.At(0, 0).Style.HasForegroundRGB, "top bar must remain crisp") | |
| 40 | - | require.False(t, frame.At(0, 5).Style.HasForegroundRGB, "bottom bar must remain crisp") | |
| 41 | - | require.False(t, frame.At(6, 3).Style.HasForegroundRGB, "collapsed pane content must not be dimmed") | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) { | |
| 45 | - | tests := []struct { | |
| 46 | - | name string | |
| 47 | - | content domain.Rect | |
| 48 | - | affected [][2]int | |
| 49 | - | unaffected [][2]int | |
| 50 | - | }{ | |
| 51 | - | { | |
| 52 | - | name: "negative origin", | |
| 53 | - | content: domain.Rect{X: -1, Y: -1, Width: 3, Height: 3}, | |
| 54 | - | affected: [][2]int{{0, 0}, {1, 1}}, | |
| 55 | - | unaffected: [][2]int{{2, 2}, {3, 3}}, | |
| 56 | - | }, | |
| 57 | - | { | |
| 58 | - | name: "right and bottom overflow", | |
| 59 | - | content: domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, | |
| 60 | - | affected: [][2]int{{2, 2}, {3, 3}}, | |
| 61 | - | unaffected: [][2]int{{0, 0}, {1, 1}}, | |
| 62 | - | }, | |
| 20 | + | for y := range frame.Height { | |
| 21 | + | for x := range frame.Width { | |
| 22 | + | frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*frame.Width + x), Style: renderer.DefaultStyle()}) | |
| 23 | + | } | |
| 63 | 24 | } | |
| 64 | - | ||
| 65 | - | for _, tt := range tests { | |
| 66 | - | t.Run(tt.name, func(t *testing.T) { | |
| 67 | - | frame := renderer.NewFrame(4, 4) | |
| 68 | - | original := renderer.DefaultStyle() | |
| 69 | - | snap := tabLayoutSnapshot{ok: true, placements: []layout.Placement{{Content: tt.content}}} | |
| 70 | - | ||
| 71 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme()) | |
| 72 | - | ||
| 73 | - | for _, point := range tt.affected { | |
| 74 | - | require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), frame.At(point[0], point[1]).Style) | |
| 75 | - | } | |
| 76 | - | for _, point := range tt.unaffected { | |
| 77 | - | require.Equal(t, original, frame.At(point[0], point[1]).Style) | |
| 78 | - | } | |
| 79 | - | }) | |
| 25 | + | frame.Set(1, 1, original) | |
| 26 | + | frame.Set(2, 1, renderer.Cell{Continuation: true, Style: renderer.DefaultStyle()}) | |
| 27 | + | ||
| 28 | + | applyOverlayBackdrop(frame, backdropTheme()) | |
| 29 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | |
| 30 | + | for y := range frame.Height { | |
| 31 | + | for x := range frame.Width { | |
| 32 | + | require.Equal(t, dimmed, frame.At(x, y).Style, "cell (%d,%d)", x, y) | |
| 33 | + | } | |
| 80 | 34 | } | |
| 35 | + | require.Equal(t, '界', frame.At(1, 1).Rune) | |
| 36 | + | require.True(t, frame.At(2, 1).Continuation) | |
| 37 | + | require.Zero(t, frame.At(2, 1).Rune) | |
| 81 | 38 | } | |
| 82 | 39 | ||
| 83 | - | func TestOverlayBackdropDisabledAndFallback(t *testing.T) { | |
| 40 | + | func TestDimFrameRectClipsToFrame(t *testing.T) { | |
| 84 | 41 | frame := renderer.NewFrame(4, 4) | |
| 85 | - | frame.Set(1, 1, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 86 | 42 | before := frame.Clone() | |
| 87 | - | area := domain.Rect{Y: 1, Width: 4, Height: 2} | |
| 88 | - | (overlayBackdrop{}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | |
| 89 | - | require.True(t, sameCells(before.Cells, frame.Cells), "backdrops are disabled by default") | |
| 43 | + | dimFrameRect(frame, domain.Rect{X: 2, Y: 2, Width: 4, Height: 4}, backdropTheme()) | |
| 44 | + | dimmed := themeui.NewDimmer(backdropTheme()).Dim(renderer.DefaultStyle()) | |
| 90 | 45 | ||
| 91 | - | (overlayBackdrop{DimPaneContents: true}).apply(frame, area, tabLayoutSnapshot{}, backdropTheme()) | |
| 92 | - | require.True(t, frame.At(0, 1).Style.HasForegroundRGB, "failed layout dims the focused-pane fallback area") | |
| 93 | - | require.True(t, frame.At(3, 2).Style.HasForegroundRGB) | |
| 94 | - | require.False(t, frame.At(0, 0).Style.HasForegroundRGB) | |
| 95 | - | require.False(t, frame.At(0, 3).Style.HasForegroundRGB) | |
| 46 | + | for _, point := range [][2]int{{2, 2}, {3, 2}, {2, 3}, {3, 3}} { | |
| 47 | + | require.Equal(t, dimmed, frame.At(point[0], point[1]).Style) | |
| 48 | + | } | |
| 49 | + | for _, point := range [][2]int{{0, 0}, {1, 1}, {3, 1}, {1, 3}} { | |
| 50 | + | require.Equal(t, before.At(point[0], point[1]).Style, frame.At(point[0], point[1]).Style) | |
| 51 | + | } | |
| 96 | 52 | } | |
| @@ -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()}) // title | 22 | + | 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 bar | 23 | + | } |
| 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 ... | |||
| 126 | 126 | damage = append(damage, d) | |
| 127 | 127 | } | |
| 128 | 128 | } | |
| 129 | - | // Keep the committed cache unadorned: floating composition clones this | |
| 130 | - | // base, so closing or moving a popup cannot retain dimmed/bordered cells. | |
| 129 | + | // Keep the committed cache unadorned. Toasts are composed first so floating | |
| 130 | + | // terminals and scoped modals dim them with the rest of the complete frame. | |
| 131 | 131 | baseFrame := frame | |
| 132 | + | overlaysActive := state.overlays.active() | |
| 133 | + | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |
| 134 | + | var toastFootprints []domain.Rect | |
| 135 | + | if toastsVisible { | |
| 136 | + | frame = baseFrame.Clone() | |
| 137 | + | toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles) | |
| 138 | + | } | |
| 132 | 139 | if state.floating.visible { | |
| 133 | 140 | var floatingDamage []renderer.Damage | |
| 134 | 141 | frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{ | |
| 135 | - | baseFrame: baseFrame, | |
| 142 | + | baseFrame: frame, | |
| 136 | 143 | baseDamage: damage, | |
| 137 | 144 | floating: state.floating, | |
| 138 | 145 | content: content, | |
| 139 | - | layout: state.layout, | |
| 140 | 146 | theme: state.theme, | |
| 141 | 147 | borderMuted: styles.BorderMuted, | |
| 142 | 148 | borderActive: styles.BorderActive, | |
| 143 | 149 | cache: in, | |
| 144 | - | full: full || state.overlays.active(), | |
| 150 | + | full: full || overlaysActive, | |
| 145 | 151 | }) | |
| 146 | 152 | damage = floatingDamage | |
| 147 | 153 | } | |
| 154 | + | if overlaysActive { | |
| 155 | + | if !toastsVisible && !state.floating.visible { | |
| 156 | + | frame = baseFrame.Clone() | |
| 157 | + | } | |
| 158 | + | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |
| 159 | + | frame, damage = composeCapturedOverlays(state, frame, damage) | |
| 160 | + | } | |
| 148 | 161 | if !full { | |
| 149 | - | if !sameCells(in.bars.top, frame.Row(0)) { | |
| 162 | + | if !sameCells(in.bars.top, baseFrame.Row(0)) { | |
| 150 | 163 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | |
| 151 | 164 | } | |
| 152 | - | if !sameCells(in.bars.bottom, frame.Row(rows+1)) { | |
| 165 | + | if !sameCells(in.bars.bottom, baseFrame.Row(rows+1)) { | |
| 153 | 166 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1}) | |
| 154 | 167 | } | |
| 155 | 168 | } | |
| @@ -157,19 +170,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 157 | 170 | // toasts, so every render damages both the last and current toast coverage. | |
| 158 | 171 | // This restores cells exposed by dismissal and redraws a stable toast over | |
| 159 | 172 | // any underlying pane update without promoting either case to a full frame. | |
| 160 | - | overlaysActive := state.overlays.active() | |
| 161 | - | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |
| 162 | - | 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 | 173 | if full || overlaysActive { | |
| 174 | 174 | damage = []renderer.Damage{renderer.FullRedraw()} | |
| 175 | 175 | } 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 this | 129 | + | // 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 := frame | 131 | baseFrame := frame | ||
| 132 | + | overlaysActive := state.overlays.active() | |||
| 133 | + | toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0 | |||
| 134 | + | var toastFootprints []domain.Rect | |||
| 135 | + | if toastsVisible { | |||
| 136 | + | frame = baseFrame.Clone() | |||
| 137 | + | toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles) | |||
| 138 | + | } | |||
| 132 | if state.floating.visible { | 139 | if state.floating.visible { | ||
| 133 | var floatingDamage []renderer.Damage | 140 | 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 = floatingDamage | 152 | damage = floatingDamage | ||
| 147 | } | 153 | } | ||
| 154 | + | if overlaysActive { | |||
| 155 | + | if !toastsVisible && !state.floating.visible { | |||
| 156 | + | frame = baseFrame.Clone() | |||
| 157 | + | } | |||
| 158 | + | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |||
| 159 | + | frame, damage = composeCapturedOverlays(state, frame, damage) | |||
| 160 | + | } | |||
| 148 | 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 over | 171 | // 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 ( | |||
| 10 | 10 | "github.com/bnema/vev/internal/ports" | |
| 11 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 12 | 12 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 13 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 13 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 14 | 15 | "github.com/stretchr/testify/require" | |
| 15 | 16 | ) | |
| @@ -326,7 +327,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||
| 326 | 327 | reset: false, | |
| 327 | 328 | layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 3}, valid: true, fingerprint: "layout"}, | |
| 328 | 329 | panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}, TitleBar: domain.Rect{Width: 6, Height: 1}}, title: title, titleGeneration: generation, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, | |
| 329 | - | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |
| 330 | + | floating: capturedFloatingRenderState{visible: true, pane: capturedPaneRenderState{frame: floating, damage: []renderer.Damage{renderer.FullRedraw()}}, geometry: floatingGeometry{Mode: ui.PresentationFloating, Bounds: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}, Inner: domain.Rect{X: 2, Y: 1, Width: 2, Height: 1}}, generation: generation, titleGeneration: generation}, | |
| 330 | 331 | bars: barState{topRight: title, bottomRight: title}, | |
| 331 | 332 | styles: resolveStyles(nil), | |
| 332 | 333 | } | |
| @@ -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) { | |||
| 227 | 227 | reset: true, | |
| 228 | 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 | 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 | 231 | bars: barState{status: statusSnapshot{session: "sess", tabs: []statusTab{{name: "tab", active: true}}}, topRight: "R", bottomRight: "B"}, | |
| 232 | 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 | 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 | 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 | 454 | func TestComposeCapturedOverlaysMatchesKeyboardPriority(t *testing.T) { | |
| 407 | 455 | theme := backdropTheme() | |
| 408 | 456 | 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 | |||
| 366 | 366 | size := tb.size | |
| 367 | 367 | geometry := calculateContentFloatingGeometry(size, d.currentFloatingConfig()) | |
| 368 | 368 | tb.mu.Unlock() | |
| 369 | - | if !geometry.valid() { | |
| 369 | + | if !geometry.committable() { | |
| 370 | 370 | return nil, true | |
| 371 | 371 | } | |
| 372 | 372 | ||
| @@ -374,7 +374,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 374 | 374 | // floating slot and tab size are revalidated. A newer client resize may | |
| 375 | 375 | // otherwise publish this obsolete popup geometry after its PTY call returns. | |
| 376 | 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 | 379 | d.applyPreparedTabMembers(&plan) | |
| 380 | 380 | ||
| @@ -387,11 +387,11 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||
| 387 | 387 | } | |
| 388 | 388 | if currentSlot { | |
| 389 | 389 | p.mu.Lock() | |
| 390 | - | p.rect = geometry.Inner | |
| 390 | + | p.rect = plan.members[0].rect | |
| 391 | 391 | p.popupGeometry = geometry | |
| 392 | 392 | if plan.members[0].ok { | |
| 393 | 393 | 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 | 396 | p.mu.Unlock() | |
| 397 | 397 | } | |
| @@ -366,7 +366,7 @@ func (d *Daemon) applyVisibleFloatingLayoutForMember(sess *session, tb *tab, cur | |||||
| 366 | size := tb.size | 366 | 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 | 370 | 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 may | 374 | // 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.Inner | 390 | + | p.rect = plan.members[0].rect |
| 391 | p.popupGeometry = geometry | 391 | p.popupGeometry = geometry | ||
| 392 | if plan.members[0].ok { | 392 | if plan.members[0].ok { | ||
| 393 | p.resizeRetry = false | 393 | 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 | } | ||