refactor(renderer): harden floating composition
- commit
- 773d08a7b5b2aaf4ed1a99a610cd5fd08bb4487e
- author
- brice · · 2026-07-10 06:34 UTC
- parent
- b897e1c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +138 -81
M internal/usecase/daemon/floating.go +0 -23
| @@ -125,29 +125,6 @@ func (tb *tab) terminalTargetLocked() *pane { | |||
| 125 | 125 | return tb.focusedPane() | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | - | // floatingInnerSize returns the terminal area inside a percentage-sized popup. | |
| 129 | - | // For each axis, a popup with at least three cells spends one cell on each | |
| 130 | - | // border; smaller popups omit that axis's border. A valid tab always yields a | |
| 131 | - | // valid PTY size. | |
| 132 | - | func floatingInnerSize(tabSize domain.Size, cfg domain.FloatingConfig) domain.Size { | |
| 133 | - | if !tabSize.Valid() { | |
| 134 | - | return domain.Size{} | |
| 135 | - | } | |
| 136 | - | return domain.Size{ | |
| 137 | - | Cols: floatingInnerAxis(tabSize.Cols, cfg.Width), | |
| 138 | - | Rows: floatingInnerAxis(tabSize.Rows, cfg.Height), | |
| 139 | - | } | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | func floatingInnerAxis(available, percent int) int { | |
| 143 | - | percent = min(max(percent, 1), 100) | |
| 144 | - | bounds := min(max(available*percent/100, 1), available) | |
| 145 | - | if bounds >= 3 { | |
| 146 | - | return bounds - 2 | |
| 147 | - | } | |
| 148 | - | return bounds | |
| 149 | - | } | |
| 150 | - | ||
| 151 | 128 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | |
| 152 | 129 | func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | |
| 153 | 130 | if d == nil || sess == nil || tb == nil || d.ptys == nil { | |
| @@ -125,29 +125,6 @@ func (tb *tab) terminalTargetLocked() *pane { | |||||
| 125 | return tb.focusedPane() | 125 | return tb.focusedPane() | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | - | // floatingInnerSize returns the terminal area inside a percentage-sized popup. | |||
| 129 | - | // For each axis, a popup with at least three cells spends one cell on each | |||
| 130 | - | // border; smaller popups omit that axis's border. A valid tab always yields a | |||
| 131 | - | // valid PTY size. | |||
| 132 | - | func floatingInnerSize(tabSize domain.Size, cfg domain.FloatingConfig) domain.Size { | |||
| 133 | - | if !tabSize.Valid() { | |||
| 134 | - | return domain.Size{} | |||
| 135 | - | } | |||
| 136 | - | return domain.Size{ | |||
| 137 | - | Cols: floatingInnerAxis(tabSize.Cols, cfg.Width), | |||
| 138 | - | Rows: floatingInnerAxis(tabSize.Rows, cfg.Height), | |||
| 139 | - | } | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | func floatingInnerAxis(available, percent int) int { | |||
| 143 | - | percent = min(max(percent, 1), 100) | |||
| 144 | - | bounds := min(max(available*percent/100, 1), available) | |||
| 145 | - | if bounds >= 3 { | |||
| 146 | - | return bounds - 2 | |||
| 147 | - | } | |||
| 148 | - | return bounds | |||
| 149 | - | } | |||
| 150 | - | ||||
| 151 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | 128 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | ||
| 152 | func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | 129 | func (d *Daemon) ensureFloatingWarm(sess *session, tb *tab) { | ||
| 153 | if d == nil || sess == nil || tb == nil || d.ptys == nil { | 130 | if d == nil || sess == nil || tb == nil || d.ptys == nil { | ||
M internal/usecase/daemon/floating_render.go +37 -21
| @@ -14,33 +14,49 @@ type floatingGeometry struct { | |||
| 14 | 14 | Inner domain.Rect | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | - | // calculateFloatingGeometry centers a percentage-sized popup in content. Its | |
| 18 | - | // terminal area deliberately uses floatingInnerSize so launch and rendering | |
| 19 | - | // always share the same border and percentage rules. | |
| 17 | + | // floatingAxisGeometry is the canonical percentage and border calculation for | |
| 18 | + | // one popup axis. Popups smaller than three cells spend no cells on borders. | |
| 19 | + | type floatingAxisGeometry struct { | |
| 20 | + | BoundsSize int | |
| 21 | + | InnerSize int | |
| 22 | + | BorderOffset int | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry { | |
| 26 | + | percent = min(max(percent, 1), 100) | |
| 27 | + | bounds := min(max(available*percent/100, 1), available) | |
| 28 | + | axis := floatingAxisGeometry{BoundsSize: bounds, InnerSize: bounds} | |
| 29 | + | if bounds >= 3 { | |
| 30 | + | axis.InnerSize -= 2 | |
| 31 | + | axis.BorderOffset = 1 | |
| 32 | + | } | |
| 33 | + | return axis | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // calculateFloatingGeometry centers a percentage-sized popup in content. | |
| 37 | + | // Launch sizing derives from Inner too, so rendering and PTY geometry share | |
| 38 | + | // the same per-axis percentage and tiny-border rules. | |
| 20 | 39 | func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry { | |
| 21 | 40 | if content.Width <= 0 || content.Height <= 0 { | |
| 22 | 41 | return floatingGeometry{} | |
| 23 | 42 | } | |
| 43 | + | x := calculateFloatingAxisGeometry(content.Width, cfg.Width) | |
| 44 | + | y := calculateFloatingAxisGeometry(content.Height, cfg.Height) | |
| 24 | 45 | bounds := domain.Rect{ | |
| 25 | - | Width: floatingBoundsAxis(content.Width, cfg.Width), | |
| 26 | - | Height: floatingBoundsAxis(content.Height, cfg.Height), | |
| 46 | + | X: content.X + (content.Width-x.BoundsSize)/2, | |
| 47 | + | Y: content.Y + (content.Height-y.BoundsSize)/2, | |
| 48 | + | Width: x.BoundsSize, | |
| 49 | + | Height: y.BoundsSize, | |
| 50 | + | } | |
| 51 | + | return floatingGeometry{ | |
| 52 | + | Bounds: bounds, | |
| 53 | + | Inner: domain.Rect{ | |
| 54 | + | X: bounds.X + x.BorderOffset, | |
| 55 | + | Y: bounds.Y + y.BorderOffset, | |
| 56 | + | Width: x.InnerSize, | |
| 57 | + | Height: y.InnerSize, | |
| 58 | + | }, | |
| 27 | 59 | } | |
| 28 | - | bounds.X = content.X + (content.Width-bounds.Width)/2 | |
| 29 | - | bounds.Y = content.Y + (content.Height-bounds.Height)/2 | |
| 30 | - | innerSize := floatingInnerSize(domain.Size{Cols: bounds.Width, Rows: bounds.Height}, domain.FloatingConfig{Width: 100, Height: 100}) | |
| 31 | - | inner := domain.Rect{X: bounds.X, Y: bounds.Y, Width: innerSize.Cols, Height: innerSize.Rows} | |
| 32 | - | if bounds.Width >= 3 { | |
| 33 | - | inner.X++ | |
| 34 | - | } | |
| 35 | - | if bounds.Height >= 3 { | |
| 36 | - | inner.Y++ | |
| 37 | - | } | |
| 38 | - | return floatingGeometry{Bounds: bounds, Inner: inner} | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | func floatingBoundsAxis(available, percent int) int { | |
| 42 | - | percent = min(max(percent, 1), 100) | |
| 43 | - | return min(max(available*percent/100, 1), available) | |
| 44 | 60 | } | |
| 45 | 61 | ||
| 46 | 62 | // composeFloatingFrame applies the popup to a copy of the normal composed | |
| @@ -14,33 +14,49 @@ type floatingGeometry struct { | |||||
| 14 | Inner domain.Rect | 14 | Inner domain.Rect | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | - | // calculateFloatingGeometry centers a percentage-sized popup in content. Its | 17 | + | // floatingAxisGeometry is the canonical percentage and border calculation for |
| 18 | - | // terminal area deliberately uses floatingInnerSize so launch and rendering | 18 | + | // one popup axis. Popups smaller than three cells spend no cells on borders. |
| 19 | - | // always share the same border and percentage rules. | 19 | + | type floatingAxisGeometry struct { |
| 20 | + | BoundsSize int | |||
| 21 | + | InnerSize int | |||
| 22 | + | BorderOffset int | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func calculateFloatingAxisGeometry(available, percent int) floatingAxisGeometry { | |||
| 26 | + | percent = min(max(percent, 1), 100) | |||
| 27 | + | bounds := min(max(available*percent/100, 1), available) | |||
| 28 | + | axis := floatingAxisGeometry{BoundsSize: bounds, InnerSize: bounds} | |||
| 29 | + | if bounds >= 3 { | |||
| 30 | + | axis.InnerSize -= 2 | |||
| 31 | + | axis.BorderOffset = 1 | |||
| 32 | + | } | |||
| 33 | + | return axis | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // calculateFloatingGeometry centers a percentage-sized popup in content. | |||
| 37 | + | // Launch sizing derives from Inner too, so rendering and PTY geometry share | |||
| 38 | + | // the same per-axis percentage and tiny-border rules. | |||
| 20 | func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry { | 39 | func calculateFloatingGeometry(content domain.Rect, cfg domain.FloatingConfig) floatingGeometry { | ||
| 21 | if content.Width <= 0 || content.Height <= 0 { | 40 | if content.Width <= 0 || content.Height <= 0 { | ||
| 22 | return floatingGeometry{} | 41 | return floatingGeometry{} | ||
| 23 | } | 42 | } | ||
| 43 | + | x := calculateFloatingAxisGeometry(content.Width, cfg.Width) | |||
| 44 | + | y := calculateFloatingAxisGeometry(content.Height, cfg.Height) | |||
| 24 | bounds := domain.Rect{ | 45 | bounds := domain.Rect{ | ||
| 25 | - | Width: floatingBoundsAxis(content.Width, cfg.Width), | 46 | + | X: content.X + (content.Width-x.BoundsSize)/2, |
| 26 | - | Height: floatingBoundsAxis(content.Height, cfg.Height), | 47 | + | Y: content.Y + (content.Height-y.BoundsSize)/2, |
| 48 | + | Width: x.BoundsSize, | |||
| 49 | + | Height: y.BoundsSize, | |||
| 50 | + | } | |||
| 51 | + | return floatingGeometry{ | |||
| 52 | + | Bounds: bounds, | |||
| 53 | + | Inner: domain.Rect{ | |||
| 54 | + | X: bounds.X + x.BorderOffset, | |||
| 55 | + | Y: bounds.Y + y.BorderOffset, | |||
| 56 | + | Width: x.InnerSize, | |||
| 57 | + | Height: y.InnerSize, | |||
| 58 | + | }, | |||
| 27 | } | 59 | } | ||
| 28 | - | bounds.X = content.X + (content.Width-bounds.Width)/2 | |||
| 29 | - | bounds.Y = content.Y + (content.Height-bounds.Height)/2 | |||
| 30 | - | innerSize := floatingInnerSize(domain.Size{Cols: bounds.Width, Rows: bounds.Height}, domain.FloatingConfig{Width: 100, Height: 100}) | |||
| 31 | - | inner := domain.Rect{X: bounds.X, Y: bounds.Y, Width: innerSize.Cols, Height: innerSize.Rows} | |||
| 32 | - | if bounds.Width >= 3 { | |||
| 33 | - | inner.X++ | |||
| 34 | - | } | |||
| 35 | - | if bounds.Height >= 3 { | |||
| 36 | - | inner.Y++ | |||
| 37 | - | } | |||
| 38 | - | return floatingGeometry{Bounds: bounds, Inner: inner} | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | func floatingBoundsAxis(available, percent int) int { | |||
| 42 | - | percent = min(max(percent, 1), 100) | |||
| 43 | - | return min(max(available*percent/100, 1), available) | |||
| 44 | } | 60 | } | ||
| 45 | 61 | ||||
| 46 | // composeFloatingFrame applies the popup to a copy of the normal composed | 62 | // composeFloatingFrame applies the popup to a copy of the normal composed | ||
M internal/usecase/daemon/floating_render_test.go +87 -0
| @@ -35,6 +35,25 @@ func TestCalculateFloatingGeometry(t *testing.T) { | |||
| 35 | 35 | } | |
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | + | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |
| 39 | + | tests := []struct { | |
| 40 | + | name string | |
| 41 | + | available, percent int | |
| 42 | + | want floatingAxisGeometry | |
| 43 | + | }{ | |
| 44 | + | {name: "one percent", available: 100, percent: 1, want: floatingAxisGeometry{BoundsSize: 1, InnerSize: 1}}, | |
| 45 | + | {name: "full size reserves borders", available: 100, percent: 100, want: floatingAxisGeometry{BoundsSize: 100, InnerSize: 98, BorderOffset: 1}}, | |
| 46 | + | {name: "two cells omits borders", available: 2, percent: 100, want: floatingAxisGeometry{BoundsSize: 2, InnerSize: 2}}, | |
| 47 | + | {name: "three cells leaves one inner cell", available: 3, percent: 100, want: floatingAxisGeometry{BoundsSize: 3, InnerSize: 1, BorderOffset: 1}}, | |
| 48 | + | {name: "percentage clamps", available: 10, percent: 101, want: floatingAxisGeometry{BoundsSize: 10, InnerSize: 8, BorderOffset: 1}}, | |
| 49 | + | } | |
| 50 | + | for _, tt := range tests { | |
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |
| 52 | + | require.Equal(t, tt.want, calculateFloatingAxisGeometry(tt.available, tt.percent)) | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 38 | 57 | func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | |
| 39 | 58 | p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3}) | |
| 40 | 59 | p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | |
| @@ -121,6 +140,74 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |||
| 121 | 140 | } | |
| 122 | 141 | } | |
| 123 | 142 | ||
| 143 | + | func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) { | |
| 144 | + | normalPTY, releaseNormal := newBlockingPTY(t) | |
| 145 | + | defer releaseNormal() | |
| 146 | + | d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY) | |
| 147 | + | previewTransport, previewSends := newCapturingTransport(t) | |
| 148 | + | preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 149 | + | preview.initOverlays() | |
| 150 | + | preview.setSession(sess) | |
| 151 | + | ||
| 152 | + | tb := sess.activeTab() | |
| 153 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8}) | |
| 154 | + | floating.screen.Write([]byte("hidden output")) | |
| 155 | + | tb.mu.Lock() | |
| 156 | + | generation := tb.beginFloatingWarmLocked(false) | |
| 157 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |
| 158 | + | tb.previewClient = preview | |
| 159 | + | tb.mu.Unlock() | |
| 160 | + | ||
| 161 | + | d.render(sess, tb, floating) | |
| 162 | + | ||
| 163 | + | floating.mu.Lock() | |
| 164 | + | require.Empty(t, floating.screen.Damage()) | |
| 165 | + | floating.mu.Unlock() | |
| 166 | + | select { | |
| 167 | + | case frame := <-previewSends: | |
| 168 | + | t.Fatalf("hidden floating output painted picker preview: %#v", frame) | |
| 169 | + | default: | |
| 170 | + | } | |
| 171 | + | select { | |
| 172 | + | case frame := <-activeSends: | |
| 173 | + | t.Fatalf("hidden floating output painted active client: %#v", frame) | |
| 174 | + | default: | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func BenchmarkComposeFloatingFrameCached(b *testing.B) { | |
| 179 | + | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |
| 180 | + | p.title.displayFallback = "float" | |
| 181 | + | base := renderer.NewFrame(80, 24) | |
| 182 | + | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
| 183 | + | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |
| 184 | + | cache := &composedFrameCache{} | |
| 185 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 186 | + | p.screen.ClearDamage() | |
| 187 | + | ||
| 188 | + | b.ReportAllocs() | |
| 189 | + | b.ResetTimer() | |
| 190 | + | for b.Loop() { | |
| 191 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) { | |
| 196 | + | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |
| 197 | + | p.title.displayFallback = "float" | |
| 198 | + | base := renderer.NewFrame(80, 24) | |
| 199 | + | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |
| 200 | + | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |
| 201 | + | cache := &composedFrameCache{} | |
| 202 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 203 | + | p.screen.ClearDamage() | |
| 204 | + | ||
| 205 | + | allocs := testing.AllocsPerRun(100, func() { | |
| 206 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |
| 207 | + | }) | |
| 208 | + | require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes") | |
| 209 | + | } | |
| 210 | + | ||
| 124 | 211 | func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |
| 125 | 212 | d := newTestDaemon(t, nil, stubClock{}) | |
| 126 | 213 | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}}) | |
| @@ -35,6 +35,25 @@ func TestCalculateFloatingGeometry(t *testing.T) { | |||||
| 35 | } | 35 | } | ||
| 36 | } | 36 | } | ||
| 37 | 37 | ||||
| 38 | + | func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) { | |||
| 39 | + | tests := []struct { | |||
| 40 | + | name string | |||
| 41 | + | available, percent int | |||
| 42 | + | want floatingAxisGeometry | |||
| 43 | + | }{ | |||
| 44 | + | {name: "one percent", available: 100, percent: 1, want: floatingAxisGeometry{BoundsSize: 1, InnerSize: 1}}, | |||
| 45 | + | {name: "full size reserves borders", available: 100, percent: 100, want: floatingAxisGeometry{BoundsSize: 100, InnerSize: 98, BorderOffset: 1}}, | |||
| 46 | + | {name: "two cells omits borders", available: 2, percent: 100, want: floatingAxisGeometry{BoundsSize: 2, InnerSize: 2}}, | |||
| 47 | + | {name: "three cells leaves one inner cell", available: 3, percent: 100, want: floatingAxisGeometry{BoundsSize: 3, InnerSize: 1, BorderOffset: 1}}, | |||
| 48 | + | {name: "percentage clamps", available: 10, percent: 101, want: floatingAxisGeometry{BoundsSize: 10, InnerSize: 8, BorderOffset: 1}}, | |||
| 49 | + | } | |||
| 50 | + | for _, tt := range tests { | |||
| 51 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 52 | + | require.Equal(t, tt.want, calculateFloatingAxisGeometry(tt.available, tt.percent)) | |||
| 53 | + | }) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 38 | func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | 57 | func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) { | ||
| 39 | p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3}) | 58 | p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3}) | ||
| 40 | p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | 59 | p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'}) | ||
| @@ -121,6 +140,74 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) { | |||||
| 121 | } | 140 | } | ||
| 122 | } | 141 | } | ||
| 123 | 142 | ||||
| 143 | + | func TestRenderHiddenFloatingSkipsPreviewAndActiveClientAndClearsDamage(t *testing.T) { | |||
| 144 | + | normalPTY, releaseNormal := newBlockingPTY(t) | |||
| 145 | + | defer releaseNormal() | |||
| 146 | + | d, sess, _, activeSends := newManualSessionWithPTYs(t, normalPTY) | |||
| 147 | + | previewTransport, previewSends := newCapturingTransport(t) | |||
| 148 | + | preview := &attachedClient{tr: previewTransport, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |||
| 149 | + | preview.initOverlays() | |||
| 150 | + | preview.setSession(sess) | |||
| 151 | + | ||||
| 152 | + | tb := sess.activeTab() | |||
| 153 | + | floating := newPane("floating", nil, domain.Size{Cols: 20, Rows: 8}) | |||
| 154 | + | floating.screen.Write([]byte("hidden output")) | |||
| 155 | + | tb.mu.Lock() | |||
| 156 | + | generation := tb.beginFloatingWarmLocked(false) | |||
| 157 | + | require.True(t, tb.installFloatingLocked(floating, generation)) | |||
| 158 | + | tb.previewClient = preview | |||
| 159 | + | tb.mu.Unlock() | |||
| 160 | + | ||||
| 161 | + | d.render(sess, tb, floating) | |||
| 162 | + | ||||
| 163 | + | floating.mu.Lock() | |||
| 164 | + | require.Empty(t, floating.screen.Damage()) | |||
| 165 | + | floating.mu.Unlock() | |||
| 166 | + | select { | |||
| 167 | + | case frame := <-previewSends: | |||
| 168 | + | t.Fatalf("hidden floating output painted picker preview: %#v", frame) | |||
| 169 | + | default: | |||
| 170 | + | } | |||
| 171 | + | select { | |||
| 172 | + | case frame := <-activeSends: | |||
| 173 | + | t.Fatalf("hidden floating output painted active client: %#v", frame) | |||
| 174 | + | default: | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func BenchmarkComposeFloatingFrameCached(b *testing.B) { | |||
| 179 | + | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |||
| 180 | + | p.title.displayFallback = "float" | |||
| 181 | + | base := renderer.NewFrame(80, 24) | |||
| 182 | + | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |||
| 183 | + | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |||
| 184 | + | cache := &composedFrameCache{} | |||
| 185 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 186 | + | p.screen.ClearDamage() | |||
| 187 | + | ||||
| 188 | + | b.ReportAllocs() | |||
| 189 | + | b.ResetTimer() | |||
| 190 | + | for b.Loop() { | |||
| 191 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) { | |||
| 196 | + | p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18}) | |||
| 197 | + | p.title.displayFallback = "float" | |||
| 198 | + | base := renderer.NewFrame(80, 24) | |||
| 199 | + | content := domain.Rect{Y: 1, Width: 80, Height: 22} | |||
| 200 | + | cfg := domain.FloatingConfig{Width: 80, Height: 80} | |||
| 201 | + | cache := &composedFrameCache{} | |||
| 202 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 203 | + | p.screen.ClearDamage() | |||
| 204 | + | ||||
| 205 | + | allocs := testing.AllocsPerRun(100, func() { | |||
| 206 | + | composeFloatingFrame(base, nil, p, 1, content, cfg, tabLayoutSnapshot{}, themeui.Theme{}, cache, false) | |||
| 207 | + | }) | |||
| 208 | + | require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes") | |||
| 209 | + | } | |||
| 210 | + | ||||
| 124 | func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | 211 | func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | ||
| 125 | d := newTestDaemon(t, nil, stubClock{}) | 212 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 126 | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}}) | 213 | d.ApplyConfig(domain.Config{Floating: domain.FloatingConfig{Width: 50, Height: 50}}) | ||
M internal/usecase/daemon/floating_test.go +0 -22
| @@ -592,25 +592,3 @@ func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | |||
| 592 | 592 | require.Same(t, floating, tb.terminalTargetLocked()) | |
| 593 | 593 | tb.mu.Unlock() | |
| 594 | 594 | } | |
| 595 | - | ||
| 596 | - | func TestFloatingInnerSize(t *testing.T) { | |
| 597 | - | tests := []struct { | |
| 598 | - | name string | |
| 599 | - | tab domain.Size | |
| 600 | - | cfg domain.FloatingConfig | |
| 601 | - | want domain.Size | |
| 602 | - | }{ | |
| 603 | - | {name: "one percent", tab: domain.Size{Cols: 100, Rows: 100}, cfg: domain.FloatingConfig{Width: 1, Height: 1}, want: domain.Size{Cols: 1, Rows: 1}}, | |
| 604 | - | {name: "full size reserves borders", tab: domain.Size{Cols: 100, Rows: 80}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 98, Rows: 78}}, | |
| 605 | - | {name: "tiny tab omits borders", tab: domain.Size{Cols: 2, Rows: 1}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 2, Rows: 1}}, | |
| 606 | - | {name: "three cells leaves one inner cell", tab: domain.Size{Cols: 3, Rows: 3}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 1, Rows: 1}}, | |
| 607 | - | {name: "invalid tab has no pty size", tab: domain.Size{}, cfg: domain.FloatingConfig{Width: 80, Height: 80}, want: domain.Size{}}, | |
| 608 | - | } | |
| 609 | - | for _, tt := range tests { | |
| 610 | - | t.Run(tt.name, func(t *testing.T) { | |
| 611 | - | if got := floatingInnerSize(tt.tab, tt.cfg); got != tt.want { | |
| 612 | - | t.Fatalf("floatingInnerSize(%+v, %+v) = %+v, want %+v", tt.tab, tt.cfg, got, tt.want) | |
| 613 | - | } | |
| 614 | - | }) | |
| 615 | - | } | |
| 616 | - | } | |
| @@ -592,25 +592,3 @@ func TestFloatingToggleUsesVisibleTarget(t *testing.T) { | |||||
| 592 | require.Same(t, floating, tb.terminalTargetLocked()) | 592 | require.Same(t, floating, tb.terminalTargetLocked()) | ||
| 593 | tb.mu.Unlock() | 593 | tb.mu.Unlock() | ||
| 594 | } | 594 | } | ||
| 595 | - | ||||
| 596 | - | func TestFloatingInnerSize(t *testing.T) { | |||
| 597 | - | tests := []struct { | |||
| 598 | - | name string | |||
| 599 | - | tab domain.Size | |||
| 600 | - | cfg domain.FloatingConfig | |||
| 601 | - | want domain.Size | |||
| 602 | - | }{ | |||
| 603 | - | {name: "one percent", tab: domain.Size{Cols: 100, Rows: 100}, cfg: domain.FloatingConfig{Width: 1, Height: 1}, want: domain.Size{Cols: 1, Rows: 1}}, | |||
| 604 | - | {name: "full size reserves borders", tab: domain.Size{Cols: 100, Rows: 80}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 98, Rows: 78}}, | |||
| 605 | - | {name: "tiny tab omits borders", tab: domain.Size{Cols: 2, Rows: 1}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 2, Rows: 1}}, | |||
| 606 | - | {name: "three cells leaves one inner cell", tab: domain.Size{Cols: 3, Rows: 3}, cfg: domain.FloatingConfig{Width: 100, Height: 100}, want: domain.Size{Cols: 1, Rows: 1}}, | |||
| 607 | - | {name: "invalid tab has no pty size", tab: domain.Size{}, cfg: domain.FloatingConfig{Width: 80, Height: 80}, want: domain.Size{}}, | |||
| 608 | - | } | |||
| 609 | - | for _, tt := range tests { | |||
| 610 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 611 | - | if got := floatingInnerSize(tt.tab, tt.cfg); got != tt.want { | |||
| 612 | - | t.Fatalf("floatingInnerSize(%+v, %+v) = %+v, want %+v", tt.tab, tt.cfg, got, tt.want) | |||
| 613 | - | } | |||
| 614 | - | }) | |||
| 615 | - | } | |||
| 616 | - | } | |||
M internal/usecase/daemon/render.go +14 -15
| @@ -219,9 +219,6 @@ func syncUpdateEndIn(data []byte) bool { | |||
| 219 | 219 | // render paints the current client, or (when detached) just clears accumulated | |
| 220 | 220 | // damage so it never grows unbounded while headless. | |
| 221 | 221 | func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |
| 222 | - | tb.mu.Lock() | |
| 223 | - | previewer := tb.previewClient | |
| 224 | - | tb.mu.Unlock() | |
| 225 | 222 | if p == nil { | |
| 226 | 223 | return | |
| 227 | 224 | } | |
| @@ -232,6 +229,20 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||
| 232 | 229 | } | |
| 233 | 230 | p.mu.Unlock() | |
| 234 | 231 | ||
| 232 | + | // A retained floating pane continues to drain its PTY while hidden. Its | |
| 233 | + | // scheduler must not route through picker preview (or the active client), | |
| 234 | + | // but it must consume damage so a long-hidden command cannot retain it. | |
| 235 | + | tb.mu.Lock() | |
| 236 | + | hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible | |
| 237 | + | previewer := tb.previewClient | |
| 238 | + | tb.mu.Unlock() | |
| 239 | + | if hiddenFloating { | |
| 240 | + | p.mu.Lock() | |
| 241 | + | p.screen.ClearDamage() | |
| 242 | + | p.mu.Unlock() | |
| 243 | + | return | |
| 244 | + | } | |
| 245 | + | ||
| 235 | 246 | sess.mu.Lock() | |
| 236 | 247 | ac := sess.client | |
| 237 | 248 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | |
| @@ -257,18 +268,6 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||
| 257 | 268 | p.mu.Unlock() | |
| 258 | 269 | return | |
| 259 | 270 | } | |
| 260 | - | // A retained floating pane continues to drain its PTY while hidden. Do | |
| 261 | - | // not let its scheduler repaint the normal tab, but consume its damage so | |
| 262 | - | // a long-hidden command cannot retain an unbounded dirty region. | |
| 263 | - | tb.mu.Lock() | |
| 264 | - | hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible | |
| 265 | - | tb.mu.Unlock() | |
| 266 | - | if hiddenFloating { | |
| 267 | - | p.mu.Lock() | |
| 268 | - | p.screen.ClearDamage() | |
| 269 | - | p.mu.Unlock() | |
| 270 | - | return | |
| 271 | - | } | |
| 272 | 271 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), false) | |
| 273 | 272 | d.paint(sess, ac, false) | |
| 274 | 273 | } | |
| @@ -219,9 +219,6 @@ func syncUpdateEndIn(data []byte) bool { | |||||
| 219 | // render paints the current client, or (when detached) just clears accumulated | 219 | // render paints the current client, or (when detached) just clears accumulated | ||
| 220 | // damage so it never grows unbounded while headless. | 220 | // damage so it never grows unbounded while headless. | ||
| 221 | func (d *Daemon) render(sess *session, tb *tab, p *pane) { | 221 | func (d *Daemon) render(sess *session, tb *tab, p *pane) { | ||
| 222 | - | tb.mu.Lock() | |||
| 223 | - | previewer := tb.previewClient | |||
| 224 | - | tb.mu.Unlock() | |||
| 225 | if p == nil { | 222 | if p == nil { | ||
| 226 | return | 223 | return | ||
| 227 | } | 224 | } | ||
| @@ -232,6 +229,20 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||||
| 232 | } | 229 | } | ||
| 233 | p.mu.Unlock() | 230 | p.mu.Unlock() | ||
| 234 | 231 | ||||
| 232 | + | // A retained floating pane continues to drain its PTY while hidden. Its | |||
| 233 | + | // scheduler must not route through picker preview (or the active client), | |||
| 234 | + | // but it must consume damage so a long-hidden command cannot retain it. | |||
| 235 | + | tb.mu.Lock() | |||
| 236 | + | hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible | |||
| 237 | + | previewer := tb.previewClient | |||
| 238 | + | tb.mu.Unlock() | |||
| 239 | + | if hiddenFloating { | |||
| 240 | + | p.mu.Lock() | |||
| 241 | + | p.screen.ClearDamage() | |||
| 242 | + | p.mu.Unlock() | |||
| 243 | + | return | |||
| 244 | + | } | |||
| 245 | + | ||||
| 235 | sess.mu.Lock() | 246 | sess.mu.Lock() | ||
| 236 | ac := sess.client | 247 | ac := sess.client | ||
| 237 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | 248 | active := sess.active >= 0 && sess.active < len(sess.tabs) && sess.tabs[sess.active] == tb | ||
| @@ -257,18 +268,6 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) { | |||||
| 257 | p.mu.Unlock() | 268 | p.mu.Unlock() | ||
| 258 | return | 269 | return | ||
| 259 | } | 270 | } | ||
| 260 | - | // A retained floating pane continues to drain its PTY while hidden. Do | |||
| 261 | - | // not let its scheduler repaint the normal tab, but consume its damage so | |||
| 262 | - | // a long-hidden command cannot retain an unbounded dirty region. | |||
| 263 | - | tb.mu.Lock() | |||
| 264 | - | hiddenFloating := tb.floating.pane == p && tb.floating.state != floatingVisible | |||
| 265 | - | tb.mu.Unlock() | |||
| 266 | - | if hiddenFloating { | |||
| 267 | - | p.mu.Lock() | |||
| 268 | - | p.screen.ClearDamage() | |||
| 269 | - | p.mu.Unlock() | |||
| 270 | - | return | |||
| 271 | - | } | |||
| 272 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), false) | 271 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), false) | ||
| 273 | d.paint(sess, ac, false) | 272 | d.paint(sess, ac, false) | ||
| 274 | } | 273 | } | ||