fix(daemon): preserve committed floating geometry
- commit
- bc8142c6ed80d339953843499e756a6d10b31195
- author
- brice · · 2026-07-10 10:58 UTC
- parent
- c22bfd8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
5 files · +122 -35
M internal/usecase/daemon/floating.go +14 -9
| @@ -132,11 +132,15 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, | |||
| 132 | 132 | if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil { | |
| 133 | 133 | return nil, floatingGeometry{}, false | |
| 134 | 134 | } | |
| 135 | - | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |
| 135 | + | p := tb.floating.pane | |
| 136 | + | desired := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |
| 137 | + | p.mu.Lock() | |
| 138 | + | geometry := p.committedFloatingGeometryLocked(desired) | |
| 139 | + | p.mu.Unlock() | |
| 136 | 140 | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | |
| 137 | 141 | return nil, floatingGeometry{}, false | |
| 138 | 142 | } | |
| 139 | - | return tb.floating.pane, geometry, true | |
| 143 | + | return p, geometry, true | |
| 140 | 144 | } | |
| 141 | 145 | ||
| 142 | 146 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | |
| @@ -217,7 +221,7 @@ type floatingLaunchSpec struct { | |||
| 217 | 221 | sessionName string | |
| 218 | 222 | cwd string | |
| 219 | 223 | size domain.Size | |
| 220 | - | innerRect domain.Rect | |
| 224 | + | geometry floatingGeometry | |
| 221 | 225 | paneStableID string | |
| 222 | 226 | env []string | |
| 223 | 227 | command string | |
| @@ -246,11 +250,11 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 246 | 250 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | |
| 247 | 251 | sess.mu.Unlock() | |
| 248 | 252 | tb.mu.Lock() | |
| 249 | - | inner := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg).Inner | |
| 250 | - | size := rectSize(inner) | |
| 253 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |
| 254 | + | size := rectSize(geometry.Inner) | |
| 251 | 255 | if !size.Valid() { | |
| 252 | - | inner = domain.Rect{Width: 1, Height: 1} | |
| 253 | - | size = rectSize(inner) | |
| 256 | + | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} | |
| 257 | + | size = rectSize(geometry.Inner) | |
| 254 | 258 | } | |
| 255 | 259 | focused := tb.focusedPane() | |
| 256 | 260 | tabStableID, tabCtx := tb.stableID, tb.ctx | |
| @@ -281,7 +285,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||
| 281 | 285 | sessionName: name, | |
| 282 | 286 | cwd: cwd, | |
| 283 | 287 | size: size, | |
| 284 | - | innerRect: inner, | |
| 288 | + | geometry: geometry, | |
| 285 | 289 | paneStableID: paneStableID, | |
| 286 | 290 | env: d.childEnv(name, tabStableID, paneStableID, term), | |
| 287 | 291 | command: command, | |
| @@ -301,7 +305,8 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||
| 301 | 305 | return | |
| 302 | 306 | } | |
| 303 | 307 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | |
| 304 | - | p.rect = spec.innerRect | |
| 308 | + | p.rect = spec.geometry.Inner | |
| 309 | + | p.popupGeometry = spec.geometry | |
| 305 | 310 | p.title.displayFallback = spec.fallback | |
| 306 | 311 | p.ctx, p.cancel = context.WithCancel(spec.parentCtx) | |
| 307 | 312 | // The reader may run as soon as install returns; make its exit policy | |
| @@ -132,11 +132,15 @@ func (tb *tab) visibleFloatingSnapshotLocked(cfg domain.FloatingConfig) (*pane, | |||||
| 132 | if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil { | 132 | if tb == nil || tb.floating.state != floatingVisible || tb.floating.pane == nil { | ||
| 133 | return nil, floatingGeometry{}, false | 133 | return nil, floatingGeometry{}, false | ||
| 134 | } | 134 | } | ||
| 135 | - | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | 135 | + | p := tb.floating.pane |
| 136 | + | desired := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) | |||
| 137 | + | p.mu.Lock() | |||
| 138 | + | geometry := p.committedFloatingGeometryLocked(desired) | |||
| 139 | + | p.mu.Unlock() | |||
| 136 | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | 140 | if geometry.Inner.Width <= 0 || geometry.Inner.Height <= 0 { | ||
| 137 | return nil, floatingGeometry{}, false | 141 | return nil, floatingGeometry{}, false | ||
| 138 | } | 142 | } | ||
| 139 | - | return tb.floating.pane, geometry, true | 143 | + | return p, geometry, true |
| 140 | } | 144 | } | ||
| 141 | 145 | ||||
| 142 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | 146 | // ensureFloatingWarm starts the background prewarm exactly once for a tab. | ||
| @@ -217,7 +221,7 @@ type floatingLaunchSpec struct { | |||||
| 217 | sessionName string | 221 | sessionName string | ||
| 218 | cwd string | 222 | cwd string | ||
| 219 | size domain.Size | 223 | size domain.Size | ||
| 220 | - | innerRect domain.Rect | 224 | + | geometry floatingGeometry |
| 221 | paneStableID string | 225 | paneStableID string | ||
| 222 | env []string | 226 | env []string | ||
| 223 | command string | 227 | command string | ||
| @@ -246,11 +250,11 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 246 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | 250 | name, cwd, term, sessCtx := sess.name, sess.cwd, sess.terminal, sess.ctx | ||
| 247 | sess.mu.Unlock() | 251 | sess.mu.Unlock() | ||
| 248 | tb.mu.Lock() | 252 | tb.mu.Lock() | ||
| 249 | - | inner := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg).Inner | 253 | + | geometry := calculateFloatingGeometry(domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}, cfg) |
| 250 | - | size := rectSize(inner) | 254 | + | size := rectSize(geometry.Inner) |
| 251 | if !size.Valid() { | 255 | if !size.Valid() { | ||
| 252 | - | inner = domain.Rect{Width: 1, Height: 1} | 256 | + | geometry = floatingGeometry{Bounds: domain.Rect{Width: 1, Height: 1}, Inner: domain.Rect{Width: 1, Height: 1}} |
| 253 | - | size = rectSize(inner) | 257 | + | size = rectSize(geometry.Inner) |
| 254 | } | 258 | } | ||
| 255 | focused := tb.focusedPane() | 259 | focused := tb.focusedPane() | ||
| 256 | tabStableID, tabCtx := tb.stableID, tb.ctx | 260 | tabStableID, tabCtx := tb.stableID, tb.ctx | ||
| @@ -281,7 +285,7 @@ func (d *Daemon) newFloatingLaunchSpec(sess *session, tb *tab, cfg domain.Floati | |||||
| 281 | sessionName: name, | 285 | sessionName: name, | ||
| 282 | cwd: cwd, | 286 | cwd: cwd, | ||
| 283 | size: size, | 287 | size: size, | ||
| 284 | - | innerRect: inner, | 288 | + | geometry: geometry, |
| 285 | paneStableID: paneStableID, | 289 | paneStableID: paneStableID, | ||
| 286 | env: d.childEnv(name, tabStableID, paneStableID, term), | 290 | env: d.childEnv(name, tabStableID, paneStableID, term), | ||
| 287 | command: command, | 291 | command: command, | ||
| @@ -301,7 +305,8 @@ func (d *Daemon) openAndInstallFloating(sess *session, tb *tab, spec floatingLau | |||||
| 301 | return | 305 | return | ||
| 302 | } | 306 | } | ||
| 303 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | 307 | p := newPaneWithStableID(layout.PaneID("floating"), spec.paneStableID, pty, spec.size) | ||
| 304 | - | p.rect = spec.innerRect | 308 | + | p.rect = spec.geometry.Inner |
| 309 | + | p.popupGeometry = spec.geometry | |||
| 305 | p.title.displayFallback = spec.fallback | 310 | p.title.displayFallback = spec.fallback | ||
| 306 | p.ctx, p.cancel = context.WithCancel(spec.parentCtx) | 311 | p.ctx, p.cancel = context.WithCancel(spec.parentCtx) | ||
| 307 | // The reader may run as soon as install returns; make its exit policy | 312 | // The reader may run as soon as install returns; make its exit policy | ||
M internal/usecase/daemon/floating_render.go +21 -4
| @@ -14,6 +14,20 @@ type floatingGeometry struct { | |||
| 14 | 14 | Inner domain.Rect | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | + | func (g floatingGeometry) valid() bool { | |
| 18 | + | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // committedFloatingGeometryLocked returns the geometry whose PTY resize was | |
| 22 | + | // last committed. Newly constructed test panes fall back to the requested | |
| 23 | + | // geometry until their first resize. The caller holds p.mu. | |
| 24 | + | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | |
| 25 | + | if p.popupGeometry.valid() { | |
| 26 | + | return p.popupGeometry | |
| 27 | + | } | |
| 28 | + | return fallback | |
| 29 | + | } | |
| 30 | + | ||
| 17 | 31 | // floatingAxisGeometry is the canonical percentage and border calculation for | |
| 18 | 32 | // one popup axis. Popups smaller than three cells spend no cells on borders. | |
| 19 | 33 | type floatingAxisGeometry struct { | |
| @@ -76,7 +90,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||
| 76 | 90 | } | |
| 77 | 91 | } | |
| 78 | 92 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | |
| 79 | - | geometry := calculateFloatingGeometry(content, cfg) | |
| 93 | + | desiredGeometry := calculateFloatingGeometry(content, cfg) | |
| 80 | 94 | ||
| 81 | 95 | popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation | |
| 82 | 96 | ||
| @@ -84,6 +98,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||
| 84 | 98 | // every read of them, including the blit, in the same critical section. | |
| 85 | 99 | // In particular, Frame is not safe to retain as an alias after unlocking. | |
| 86 | 100 | p.mu.Lock() | |
| 101 | + | geometry := p.committedFloatingGeometryLocked(desiredGeometry) | |
| 87 | 102 | title := p.displayTitleLocked() | |
| 88 | 103 | titleGeneration := p.title.generation | |
| 89 | 104 | screenDamage := p.screen.Damage() | |
| @@ -140,8 +155,9 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, | |||
| 140 | 155 | ||
| 141 | 156 | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | |
| 142 | 157 | // is intentionally outside pane.mu; failed resizes do not publish any state. | |
| 143 | - | func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool { | |
| 144 | - | if p == nil || inner.Width <= 0 || inner.Height <= 0 { | |
| 158 | + | func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool { | |
| 159 | + | inner := geometry.Inner | |
| 160 | + | if p == nil || !geometry.valid() { | |
| 145 | 161 | return false | |
| 146 | 162 | } | |
| 147 | 163 | p.resizeMu.Lock() | |
| @@ -163,6 +179,7 @@ func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool { | |||
| 163 | 179 | p.mu.Lock() | |
| 164 | 180 | p.screen.Resize(inner.Width, inner.Height) | |
| 165 | 181 | p.rect = inner | |
| 182 | + | p.popupGeometry = geometry | |
| 166 | 183 | p.mu.Unlock() | |
| 167 | 184 | return true | |
| 168 | 185 | } | |
| @@ -180,7 +197,7 @@ func (d *Daemon) resizeInstalledFloating(tb *tab) bool { | |||
| 180 | 197 | if p == nil { | |
| 181 | 198 | return false | |
| 182 | 199 | } | |
| 183 | - | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner) | |
| 200 | + | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig())) | |
| 184 | 201 | } | |
| 185 | 202 | ||
| 186 | 203 | // resizeActiveFloating updates a visible floating pane during a client resize. | |
| @@ -14,6 +14,20 @@ type floatingGeometry struct { | |||||
| 14 | Inner domain.Rect | 14 | Inner domain.Rect | ||
| 15 | } | 15 | } | ||
| 16 | 16 | ||||
| 17 | + | func (g floatingGeometry) valid() bool { | |||
| 18 | + | return g.Bounds.Width > 0 && g.Bounds.Height > 0 && g.Inner.Width > 0 && g.Inner.Height > 0 | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | // committedFloatingGeometryLocked returns the geometry whose PTY resize was | |||
| 22 | + | // last committed. Newly constructed test panes fall back to the requested | |||
| 23 | + | // geometry until their first resize. The caller holds p.mu. | |||
| 24 | + | func (p *pane) committedFloatingGeometryLocked(fallback floatingGeometry) floatingGeometry { | |||
| 25 | + | if p.popupGeometry.valid() { | |||
| 26 | + | return p.popupGeometry | |||
| 27 | + | } | |||
| 28 | + | return fallback | |||
| 29 | + | } | |||
| 30 | + | ||||
| 17 | // floatingAxisGeometry is the canonical percentage and border calculation for | 31 | // floatingAxisGeometry is the canonical percentage and border calculation for | ||
| 18 | // one popup axis. Popups smaller than three cells spend no cells on borders. | 32 | // one popup axis. Popups smaller than three cells spend no cells on borders. | ||
| 19 | type floatingAxisGeometry struct { | 33 | type floatingAxisGeometry struct { | ||
| @@ -76,7 +90,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||||
| 76 | } | 90 | } | ||
| 77 | } | 91 | } | ||
| 78 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | 92 | (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme) | ||
| 79 | - | geometry := calculateFloatingGeometry(content, cfg) | 93 | + | desiredGeometry := calculateFloatingGeometry(content, cfg) |
| 80 | 94 | ||||
| 81 | popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation | 95 | popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation | ||
| 82 | 96 | ||||
| @@ -84,6 +98,7 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||||
| 84 | // every read of them, including the blit, in the same critical section. | 98 | // every read of them, including the blit, in the same critical section. | ||
| 85 | // In particular, Frame is not safe to retain as an alias after unlocking. | 99 | // In particular, Frame is not safe to retain as an alias after unlocking. | ||
| 86 | p.mu.Lock() | 100 | p.mu.Lock() | ||
| 101 | + | geometry := p.committedFloatingGeometryLocked(desiredGeometry) | |||
| 87 | title := p.displayTitleLocked() | 102 | title := p.displayTitleLocked() | ||
| 88 | titleGeneration := p.title.generation | 103 | titleGeneration := p.title.generation | ||
| 89 | screenDamage := p.screen.Damage() | 104 | screenDamage := p.screen.Damage() | ||
| @@ -140,8 +155,9 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, | |||||
| 140 | 155 | ||||
| 141 | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | 156 | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | ||
| 142 | // is intentionally outside pane.mu; failed resizes do not publish any state. | 157 | // is intentionally outside pane.mu; failed resizes do not publish any state. | ||
| 143 | - | func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool { | 158 | + | func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool { |
| 144 | - | if p == nil || inner.Width <= 0 || inner.Height <= 0 { | 159 | + | inner := geometry.Inner |
| 160 | + | if p == nil || !geometry.valid() { | |||
| 145 | return false | 161 | return false | ||
| 146 | } | 162 | } | ||
| 147 | p.resizeMu.Lock() | 163 | p.resizeMu.Lock() | ||
| @@ -163,6 +179,7 @@ func (d *Daemon) resizeFloatingPane(p *pane, inner domain.Rect) bool { | |||||
| 163 | p.mu.Lock() | 179 | p.mu.Lock() | ||
| 164 | p.screen.Resize(inner.Width, inner.Height) | 180 | p.screen.Resize(inner.Width, inner.Height) | ||
| 165 | p.rect = inner | 181 | p.rect = inner | ||
| 182 | + | p.popupGeometry = geometry | |||
| 166 | p.mu.Unlock() | 183 | p.mu.Unlock() | ||
| 167 | return true | 184 | return true | ||
| 168 | } | 185 | } | ||
| @@ -180,7 +197,7 @@ func (d *Daemon) resizeInstalledFloating(tb *tab) bool { | |||||
| 180 | if p == nil { | 197 | if p == nil { | ||
| 181 | return false | 198 | return false | ||
| 182 | } | 199 | } | ||
| 183 | - | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig()).Inner) | 200 | + | return d.resizeFloatingPane(p, calculateFloatingGeometry(content, d.currentFloatingConfig())) |
| 184 | } | 201 | } | ||
| 185 | 202 | ||||
| 186 | // resizeActiveFloating updates a visible floating pane during a client resize. | 203 | // resizeActiveFloating updates a visible floating pane during a client resize. | ||
M internal/usecase/daemon/floating_render_test.go +70 -16
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "strings" | |
| 7 | 7 | "sync" | |
| 8 | 8 | "testing" | |
| 9 | - | "time" | |
| 10 | 9 | ||
| 11 | 10 | "github.com/stretchr/testify/require" | |
| 12 | 11 | ||
| @@ -214,11 +213,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 214 | 213 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | |
| 215 | 214 | sess := &session{tabs: []*tab{tb}} | |
| 216 | 215 | ||
| 217 | - | initial := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()).Inner | |
| 218 | - | current := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()).Inner | |
| 216 | + | initialGeometry := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()) | |
| 217 | + | currentGeometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()) | |
| 218 | + | initial := initialGeometry.Inner | |
| 219 | + | current := currentGeometry.Inner | |
| 219 | 220 | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | |
| 220 | 221 | floating := newPane("floating", pty, rectSize(initial)) | |
| 221 | 222 | floating.rect = initial | |
| 223 | + | floating.popupGeometry = initialGeometry | |
| 222 | 224 | ||
| 223 | 225 | // Prewarming installs a hidden pane at the initial geometry. | |
| 224 | 226 | tb.mu.Lock() | |
| @@ -238,6 +240,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 238 | 240 | require.Equal(t, initial, floating.rect) | |
| 239 | 241 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | |
| 240 | 242 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | |
| 243 | + | require.Equal(t, initialGeometry, floating.popupGeometry) | |
| 241 | 244 | ||
| 242 | 245 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | |
| 243 | 246 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | |
| @@ -245,6 +248,39 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||
| 245 | 248 | require.Equal(t, current, floating.rect) | |
| 246 | 249 | require.Equal(t, current.Width, floating.screen.Frame.Width) | |
| 247 | 250 | require.Equal(t, current.Height, floating.screen.Frame.Height) | |
| 251 | + | require.Equal(t, currentGeometry, floating.popupGeometry) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) { | |
| 255 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |
| 256 | + | oldContent := domain.Rect{Y: 1, Width: 80, Height: 24} | |
| 257 | + | newContent := domain.Rect{Y: 1, Width: 100, Height: 40} | |
| 258 | + | oldGeometry := calculateFloatingGeometry(oldContent, cfg) | |
| 259 | + | newGeometry := calculateFloatingGeometry(newContent, cfg) | |
| 260 | + | pty := &resizePTY{err: errors.New("resize failed")} | |
| 261 | + | p := newPane("floating", pty, rectSize(oldGeometry.Inner)) | |
| 262 | + | p.rect = oldGeometry.Inner | |
| 263 | + | p.popupGeometry = oldGeometry | |
| 264 | + | tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height}) | |
| 265 | + | installTestFloating(tb, p, true) | |
| 266 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 267 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 268 | + | ||
| 269 | + | require.False(t, d.resizeFloatingPane(p, newGeometry)) | |
| 270 | + | tb.mu.Lock() | |
| 271 | + | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 272 | + | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, cfg) | |
| 273 | + | tb.mu.Unlock() | |
| 274 | + | require.True(t, visible) | |
| 275 | + | require.Equal(t, oldGeometry, inputGeometry) | |
| 276 | + | require.Equal(t, oldGeometry.Inner, copyRect) | |
| 277 | + | ||
| 278 | + | base := renderer.NewFrame(newContent.Width, newContent.Height+2) | |
| 279 | + | frame, _ := composeFloatingFrame(base, nil, p, 1, newContent, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true) | |
| 280 | + | require.Equal(t, '┌', frame.At(oldGeometry.Bounds.X, oldGeometry.Bounds.Y).Rune) | |
| 281 | + | if newGeometry.Bounds.X != oldGeometry.Bounds.X || newGeometry.Bounds.Y != oldGeometry.Bounds.Y { | |
| 282 | + | require.NotEqual(t, '┌', frame.At(newGeometry.Bounds.X, newGeometry.Bounds.Y).Rune) | |
| 283 | + | } | |
| 248 | 284 | } | |
| 249 | 285 | ||
| 250 | 286 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |
| @@ -253,7 +289,8 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 253 | 289 | p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4}) | |
| 254 | 290 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | |
| 255 | 291 | d := newTestDaemon(t, nil, stubClock{}) | |
| 256 | - | require.False(t, d.resizeFloatingPane(p, domain.Rect{X: 2, Y: 2, Width: 9, Height: 7})) | |
| 292 | + | requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}} | |
| 293 | + | require.False(t, d.resizeFloatingPane(p, requested)) | |
| 257 | 294 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | |
| 258 | 295 | require.Equal(t, 5, p.screen.Frame.Width) | |
| 259 | 296 | require.Equal(t, 4, p.screen.Frame.Height) | |
| @@ -263,34 +300,44 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 263 | 300 | pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})} | |
| 264 | 301 | p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2}) | |
| 265 | 302 | d := newTestDaemon(t, nil, stubClock{}) | |
| 266 | - | first := domain.Rect{Width: 4, Height: 3} | |
| 267 | - | second := domain.Rect{Width: 8, Height: 6} | |
| 303 | + | first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}} | |
| 304 | + | second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}} | |
| 268 | 305 | done1 := make(chan bool, 1) | |
| 269 | 306 | done2 := make(chan bool, 1) | |
| 307 | + | secondStarted := make(chan struct{}) | |
| 270 | 308 | go func() { done1 <- d.resizeFloatingPane(p, first) }() | |
| 271 | 309 | <-pty.entered | |
| 272 | - | go func() { done2 <- d.resizeFloatingPane(p, second) }() | |
| 273 | - | require.Never(t, func() bool { return pty.calls() > 1 }, 30*time.Millisecond, time.Millisecond) | |
| 310 | + | go func() { | |
| 311 | + | close(secondStarted) | |
| 312 | + | done2 <- d.resizeFloatingPane(p, second) | |
| 313 | + | }() | |
| 314 | + | <-secondStarted | |
| 274 | 315 | close(pty.release) | |
| 275 | 316 | require.True(t, <-done1) | |
| 276 | 317 | require.True(t, <-done2) | |
| 277 | - | require.Equal(t, second, p.rect) | |
| 318 | + | require.Equal(t, second.Inner, p.rect) | |
| 319 | + | require.Equal(t, second, p.popupGeometry) | |
| 320 | + | require.Equal(t, 1, pty.maxConcurrentCalls()) | |
| 278 | 321 | require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes()) | |
| 279 | 322 | }) | |
| 280 | 323 | } | |
| 281 | 324 | ||
| 282 | 325 | type resizePTY struct { | |
| 283 | - | mu sync.Mutex | |
| 284 | - | resizes []domain.Size | |
| 285 | - | err error | |
| 286 | - | errs []error | |
| 287 | - | entered chan struct{} | |
| 288 | - | release chan struct{} | |
| 326 | + | mu sync.Mutex | |
| 327 | + | resizes []domain.Size | |
| 328 | + | err error | |
| 329 | + | errs []error | |
| 330 | + | entered chan struct{} | |
| 331 | + | release chan struct{} | |
| 332 | + | activeCalls int | |
| 333 | + | maxConcurrent int | |
| 289 | 334 | } | |
| 290 | 335 | ||
| 291 | 336 | func (p *resizePTY) Resize(sz domain.Size) error { | |
| 292 | 337 | p.mu.Lock() | |
| 293 | 338 | p.resizes = append(p.resizes, sz) | |
| 339 | + | p.activeCalls++ | |
| 340 | + | p.maxConcurrent = max(p.maxConcurrent, p.activeCalls) | |
| 294 | 341 | n := len(p.resizes) | |
| 295 | 342 | err := p.err | |
| 296 | 343 | if n <= len(p.errs) { | |
| @@ -301,9 +348,16 @@ func (p *resizePTY) Resize(sz domain.Size) error { | |||
| 301 | 348 | close(p.entered) | |
| 302 | 349 | <-p.release | |
| 303 | 350 | } | |
| 351 | + | p.mu.Lock() | |
| 352 | + | p.activeCalls-- | |
| 353 | + | p.mu.Unlock() | |
| 304 | 354 | return err | |
| 305 | 355 | } | |
| 306 | - | func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) } | |
| 356 | + | func (p *resizePTY) maxConcurrentCalls() int { | |
| 357 | + | p.mu.Lock() | |
| 358 | + | defer p.mu.Unlock() | |
| 359 | + | return p.maxConcurrent | |
| 360 | + | } | |
| 307 | 361 | func (p *resizePTY) sizes() []domain.Size { | |
| 308 | 362 | p.mu.Lock() | |
| 309 | 363 | defer p.mu.Unlock() | |
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "strings" | 6 | "strings" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | - | "time" | |||
| 10 | 9 | ||||
| 11 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 12 | 11 | ||||
| @@ -214,11 +213,14 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||||
| 214 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | 213 | tb := newTab(nil, domain.Size{Cols: 80, Rows: 24}) | ||
| 215 | sess := &session{tabs: []*tab{tb}} | 214 | sess := &session{tabs: []*tab{tb}} | ||
| 216 | 215 | ||||
| 217 | - | initial := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()).Inner | 216 | + | initialGeometry := calculateFloatingGeometry(domain.Rect{Width: 80, Height: 24}, d.currentFloatingConfig()) |
| 218 | - | current := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()).Inner | 217 | + | currentGeometry := calculateFloatingGeometry(domain.Rect{Width: 100, Height: 40}, d.currentFloatingConfig()) |
| 218 | + | initial := initialGeometry.Inner | |||
| 219 | + | current := currentGeometry.Inner | |||
| 219 | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | 220 | pty := &resizePTY{errs: []error{errors.New("first show fails"), nil}} | ||
| 220 | floating := newPane("floating", pty, rectSize(initial)) | 221 | floating := newPane("floating", pty, rectSize(initial)) | ||
| 221 | floating.rect = initial | 222 | floating.rect = initial | ||
| 223 | + | floating.popupGeometry = initialGeometry | |||
| 222 | 224 | ||||
| 223 | // Prewarming installs a hidden pane at the initial geometry. | 225 | // Prewarming installs a hidden pane at the initial geometry. | ||
| 224 | tb.mu.Lock() | 226 | tb.mu.Lock() | ||
| @@ -238,6 +240,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||||
| 238 | require.Equal(t, initial, floating.rect) | 240 | require.Equal(t, initial, floating.rect) | ||
| 239 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | 241 | require.Equal(t, initial.Width, floating.screen.Frame.Width) | ||
| 240 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | 242 | require.Equal(t, initial.Height, floating.screen.Frame.Height) | ||
| 243 | + | require.Equal(t, initialGeometry, floating.popupGeometry) | |||
| 241 | 244 | ||||
| 242 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | 245 | require.NoError(t, d.toggleFloating(sess, nil)) // hide | ||
| 243 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | 246 | require.NoError(t, d.toggleFloating(sess, nil)) // retry show | ||
| @@ -245,6 +248,39 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) { | |||||
| 245 | require.Equal(t, current, floating.rect) | 248 | require.Equal(t, current, floating.rect) | ||
| 246 | require.Equal(t, current.Width, floating.screen.Frame.Width) | 249 | require.Equal(t, current.Width, floating.screen.Frame.Width) | ||
| 247 | require.Equal(t, current.Height, floating.screen.Frame.Height) | 250 | require.Equal(t, current.Height, floating.screen.Frame.Height) | ||
| 251 | + | require.Equal(t, currentGeometry, floating.popupGeometry) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) { | |||
| 255 | + | cfg := domain.FloatingConfig{Width: 50, Height: 50} | |||
| 256 | + | oldContent := domain.Rect{Y: 1, Width: 80, Height: 24} | |||
| 257 | + | newContent := domain.Rect{Y: 1, Width: 100, Height: 40} | |||
| 258 | + | oldGeometry := calculateFloatingGeometry(oldContent, cfg) | |||
| 259 | + | newGeometry := calculateFloatingGeometry(newContent, cfg) | |||
| 260 | + | pty := &resizePTY{err: errors.New("resize failed")} | |||
| 261 | + | p := newPane("floating", pty, rectSize(oldGeometry.Inner)) | |||
| 262 | + | p.rect = oldGeometry.Inner | |||
| 263 | + | p.popupGeometry = oldGeometry | |||
| 264 | + | tb := newTab(nil, domain.Size{Cols: newContent.Width, Rows: newContent.Height}) | |||
| 265 | + | installTestFloating(tb, p, true) | |||
| 266 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 267 | + | d.ApplyConfig(domain.Config{Floating: cfg}) | |||
| 268 | + | ||||
| 269 | + | require.False(t, d.resizeFloatingPane(p, newGeometry)) | |||
| 270 | + | tb.mu.Lock() | |||
| 271 | + | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |||
| 272 | + | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, cfg) | |||
| 273 | + | tb.mu.Unlock() | |||
| 274 | + | require.True(t, visible) | |||
| 275 | + | require.Equal(t, oldGeometry, inputGeometry) | |||
| 276 | + | require.Equal(t, oldGeometry.Inner, copyRect) | |||
| 277 | + | ||||
| 278 | + | base := renderer.NewFrame(newContent.Width, newContent.Height+2) | |||
| 279 | + | frame, _ := composeFloatingFrame(base, nil, p, 1, newContent, cfg, tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true) | |||
| 280 | + | require.Equal(t, '┌', frame.At(oldGeometry.Bounds.X, oldGeometry.Bounds.Y).Rune) | |||
| 281 | + | if newGeometry.Bounds.X != oldGeometry.Bounds.X || newGeometry.Bounds.Y != oldGeometry.Bounds.Y { | |||
| 282 | + | require.NotEqual(t, '┌', frame.At(newGeometry.Bounds.X, newGeometry.Bounds.Y).Rune) | |||
| 283 | + | } | |||
| 248 | } | 284 | } | ||
| 249 | 285 | ||||
| 250 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | 286 | func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | ||
| @@ -253,7 +289,8 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||||
| 253 | p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4}) | 289 | p := newPane("floating", pty, domain.Size{Cols: 5, Rows: 4}) | ||
| 254 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | 290 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | ||
| 255 | d := newTestDaemon(t, nil, stubClock{}) | 291 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 256 | - | require.False(t, d.resizeFloatingPane(p, domain.Rect{X: 2, Y: 2, Width: 9, Height: 7})) | 292 | + | requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}} |
| 293 | + | require.False(t, d.resizeFloatingPane(p, requested)) | |||
| 257 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | 294 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | ||
| 258 | require.Equal(t, 5, p.screen.Frame.Width) | 295 | require.Equal(t, 5, p.screen.Frame.Width) | ||
| 259 | require.Equal(t, 4, p.screen.Frame.Height) | 296 | require.Equal(t, 4, p.screen.Frame.Height) | ||
| @@ -263,34 +300,44 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||||
| 263 | pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})} | 300 | pty := &resizePTY{entered: make(chan struct{}), release: make(chan struct{})} | ||
| 264 | p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2}) | 301 | p := newPane("floating", pty, domain.Size{Cols: 2, Rows: 2}) | ||
| 265 | d := newTestDaemon(t, nil, stubClock{}) | 302 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 266 | - | first := domain.Rect{Width: 4, Height: 3} | 303 | + | first := floatingGeometry{Bounds: domain.Rect{Width: 6, Height: 5}, Inner: domain.Rect{X: 1, Y: 1, Width: 4, Height: 3}} |
| 267 | - | second := domain.Rect{Width: 8, Height: 6} | 304 | + | second := floatingGeometry{Bounds: domain.Rect{Width: 10, Height: 8}, Inner: domain.Rect{X: 1, Y: 1, Width: 8, Height: 6}} |
| 268 | done1 := make(chan bool, 1) | 305 | done1 := make(chan bool, 1) | ||
| 269 | done2 := make(chan bool, 1) | 306 | done2 := make(chan bool, 1) | ||
| 307 | + | secondStarted := make(chan struct{}) | |||
| 270 | go func() { done1 <- d.resizeFloatingPane(p, first) }() | 308 | go func() { done1 <- d.resizeFloatingPane(p, first) }() | ||
| 271 | <-pty.entered | 309 | <-pty.entered | ||
| 272 | - | go func() { done2 <- d.resizeFloatingPane(p, second) }() | 310 | + | go func() { |
| 273 | - | require.Never(t, func() bool { return pty.calls() > 1 }, 30*time.Millisecond, time.Millisecond) | 311 | + | close(secondStarted) |
| 312 | + | done2 <- d.resizeFloatingPane(p, second) | |||
| 313 | + | }() | |||
| 314 | + | <-secondStarted | |||
| 274 | close(pty.release) | 315 | close(pty.release) | ||
| 275 | require.True(t, <-done1) | 316 | require.True(t, <-done1) | ||
| 276 | require.True(t, <-done2) | 317 | require.True(t, <-done2) | ||
| 277 | - | require.Equal(t, second, p.rect) | 318 | + | require.Equal(t, second.Inner, p.rect) |
| 319 | + | require.Equal(t, second, p.popupGeometry) | |||
| 320 | + | require.Equal(t, 1, pty.maxConcurrentCalls()) | |||
| 278 | require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes()) | 321 | require.Equal(t, []domain.Size{{Cols: 4, Rows: 3}, {Cols: 8, Rows: 6}}, pty.sizes()) | ||
| 279 | }) | 322 | }) | ||
| 280 | } | 323 | } | ||
| 281 | 324 | ||||
| 282 | type resizePTY struct { | 325 | type resizePTY struct { | ||
| 283 | - | mu sync.Mutex | 326 | + | mu sync.Mutex |
| 284 | - | resizes []domain.Size | 327 | + | resizes []domain.Size |
| 285 | - | err error | 328 | + | err error |
| 286 | - | errs []error | 329 | + | errs []error |
| 287 | - | entered chan struct{} | 330 | + | entered chan struct{} |
| 288 | - | release chan struct{} | 331 | + | release chan struct{} |
| 332 | + | activeCalls int | |||
| 333 | + | maxConcurrent int | |||
| 289 | } | 334 | } | ||
| 290 | 335 | ||||
| 291 | func (p *resizePTY) Resize(sz domain.Size) error { | 336 | func (p *resizePTY) Resize(sz domain.Size) error { | ||
| 292 | p.mu.Lock() | 337 | p.mu.Lock() | ||
| 293 | p.resizes = append(p.resizes, sz) | 338 | p.resizes = append(p.resizes, sz) | ||
| 339 | + | p.activeCalls++ | |||
| 340 | + | p.maxConcurrent = max(p.maxConcurrent, p.activeCalls) | |||
| 294 | n := len(p.resizes) | 341 | n := len(p.resizes) | ||
| 295 | err := p.err | 342 | err := p.err | ||
| 296 | if n <= len(p.errs) { | 343 | if n <= len(p.errs) { | ||
| @@ -301,9 +348,16 @@ func (p *resizePTY) Resize(sz domain.Size) error { | |||||
| 301 | close(p.entered) | 348 | close(p.entered) | ||
| 302 | <-p.release | 349 | <-p.release | ||
| 303 | } | 350 | } | ||
| 351 | + | p.mu.Lock() | |||
| 352 | + | p.activeCalls-- | |||
| 353 | + | p.mu.Unlock() | |||
| 304 | return err | 354 | return err | ||
| 305 | } | 355 | } | ||
| 306 | - | func (p *resizePTY) calls() int { p.mu.Lock(); defer p.mu.Unlock(); return len(p.resizes) } | 356 | + | func (p *resizePTY) maxConcurrentCalls() int { |
| 357 | + | p.mu.Lock() | |||
| 358 | + | defer p.mu.Unlock() | |||
| 359 | + | return p.maxConcurrent | |||
| 360 | + | } | |||
| 307 | func (p *resizePTY) sizes() []domain.Size { | 361 | func (p *resizePTY) sizes() []domain.Size { | ||
| 308 | p.mu.Lock() | 362 | p.mu.Lock() | ||
| 309 | defer p.mu.Unlock() | 363 | defer p.mu.Unlock() | ||
M internal/usecase/daemon/pane.go +1 -0
| @@ -27,6 +27,7 @@ type pane struct { | |||
| 27 | 27 | flush chan struct{} | |
| 28 | 28 | syncGen uint64 | |
| 29 | 29 | rect domain.Rect | |
| 30 | + | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | |
| 30 | 31 | title paneTitleState | |
| 31 | 32 | ctx context.Context | |
| 32 | 33 | cancel context.CancelFunc | |
| @@ -27,6 +27,7 @@ type pane struct { | |||||
| 27 | flush chan struct{} | 27 | flush chan struct{} | ||
| 28 | syncGen uint64 | 28 | syncGen uint64 | ||
| 29 | rect domain.Rect | 29 | rect domain.Rect | ||
| 30 | + | popupGeometry floatingGeometry // last geometry committed after a successful floating resize | |||
| 30 | title paneTitleState | 31 | title paneTitleState | ||
| 31 | ctx context.Context | 32 | ctx context.Context | ||
| 32 | cancel context.CancelFunc | 33 | cancel context.CancelFunc | ||
M internal/usecase/daemon/render.go +16 -6
| @@ -283,7 +283,11 @@ func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, | |||
| 283 | 283 | return domain.Rect{} | |
| 284 | 284 | } | |
| 285 | 285 | if hasFloating && p == floating { | |
| 286 | - | return calculateFloatingGeometry(contentArea, cfg).Inner | |
| 286 | + | desired := calculateFloatingGeometry(contentArea, cfg) | |
| 287 | + | p.mu.Lock() | |
| 288 | + | geometry := p.committedFloatingGeometryLocked(desired) | |
| 289 | + | p.mu.Unlock() | |
| 290 | + | return geometry.Inner | |
| 287 | 291 | } | |
| 288 | 292 | for _, placement := range layoutSnap.placements { | |
| 289 | 293 | if placement.ID == p.id && !placement.Collapsed { | |
| @@ -373,9 +377,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 373 | 377 | ac.sendMu.Unlock() | |
| 374 | 378 | return | |
| 375 | 379 | } | |
| 376 | - | // Recheck the slot after refreshing its title outside the tab lock. | |
| 380 | + | // Recheck the slot after refreshing its title outside the tab lock. One | |
| 381 | + | // paint uses one immutable floating config snapshot across composition, | |
| 382 | + | // copy targeting, and cursor placement. | |
| 377 | 383 | floating = tb.floating.pane | |
| 378 | 384 | hasFloating = tb.floating.state == floatingVisible && floating != nil | |
| 385 | + | floatingCfg := d.currentFloatingConfig() | |
| 379 | 386 | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |
| 380 | 387 | if reset || overlayActive { | |
| 381 | 388 | ac.bars.Reset() | |
| @@ -393,14 +400,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 393 | 400 | } | |
| 394 | 401 | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | |
| 395 | 402 | if hasFloating { | |
| 396 | - | frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, d.currentFloatingConfig(), layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |
| 403 | + | frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, floatingCfg, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | |
| 397 | 404 | } | |
| 398 | 405 | if overlays.copyActive { | |
| 399 | 406 | copyPane := overlays.copyPane | |
| 400 | 407 | if copyPane == nil { | |
| 401 | 408 | copyPane = p | |
| 402 | 409 | } | |
| 403 | - | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, d.currentFloatingConfig()) | |
| 410 | + | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg) | |
| 404 | 411 | frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars) | |
| 405 | 412 | } | |
| 406 | 413 | // A palette above normal/copy content dims that composed content. When a | |
| @@ -434,10 +441,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 434 | 441 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | |
| 435 | 442 | if hasFloating { | |
| 436 | 443 | cursorPane = floating | |
| 437 | - | geometry := calculateFloatingGeometry(contentArea, d.currentFloatingConfig()) | |
| 438 | - | cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0 | |
| 439 | 444 | } | |
| 440 | 445 | cursorPane.mu.Lock() | |
| 446 | + | if hasFloating { | |
| 447 | + | desiredGeometry := calculateFloatingGeometry(contentArea, floatingCfg) | |
| 448 | + | geometry := floating.committedFloatingGeometryLocked(desiredGeometry) | |
| 449 | + | cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0 | |
| 450 | + | } | |
| 441 | 451 | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | |
| 442 | 452 | cursorPane.mu.Unlock() | |
| 443 | 453 | ac.sess.mu.Lock() | |
| @@ -283,7 +283,11 @@ func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, | |||||
| 283 | return domain.Rect{} | 283 | return domain.Rect{} | ||
| 284 | } | 284 | } | ||
| 285 | if hasFloating && p == floating { | 285 | if hasFloating && p == floating { | ||
| 286 | - | return calculateFloatingGeometry(contentArea, cfg).Inner | 286 | + | desired := calculateFloatingGeometry(contentArea, cfg) |
| 287 | + | p.mu.Lock() | |||
| 288 | + | geometry := p.committedFloatingGeometryLocked(desired) | |||
| 289 | + | p.mu.Unlock() | |||
| 290 | + | return geometry.Inner | |||
| 287 | } | 291 | } | ||
| 288 | for _, placement := range layoutSnap.placements { | 292 | for _, placement := range layoutSnap.placements { | ||
| 289 | if placement.ID == p.id && !placement.Collapsed { | 293 | if placement.ID == p.id && !placement.Collapsed { | ||
| @@ -373,9 +377,12 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 373 | ac.sendMu.Unlock() | 377 | ac.sendMu.Unlock() | ||
| 374 | return | 378 | return | ||
| 375 | } | 379 | } | ||
| 376 | - | // Recheck the slot after refreshing its title outside the tab lock. | 380 | + | // Recheck the slot after refreshing its title outside the tab lock. One |
| 381 | + | // paint uses one immutable floating config snapshot across composition, | |||
| 382 | + | // copy targeting, and cursor placement. | |||
| 377 | floating = tb.floating.pane | 383 | floating = tb.floating.pane | ||
| 378 | hasFloating = tb.floating.state == floatingVisible && floating != nil | 384 | hasFloating = tb.floating.state == floatingVisible && floating != nil | ||
| 385 | + | floatingCfg := d.currentFloatingConfig() | |||
| 379 | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | 386 | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | ||
| 380 | if reset || overlayActive { | 387 | if reset || overlayActive { | ||
| 381 | ac.bars.Reset() | 388 | ac.bars.Reset() | ||
| @@ -393,14 +400,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 393 | } | 400 | } | ||
| 394 | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | 401 | contentArea := domain.Rect{Y: 1, Width: frame.Width, Height: max(0, frame.Height-2)} | ||
| 395 | if hasFloating { | 402 | if hasFloating { | ||
| 396 | - | frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, d.currentFloatingConfig(), layoutSnap, bars.theme, &ac.composed, reset || overlayActive) | 403 | + | frame, damage = composeFloatingFrame(frame, damage, floating, tb.floating.generation, contentArea, floatingCfg, layoutSnap, bars.theme, &ac.composed, reset || overlayActive) |
| 397 | } | 404 | } | ||
| 398 | if overlays.copyActive { | 405 | if overlays.copyActive { | ||
| 399 | copyPane := overlays.copyPane | 406 | copyPane := overlays.copyPane | ||
| 400 | if copyPane == nil { | 407 | if copyPane == nil { | ||
| 401 | copyPane = p | 408 | copyPane = p | ||
| 402 | } | 409 | } | ||
| 403 | - | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, d.currentFloatingConfig()) | 410 | + | copyTarget := copyTargetRectLocked(layoutSnap, contentArea, copyPane, floating, hasFloating, floatingCfg) |
| 404 | frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars) | 411 | frame, damage = composeCopyClientFrame(overlays.copyMode, copyPane, copyTarget, frame, bars) | ||
| 405 | } | 412 | } | ||
| 406 | // A palette above normal/copy content dims that composed content. When a | 413 | // A palette above normal/copy content dims that composed content. When a | ||
| @@ -434,10 +441,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 434 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | 441 | cursorContent, cursorVisible := focusedPaneContentRect(layoutSnap, p.id) | ||
| 435 | if hasFloating { | 442 | if hasFloating { | ||
| 436 | cursorPane = floating | 443 | cursorPane = floating | ||
| 437 | - | geometry := calculateFloatingGeometry(contentArea, d.currentFloatingConfig()) | |||
| 438 | - | cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0 | |||
| 439 | } | 444 | } | ||
| 440 | cursorPane.mu.Lock() | 445 | cursorPane.mu.Lock() | ||
| 446 | + | if hasFloating { | |||
| 447 | + | desiredGeometry := calculateFloatingGeometry(contentArea, floatingCfg) | |||
| 448 | + | geometry := floating.committedFloatingGeometryLocked(desiredGeometry) | |||
| 449 | + | cursorContent, cursorVisible = geometry.Inner, geometry.Inner.Width > 0 && geometry.Inner.Height > 0 | |||
| 450 | + | } | |||
| 441 | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | 451 | desiredCursor := desiredCursorOut(cursorPane.screen, cursorContent, !cursorVisible || overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive) | ||
| 442 | cursorPane.mu.Unlock() | 452 | cursorPane.mu.Unlock() | ||
| 443 | ac.sess.mu.Lock() | 453 | ac.sess.mu.Lock() | ||