refactor(daemon): remove legacy resize dispatch owners
- commit
- 29a2879de9f59c30a525844725554955923db7b8
- author
- brice · · 2026-07-12 08:31 UTC
- parent
- cf19ce4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +82 -197
M internal/usecase/daemon/client.go +19 -32
| @@ -39,22 +39,19 @@ type attachedClient struct { | |||
| 39 | 39 | // pipelineCache is the last successfully emitted composition. pipelineScratch | |
| 40 | 40 | // is its attachment-owned alternate buffer; both are only touched under | |
| 41 | 41 | // sendMu and must never share mutable backing storage. | |
| 42 | - | pipelineCache composeCacheInput | |
| 43 | - | pipelineScratch composeCacheInput | |
| 44 | - | renderScratch renderCaptureScratch // only touched while sendMu is held | |
| 45 | - | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | |
| 46 | - | resizePaint pendingByteTimer // guarded by sendMu | |
| 47 | - | resizePaintGeneration uint64 // guarded by sendMu | |
| 48 | - | resizePaintPending bool // guarded by sendMu | |
| 49 | - | size domain.Size | |
| 50 | - | keys *keys.Router | |
| 51 | - | sess Guarded[*session] | |
| 52 | - | mouseScan mouse.Scanner | |
| 53 | - | themeMu sync.Mutex | |
| 54 | - | theme themeui.Theme | |
| 55 | - | clientTheme themeui.Theme | |
| 56 | - | lastCursor cursorOut | |
| 57 | - | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |
| 42 | + | pipelineCache composeCacheInput | |
| 43 | + | pipelineScratch composeCacheInput | |
| 44 | + | renderScratch renderCaptureScratch // only touched while sendMu is held | |
| 45 | + | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | |
| 46 | + | size domain.Size | |
| 47 | + | keys *keys.Router | |
| 48 | + | sess Guarded[*session] | |
| 49 | + | mouseScan mouse.Scanner | |
| 50 | + | themeMu sync.Mutex | |
| 51 | + | theme themeui.Theme | |
| 52 | + | clientTheme themeui.Theme | |
| 53 | + | lastCursor cursorOut | |
| 54 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |
| 58 | 55 | // previousSession is guarded independently. It is retained through temporary | |
| 59 | 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | |
| 60 | 57 | previousSession Guarded[*session] | |
| @@ -71,6 +68,8 @@ type cursorOut struct { | |||
| 71 | 68 | hasStyle bool | |
| 72 | 69 | } | |
| 73 | 70 | ||
| 71 | + | // pendingByteTimer is retained for overlay input timers. Resize timing belongs | |
| 72 | + | // exclusively to renderCoordinator. | |
| 74 | 73 | type pendingByteTimer struct { | |
| 75 | 74 | timer ports.Timer | |
| 76 | 75 | done chan struct{} | |
| @@ -100,16 +99,6 @@ func (p *pendingByteTimer) stop() { | |||
| 100 | 99 | } | |
| 101 | 100 | } | |
| 102 | 101 | ||
| 103 | - | // cancelResizePaint invalidates callbacks before detaching or replacing an | |
| 104 | - | // attachment. It deliberately takes sendMu before any session/daemon lock. | |
| 105 | - | func (ac *attachedClient) cancelResizePaint() { | |
| 106 | - | ac.sendMu.Lock() | |
| 107 | - | ac.resizePaintGeneration++ | |
| 108 | - | ac.resizePaintPending = false | |
| 109 | - | ac.resizePaint.stop() | |
| 110 | - | ac.sendMu.Unlock() | |
| 111 | - | } | |
| 112 | - | ||
| 113 | 102 | func (ac *attachedClient) initOverlays() { | |
| 114 | 103 | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) | |
| 115 | 104 | } | |
| @@ -413,7 +402,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, | |||
| 413 | 402 | // paint occurs under sendMu, closing a park/resume race after this | |
| 414 | 403 | // unlocked coordinator validation. | |
| 415 | 404 | if w.attachment != nil && rc.wakeCurrent(w) { | |
| 416 | - | d.paintCoordinatorWake(sess, w) | |
| 405 | + | d.paint(sess, w.attachment, w.reset, w.attachmentEpoch) | |
| 417 | 406 | } | |
| 418 | 407 | }, | |
| 419 | 408 | ackReady: func() bool { | |
| @@ -459,8 +448,6 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) { | |||
| 459 | 448 | if old == nil { | |
| 460 | 449 | return | |
| 461 | 450 | } | |
| 462 | - | old.cancelResizePaint() | |
| 463 | - | ||
| 464 | 451 | d.unregisterPreview(old) | |
| 465 | 452 | old.clearPreviousSession() | |
| 466 | 453 | old.setSession(nil) | |
| @@ -487,8 +474,10 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 487 | 474 | d.resizeForFirstPaint(sess, ac, clientSize) | |
| 488 | 475 | } | |
| 489 | 476 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 490 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 477 | + | // Activation can synchronously resize a retained floating pane through the | |
| 478 | + | // same transaction. Fold its invalidation into this mandatory Welcome frame. | |
| 491 | 479 | d.activateTab(sess, tb) | |
| 480 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 492 | 481 | } | |
| 493 | 482 | ||
| 494 | 483 | // runConnLoop is the per-connection input router: it pumps client messages | |
| @@ -563,7 +552,6 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||
| 563 | 552 | if !sess.detachIfCurrent(ac) { | |
| 564 | 553 | return // already displaced by a newer client; nothing to do | |
| 565 | 554 | } | |
| 566 | - | ac.cancelResizePaint() | |
| 567 | 555 | if rc := sess.renderCoordinator(); rc != nil { | |
| 568 | 556 | rc.noteDetach(ac) | |
| 569 | 557 | } | |
| @@ -637,7 +625,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por | |||
| 637 | 625 | return | |
| 638 | 626 | } | |
| 639 | 627 | if sess.detachIfCurrent(ac) { | |
| 640 | - | ac.cancelResizePaint() | |
| 641 | 628 | if rc := sess.renderCoordinator(); rc != nil { | |
| 642 | 629 | rc.noteDetach(ac) | |
| 643 | 630 | } | |
| @@ -39,22 +39,19 @@ type attachedClient struct { | |||||
| 39 | // pipelineCache is the last successfully emitted composition. pipelineScratch | 39 | // pipelineCache is the last successfully emitted composition. pipelineScratch | ||
| 40 | // is its attachment-owned alternate buffer; both are only touched under | 40 | // is its attachment-owned alternate buffer; both are only touched under | ||
| 41 | // sendMu and must never share mutable backing storage. | 41 | // sendMu and must never share mutable backing storage. | ||
| 42 | - | pipelineCache composeCacheInput | 42 | + | pipelineCache composeCacheInput |
| 43 | - | pipelineScratch composeCacheInput | 43 | + | pipelineScratch composeCacheInput |
| 44 | - | renderScratch renderCaptureScratch // only touched while sendMu is held | 44 | + | renderScratch renderCaptureScratch // only touched while sendMu is held |
| 45 | - | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held | 45 | + | captureFrames map[layout.PaneID]capturedPaneRenderState // only touched while sendMu is held |
| 46 | - | resizePaint pendingByteTimer // guarded by sendMu | 46 | + | size domain.Size |
| 47 | - | resizePaintGeneration uint64 // guarded by sendMu | 47 | + | keys *keys.Router |
| 48 | - | resizePaintPending bool // guarded by sendMu | 48 | + | sess Guarded[*session] |
| 49 | - | size domain.Size | 49 | + | mouseScan mouse.Scanner |
| 50 | - | keys *keys.Router | 50 | + | themeMu sync.Mutex |
| 51 | - | sess Guarded[*session] | 51 | + | theme themeui.Theme |
| 52 | - | mouseScan mouse.Scanner | 52 | + | clientTheme themeui.Theme |
| 53 | - | themeMu sync.Mutex | 53 | + | lastCursor cursorOut |
| 54 | - | theme themeui.Theme | 54 | + | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held |
| 55 | - | clientTheme themeui.Theme | |||
| 56 | - | lastCursor cursorOut | |||
| 57 | - | renderStages renderStageHooks // invoked at real pipeline boundaries while sendMu is held | |||
| 58 | // previousSession is guarded independently. It is retained through temporary | 55 | // previousSession is guarded independently. It is retained through temporary | ||
| 59 | // setSession(nil) hand-offs and cleared only on terminal teardown. | 56 | // setSession(nil) hand-offs and cleared only on terminal teardown. | ||
| 60 | previousSession Guarded[*session] | 57 | previousSession Guarded[*session] | ||
| @@ -71,6 +68,8 @@ type cursorOut struct { | |||||
| 71 | hasStyle bool | 68 | hasStyle bool | ||
| 72 | } | 69 | } | ||
| 73 | 70 | ||||
| 71 | + | // pendingByteTimer is retained for overlay input timers. Resize timing belongs | |||
| 72 | + | // exclusively to renderCoordinator. | |||
| 74 | type pendingByteTimer struct { | 73 | type pendingByteTimer struct { | ||
| 75 | timer ports.Timer | 74 | timer ports.Timer | ||
| 76 | done chan struct{} | 75 | done chan struct{} | ||
| @@ -100,16 +99,6 @@ func (p *pendingByteTimer) stop() { | |||||
| 100 | } | 99 | } | ||
| 101 | } | 100 | } | ||
| 102 | 101 | ||||
| 103 | - | // cancelResizePaint invalidates callbacks before detaching or replacing an | |||
| 104 | - | // attachment. It deliberately takes sendMu before any session/daemon lock. | |||
| 105 | - | func (ac *attachedClient) cancelResizePaint() { | |||
| 106 | - | ac.sendMu.Lock() | |||
| 107 | - | ac.resizePaintGeneration++ | |||
| 108 | - | ac.resizePaintPending = false | |||
| 109 | - | ac.resizePaint.stop() | |||
| 110 | - | ac.sendMu.Unlock() | |||
| 111 | - | } | |||
| 112 | - | ||||
| 113 | func (ac *attachedClient) initOverlays() { | 102 | func (ac *attachedClient) initOverlays() { | ||
| 114 | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) | 103 | ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) }) | ||
| 115 | } | 104 | } | ||
| @@ -413,7 +402,7 @@ func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, | |||||
| 413 | // paint occurs under sendMu, closing a park/resume race after this | 402 | // paint occurs under sendMu, closing a park/resume race after this | ||
| 414 | // unlocked coordinator validation. | 403 | // unlocked coordinator validation. | ||
| 415 | if w.attachment != nil && rc.wakeCurrent(w) { | 404 | if w.attachment != nil && rc.wakeCurrent(w) { | ||
| 416 | - | d.paintCoordinatorWake(sess, w) | 405 | + | d.paint(sess, w.attachment, w.reset, w.attachmentEpoch) |
| 417 | } | 406 | } | ||
| 418 | }, | 407 | }, | ||
| 419 | ackReady: func() bool { | 408 | ackReady: func() bool { | ||
| @@ -459,8 +448,6 @@ func (d *Daemon) detachReplacedClient(old *attachedClient) { | |||||
| 459 | if old == nil { | 448 | if old == nil { | ||
| 460 | return | 449 | return | ||
| 461 | } | 450 | } | ||
| 462 | - | old.cancelResizePaint() | |||
| 463 | - | ||||
| 464 | d.unregisterPreview(old) | 451 | d.unregisterPreview(old) | ||
| 465 | old.clearPreviousSession() | 452 | old.clearPreviousSession() | ||
| 466 | old.setSession(nil) | 453 | old.setSession(nil) | ||
| @@ -487,8 +474,10 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||||
| 487 | d.resizeForFirstPaint(sess, ac, clientSize) | 474 | d.resizeForFirstPaint(sess, ac, clientSize) | ||
| 488 | } | 475 | } | ||
| 489 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 476 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 490 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | 477 | + | // Activation can synchronously resize a retained floating pane through the |
| 478 | + | // same transaction. Fold its invalidation into this mandatory Welcome frame. | |||
| 491 | d.activateTab(sess, tb) | 479 | d.activateTab(sess, tb) | ||
| 480 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |||
| 492 | } | 481 | } | ||
| 493 | 482 | ||||
| 494 | // runConnLoop is the per-connection input router: it pumps client messages | 483 | // runConnLoop is the per-connection input router: it pumps client messages | ||
| @@ -563,7 +552,6 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran | |||||
| 563 | if !sess.detachIfCurrent(ac) { | 552 | if !sess.detachIfCurrent(ac) { | ||
| 564 | return // already displaced by a newer client; nothing to do | 553 | return // already displaced by a newer client; nothing to do | ||
| 565 | } | 554 | } | ||
| 566 | - | ac.cancelResizePaint() | |||
| 567 | if rc := sess.renderCoordinator(); rc != nil { | 555 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 568 | rc.noteDetach(ac) | 556 | rc.noteDetach(ac) | ||
| 569 | } | 557 | } | ||
| @@ -637,7 +625,6 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed por | |||||
| 637 | return | 625 | return | ||
| 638 | } | 626 | } | ||
| 639 | if sess.detachIfCurrent(ac) { | 627 | if sess.detachIfCurrent(ac) { | ||
| 640 | - | ac.cancelResizePaint() | |||
| 641 | if rc := sess.renderCoordinator(); rc != nil { | 628 | if rc := sess.renderCoordinator(); rc != nil { | ||
| 642 | rc.noteDetach(ac) | 629 | rc.noteDetach(ac) | ||
| 643 | } | 630 | } | ||
M internal/usecase/daemon/floating.go +15 -9
| @@ -169,7 +169,13 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||
| 169 | 169 | return | |
| 170 | 170 | } | |
| 171 | 171 | d.ensureFloatingWarm(sess, tb) | |
| 172 | - | d.resizeInstalledFloating(tb) | |
| 172 | + | tb.mu.Lock() | |
| 173 | + | hasFloating := tb.floating.pane != nil | |
| 174 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 175 | + | tb.mu.Unlock() | |
| 176 | + | if hasFloating { | |
| 177 | + | d.requestTransactionalResize(sess, ac, size, true) | |
| 178 | + | } | |
| 173 | 179 | } | |
| 174 | 180 | ||
| 175 | 181 | // toggleFloating changes only this tab's slot. Opening an uninitialized slot | |
| @@ -197,10 +203,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||
| 197 | 203 | return nil | |
| 198 | 204 | } | |
| 199 | 205 | if visible { | |
| 200 | - | d.resizeActiveFloating(tb) | |
| 201 | - | } | |
| 202 | - | if ac != nil { | |
| 203 | - | d.invalidateRender(sess, ac, true, "floating.go") | |
| 206 | + | tb.mu.Lock() | |
| 207 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 208 | + | tb.mu.Unlock() | |
| 209 | + | d.requestTransactionalResize(sess, ac, size, true) | |
| 204 | 210 | } | |
| 205 | 211 | return nil | |
| 206 | 212 | } | |
| @@ -343,13 +349,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||
| 343 | 349 | d.reapplyThemeSession(sess) | |
| 344 | 350 | d.startPaneGoroutines(sess, tb, p) | |
| 345 | 351 | if visible { | |
| 346 | - | d.resizeActiveFloating(tb) | |
| 347 | 352 | sess.mu.Lock() | |
| 348 | 353 | ac := sess.client | |
| 349 | 354 | sess.mu.Unlock() | |
| 350 | - | if ac != nil { | |
| 351 | - | d.invalidateRender(sess, ac, true, "floating.go") | |
| 352 | - | } | |
| 355 | + | tb.mu.Lock() | |
| 356 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |
| 357 | + | tb.mu.Unlock() | |
| 358 | + | d.requestTransactionalResize(sess, ac, size, true) | |
| 353 | 359 | } | |
| 354 | 360 | } | |
| 355 | 361 | ||
| @@ -169,7 +169,13 @@ func (d *Daemon) activateTab(sess *session, tb *tab) { | |||||
| 169 | return | 169 | return | ||
| 170 | } | 170 | } | ||
| 171 | d.ensureFloatingWarm(sess, tb) | 171 | d.ensureFloatingWarm(sess, tb) | ||
| 172 | - | d.resizeInstalledFloating(tb) | 172 | + | tb.mu.Lock() |
| 173 | + | hasFloating := tb.floating.pane != nil | |||
| 174 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} | |||
| 175 | + | tb.mu.Unlock() | |||
| 176 | + | if hasFloating { | |||
| 177 | + | d.requestTransactionalResize(sess, ac, size, true) | |||
| 178 | + | } | |||
| 173 | } | 179 | } | ||
| 174 | 180 | ||||
| 175 | // toggleFloating changes only this tab's slot. Opening an uninitialized slot | 181 | // toggleFloating changes only this tab's slot. Opening an uninitialized slot | ||
| @@ -197,10 +203,10 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error { | |||||
| 197 | return nil | 203 | return nil | ||
| 198 | } | 204 | } | ||
| 199 | if visible { | 205 | if visible { | ||
| 200 | - | d.resizeActiveFloating(tb) | 206 | + | tb.mu.Lock() |
| 201 | - | } | 207 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} |
| 202 | - | if ac != nil { | 208 | + | tb.mu.Unlock() |
| 203 | - | d.invalidateRender(sess, ac, true, "floating.go") | 209 | + | d.requestTransactionalResize(sess, ac, size, true) |
| 204 | } | 210 | } | ||
| 205 | return nil | 211 | return nil | ||
| 206 | } | 212 | } | ||
| @@ -343,13 +349,13 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin | |||||
| 343 | d.reapplyThemeSession(sess) | 349 | d.reapplyThemeSession(sess) | ||
| 344 | d.startPaneGoroutines(sess, tb, p) | 350 | d.startPaneGoroutines(sess, tb, p) | ||
| 345 | if visible { | 351 | if visible { | ||
| 346 | - | d.resizeActiveFloating(tb) | |||
| 347 | sess.mu.Lock() | 352 | sess.mu.Lock() | ||
| 348 | ac := sess.client | 353 | ac := sess.client | ||
| 349 | sess.mu.Unlock() | 354 | sess.mu.Unlock() | ||
| 350 | - | if ac != nil { | 355 | + | tb.mu.Lock() |
| 351 | - | d.invalidateRender(sess, ac, true, "floating.go") | 356 | + | size := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows + 2} |
| 352 | - | } | 357 | + | tb.mu.Unlock() |
| 358 | + | d.requestTransactionalResize(sess, ac, size, true) | |||
| 353 | } | 359 | } | ||
| 354 | } | 360 | } | ||
| 355 | 361 | ||||
M internal/usecase/daemon/floating_render.go +0 -67
| @@ -133,70 +133,3 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, | |||
| 133 | 133 | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | |
| 134 | 134 | } | |
| 135 | 135 | } | |
| 136 | - | ||
| 137 | - | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | |
| 138 | - | // is intentionally outside pane.mu; failed resizes do not publish any state. | |
| 139 | - | func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool { | |
| 140 | - | inner := geometry.Inner | |
| 141 | - | if p == nil || !geometry.valid() { | |
| 142 | - | return false | |
| 143 | - | } | |
| 144 | - | p.resizeMu.Lock() | |
| 145 | - | defer p.resizeMu.Unlock() | |
| 146 | - | ||
| 147 | - | p.mu.Lock() | |
| 148 | - | old := p.rect | |
| 149 | - | pty := p.pty | |
| 150 | - | p.mu.Unlock() | |
| 151 | - | if old.Width == inner.Width && old.Height == inner.Height { | |
| 152 | - | // Position and outer bounds can change while the terminal dimensions do | |
| 153 | - | // not. Commit that geometry without needlessly resetting the PTY/screen. | |
| 154 | - | p.mu.Lock() | |
| 155 | - | p.rect = inner | |
| 156 | - | p.popupGeometry = geometry | |
| 157 | - | p.mu.Unlock() | |
| 158 | - | return true | |
| 159 | - | } | |
| 160 | - | if pty != nil { | |
| 161 | - | if err := pty.Resize(rectSize(inner)); err != nil { | |
| 162 | - | d.log.Warn("floating pty resize failed", "err", err) | |
| 163 | - | return false | |
| 164 | - | } | |
| 165 | - | } | |
| 166 | - | p.mu.Lock() | |
| 167 | - | p.screen.Resize(inner.Width, inner.Height) | |
| 168 | - | p.rect = inner | |
| 169 | - | p.popupGeometry = geometry | |
| 170 | - | p.mu.Unlock() | |
| 171 | - | return true | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | // resizeInstalledFloating updates an installed floating pane, including a | |
| 175 | - | // retained hidden pane when its tab becomes active. | |
| 176 | - | func (d *Daemon) resizeInstalledFloating(tb *tab) bool { | |
| 177 | - | if d == nil || tb == nil { | |
| 178 | - | return false | |
| 179 | - | } | |
| 180 | - | tb.mu.Lock() | |
| 181 | - | p := tb.floating.pane | |
| 182 | - | content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows} | |
| 183 | - | tb.mu.Unlock() | |
| 184 | - | if p == nil { | |
| 185 | - | return false | |
| 186 | - | } | |
| 187 | - | return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig())) | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | // resizeActiveFloating updates a visible floating pane during a client resize. | |
| 191 | - | func (d *Daemon) resizeActiveFloating(tb *tab) bool { | |
| 192 | - | if d == nil || tb == nil { | |
| 193 | - | return false | |
| 194 | - | } | |
| 195 | - | tb.mu.Lock() | |
| 196 | - | visible := tb.floating.state == floatingVisible | |
| 197 | - | tb.mu.Unlock() | |
| 198 | - | if !visible { | |
| 199 | - | return false | |
| 200 | - | } | |
| 201 | - | return d.resizeInstalledFloating(tb) | |
| 202 | - | } | |
| @@ -133,70 +133,3 @@ func drawFloatingBorder(frame renderer.Frame, bounds domain.Rect, title string, | |||||
| 133 | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | 133 | ui.DrawText(frame, bounds.X+2, bounds.Y, bounds.X+bounds.Width-2, title, style) | ||
| 134 | } | 134 | } | ||
| 135 | } | 135 | } | ||
| 136 | - | ||||
| 137 | - | // resizeFloatingPane serializes PTY resizes for a floating pane. PTY.Resize | |||
| 138 | - | // is intentionally outside pane.mu; failed resizes do not publish any state. | |||
| 139 | - | func (d *Daemon) resizeFloatingPane(p *pane, geometry floatingGeometry) bool { | |||
| 140 | - | inner := geometry.Inner | |||
| 141 | - | if p == nil || !geometry.valid() { | |||
| 142 | - | return false | |||
| 143 | - | } | |||
| 144 | - | p.resizeMu.Lock() | |||
| 145 | - | defer p.resizeMu.Unlock() | |||
| 146 | - | ||||
| 147 | - | p.mu.Lock() | |||
| 148 | - | old := p.rect | |||
| 149 | - | pty := p.pty | |||
| 150 | - | p.mu.Unlock() | |||
| 151 | - | if old.Width == inner.Width && old.Height == inner.Height { | |||
| 152 | - | // Position and outer bounds can change while the terminal dimensions do | |||
| 153 | - | // not. Commit that geometry without needlessly resetting the PTY/screen. | |||
| 154 | - | p.mu.Lock() | |||
| 155 | - | p.rect = inner | |||
| 156 | - | p.popupGeometry = geometry | |||
| 157 | - | p.mu.Unlock() | |||
| 158 | - | return true | |||
| 159 | - | } | |||
| 160 | - | if pty != nil { | |||
| 161 | - | if err := pty.Resize(rectSize(inner)); err != nil { | |||
| 162 | - | d.log.Warn("floating pty resize failed", "err", err) | |||
| 163 | - | return false | |||
| 164 | - | } | |||
| 165 | - | } | |||
| 166 | - | p.mu.Lock() | |||
| 167 | - | p.screen.Resize(inner.Width, inner.Height) | |||
| 168 | - | p.rect = inner | |||
| 169 | - | p.popupGeometry = geometry | |||
| 170 | - | p.mu.Unlock() | |||
| 171 | - | return true | |||
| 172 | - | } | |||
| 173 | - | ||||
| 174 | - | // resizeInstalledFloating updates an installed floating pane, including a | |||
| 175 | - | // retained hidden pane when its tab becomes active. | |||
| 176 | - | func (d *Daemon) resizeInstalledFloating(tb *tab) bool { | |||
| 177 | - | if d == nil || tb == nil { | |||
| 178 | - | return false | |||
| 179 | - | } | |||
| 180 | - | tb.mu.Lock() | |||
| 181 | - | p := tb.floating.pane | |||
| 182 | - | content := domain.Size{Cols: tb.size.Cols, Rows: tb.size.Rows} | |||
| 183 | - | tb.mu.Unlock() | |||
| 184 | - | if p == nil { | |||
| 185 | - | return false | |||
| 186 | - | } | |||
| 187 | - | return d.resizeFloatingPane(p, calculateContentFloatingGeometry(content, d.currentFloatingConfig())) | |||
| 188 | - | } | |||
| 189 | - | ||||
| 190 | - | // resizeActiveFloating updates a visible floating pane during a client resize. | |||
| 191 | - | func (d *Daemon) resizeActiveFloating(tb *tab) bool { | |||
| 192 | - | if d == nil || tb == nil { | |||
| 193 | - | return false | |||
| 194 | - | } | |||
| 195 | - | tb.mu.Lock() | |||
| 196 | - | visible := tb.floating.state == floatingVisible | |||
| 197 | - | tb.mu.Unlock() | |||
| 198 | - | if !visible { | |||
| 199 | - | return false | |||
| 200 | - | } | |||
| 201 | - | return d.resizeInstalledFloating(tb) | |||
| 202 | - | } | |||
M internal/usecase/daemon/floating_render_test.go +24 -5
| @@ -14,6 +14,25 @@ import ( | |||
| 14 | 14 | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 15 | ) | |
| 16 | 16 | ||
| 17 | + | // applyFloatingResizePlanForTest exercises the transactional apply primitive. | |
| 18 | + | func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool { | |
| 19 | + | if p == nil || !geometry.valid() { | |
| 20 | + | return false | |
| 21 | + | } | |
| 22 | + | plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}} | |
| 23 | + | d.applyResize(&plan) | |
| 24 | + | member := plan.members[0] | |
| 25 | + | if !member.ok { | |
| 26 | + | return false | |
| 27 | + | } | |
| 28 | + | p.mu.Lock() | |
| 29 | + | p.rect = geometry.Inner | |
| 30 | + | p.popupGeometry = geometry | |
| 31 | + | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |
| 32 | + | p.mu.Unlock() | |
| 33 | + | return true | |
| 34 | + | } | |
| 35 | + | ||
| 17 | 36 | func TestFloatingGeometryTranslate(t *testing.T) { | |
| 18 | 37 | geometry := floatingGeometry{ | |
| 19 | 38 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | |
| @@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) | |||
| 200 | 219 | p.rect, p.popupGeometry = initial.Inner, initial | |
| 201 | 220 | d := newTestDaemon(t, nil, stubClock{}) | |
| 202 | 221 | ||
| 203 | - | require.True(t, d.resizeFloatingPane(p, tt.next)) | |
| 222 | + | require.True(t, applyFloatingResizePlanForTest(d, p, tt.next)) | |
| 204 | 223 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | |
| 205 | 224 | require.Equal(t, tt.next.Inner, p.rect) | |
| 206 | 225 | require.Equal(t, tt.next, p.popupGeometry) | |
| @@ -362,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||
| 362 | 381 | d := newTestDaemon(t, nil, stubClock{}) | |
| 363 | 382 | d.ApplyConfig(domain.Config{Floating: cfg}) | |
| 364 | 383 | ||
| 365 | - | require.False(t, d.resizeFloatingPane(p, newGeometry)) | |
| 384 | + | require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry)) | |
| 366 | 385 | tb.mu.Lock() | |
| 367 | 386 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | |
| 368 | 387 | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | |
| @@ -388,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 388 | 407 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | |
| 389 | 408 | d := newTestDaemon(t, nil, stubClock{}) | |
| 390 | 409 | requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}} | |
| 391 | - | require.False(t, d.resizeFloatingPane(p, requested)) | |
| 410 | + | require.False(t, applyFloatingResizePlanForTest(d, p, requested)) | |
| 392 | 411 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | |
| 393 | 412 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | |
| 394 | 413 | require.Equal(t, 5, p.screen.Frame.Width) | |
| @@ -404,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||
| 404 | 423 | done1 := make(chan bool, 1) | |
| 405 | 424 | done2 := make(chan bool, 1) | |
| 406 | 425 | secondStarted := make(chan struct{}) | |
| 407 | - | go func() { done1 <- d.resizeFloatingPane(p, first) }() | |
| 426 | + | go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }() | |
| 408 | 427 | <-pty.entered | |
| 409 | 428 | go func() { | |
| 410 | 429 | close(secondStarted) | |
| 411 | - | done2 <- d.resizeFloatingPane(p, second) | |
| 430 | + | done2 <- applyFloatingResizePlanForTest(d, p, second) | |
| 412 | 431 | }() | |
| 413 | 432 | <-secondStarted | |
| 414 | 433 | close(pty.release) | |
| @@ -14,6 +14,25 @@ import ( | |||||
| 14 | "github.com/bnema/vev/pkg/renderer" | 14 | "github.com/bnema/vev/pkg/renderer" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | + | // applyFloatingResizePlanForTest exercises the transactional apply primitive. | |||
| 18 | + | func applyFloatingResizePlanForTest(d *Daemon, p *pane, geometry floatingGeometry) bool { | |||
| 19 | + | if p == nil || !geometry.valid() { | |||
| 20 | + | return false | |||
| 21 | + | } | |||
| 22 | + | plan := resizePlan{members: []resizeMember{{pane: p, rect: geometry.Inner, floating: geometry, isFloating: true}}} | |||
| 23 | + | d.applyResize(&plan) | |||
| 24 | + | member := plan.members[0] | |||
| 25 | + | if !member.ok { | |||
| 26 | + | return false | |||
| 27 | + | } | |||
| 28 | + | p.mu.Lock() | |||
| 29 | + | p.rect = geometry.Inner | |||
| 30 | + | p.popupGeometry = geometry | |||
| 31 | + | p.screen.Resize(geometry.Inner.Width, geometry.Inner.Height) | |||
| 32 | + | p.mu.Unlock() | |||
| 33 | + | return true | |||
| 34 | + | } | |||
| 35 | + | ||||
| 17 | func TestFloatingGeometryTranslate(t *testing.T) { | 36 | func TestFloatingGeometryTranslate(t *testing.T) { | ||
| 18 | geometry := floatingGeometry{ | 37 | geometry := floatingGeometry{ | ||
| 19 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | 38 | Bounds: domain.Rect{X: 3, Y: 5, Width: 10, Height: 8}, | ||
| @@ -200,7 +219,7 @@ func TestResizeFloatingPaneCommitsSameSizeGeometryWithoutPTYResize(t *testing.T) | |||||
| 200 | p.rect, p.popupGeometry = initial.Inner, initial | 219 | p.rect, p.popupGeometry = initial.Inner, initial | ||
| 201 | d := newTestDaemon(t, nil, stubClock{}) | 220 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 202 | 221 | ||||
| 203 | - | require.True(t, d.resizeFloatingPane(p, tt.next)) | 222 | + | require.True(t, applyFloatingResizePlanForTest(d, p, tt.next)) |
| 204 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | 223 | require.Empty(t, pty.sizes(), "same-size geometry must not resize the PTY") | ||
| 205 | require.Equal(t, tt.next.Inner, p.rect) | 224 | require.Equal(t, tt.next.Inner, p.rect) | ||
| 206 | require.Equal(t, tt.next, p.popupGeometry) | 225 | require.Equal(t, tt.next, p.popupGeometry) | ||
| @@ -362,7 +381,7 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) | |||||
| 362 | d := newTestDaemon(t, nil, stubClock{}) | 381 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 363 | d.ApplyConfig(domain.Config{Floating: cfg}) | 382 | d.ApplyConfig(domain.Config{Floating: cfg}) | ||
| 364 | 383 | ||||
| 365 | - | require.False(t, d.resizeFloatingPane(p, newGeometry)) | 384 | + | require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry)) |
| 366 | tb.mu.Lock() | 385 | tb.mu.Lock() | ||
| 367 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | 386 | _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg) | ||
| 368 | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | 387 | copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y)) | ||
| @@ -388,7 +407,7 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||||
| 388 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | 407 | p.rect = domain.Rect{X: 8, Y: 3, Width: 5, Height: 4} | ||
| 389 | d := newTestDaemon(t, nil, stubClock{}) | 408 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 390 | requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}} | 409 | requested := floatingGeometry{Bounds: domain.Rect{X: 1, Y: 1, Width: 11, Height: 9}, Inner: domain.Rect{X: 2, Y: 2, Width: 9, Height: 7}} | ||
| 391 | - | require.False(t, d.resizeFloatingPane(p, requested)) | 410 | + | require.False(t, applyFloatingResizePlanForTest(d, p, requested)) |
| 392 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | 411 | require.Equal(t, domain.Rect{X: 8, Y: 3, Width: 5, Height: 4}, p.rect) | ||
| 393 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | 412 | require.Equal(t, floatingGeometry{}, p.popupGeometry) | ||
| 394 | require.Equal(t, 5, p.screen.Frame.Width) | 413 | require.Equal(t, 5, p.screen.Frame.Width) | ||
| @@ -404,11 +423,11 @@ func TestResizeFloatingPaneFailureAndSerialization(t *testing.T) { | |||||
| 404 | done1 := make(chan bool, 1) | 423 | done1 := make(chan bool, 1) | ||
| 405 | done2 := make(chan bool, 1) | 424 | done2 := make(chan bool, 1) | ||
| 406 | secondStarted := make(chan struct{}) | 425 | secondStarted := make(chan struct{}) | ||
| 407 | - | go func() { done1 <- d.resizeFloatingPane(p, first) }() | 426 | + | go func() { done1 <- applyFloatingResizePlanForTest(d, p, first) }() |
| 408 | <-pty.entered | 427 | <-pty.entered | ||
| 409 | go func() { | 428 | go func() { | ||
| 410 | close(secondStarted) | 429 | close(secondStarted) | ||
| 411 | - | done2 <- d.resizeFloatingPane(p, second) | 430 | + | done2 <- applyFloatingResizePlanForTest(d, p, second) |
| 412 | }() | 431 | }() | ||
| 413 | <-secondStarted | 432 | <-secondStarted | ||
| 414 | close(pty.release) | 433 | close(pty.release) | ||
M internal/usecase/daemon/nil_pty_test.go +1 -1
| @@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||
| 24 | 24 | sess.tabs = []*tab{first, second} | |
| 25 | 25 | ||
| 26 | 26 | require.NotPanics(t, func() { | |
| 27 | - | d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) | |
| 27 | + | d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) | |
| 28 | 28 | }) | |
| 29 | 29 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | |
| 30 | 30 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | |
| @@ -24,7 +24,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) { | |||||
| 24 | sess.tabs = []*tab{first, second} | 24 | sess.tabs = []*tab{first, second} | ||
| 25 | 25 | ||||
| 26 | require.NotPanics(t, func() { | 26 | require.NotPanics(t, func() { | ||
| 27 | - | d.resizePane(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) | 27 | + | d.applyPaneResize(first.focusedPane(), domain.Rect{Width: 20, Height: 5}) |
| 28 | }) | 28 | }) | ||
| 29 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | 29 | require.Equal(t, 20, first.focusedPane().screen.Frame.Width) | ||
| 30 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | 30 | require.Equal(t, 5, first.focusedPane().screen.Frame.Height) | ||
M internal/usecase/daemon/pane_actions.go +3 -3
| @@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||
| 111 | 111 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 112 | 112 | if !ok { | |
| 113 | 113 | for _, p := range tb.panes { | |
| 114 | - | d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 114 | + | d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | |
| 115 | 115 | } | |
| 116 | 116 | return | |
| 117 | 117 | } | |
| @@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||
| 120 | 120 | continue | |
| 121 | 121 | } | |
| 122 | 122 | if p := tb.panes[pl.ID]; p != nil { | |
| 123 | - | d.resizePane(p, pl.Content) | |
| 123 | + | d.applyPaneResize(p, pl.Content) | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| 126 | 126 | } | |
| 127 | 127 | ||
| 128 | - | func (d *Daemon) resizePane(p *pane, r domain.Rect) { | |
| 128 | + | func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) { | |
| 129 | 129 | if p == nil { | |
| 130 | 130 | return | |
| 131 | 131 | } | |
| @@ -111,7 +111,7 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||||
| 111 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 111 | placements, ok := layout.Solve(tb.tree.Root, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | ||
| 112 | if !ok { | 112 | if !ok { | ||
| 113 | for _, p := range tb.panes { | 113 | for _, p := range tb.panes { | ||
| 114 | - | d.resizePane(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) | 114 | + | d.applyPaneResize(p, domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}) |
| 115 | } | 115 | } | ||
| 116 | return | 116 | return | ||
| 117 | } | 117 | } | ||
| @@ -120,12 +120,12 @@ func (d *Daemon) applyLayoutLocked(tb *tab) { | |||||
| 120 | continue | 120 | continue | ||
| 121 | } | 121 | } | ||
| 122 | if p := tb.panes[pl.ID]; p != nil { | 122 | if p := tb.panes[pl.ID]; p != nil { | ||
| 123 | - | d.resizePane(p, pl.Content) | 123 | + | d.applyPaneResize(p, pl.Content) |
| 124 | } | 124 | } | ||
| 125 | } | 125 | } | ||
| 126 | } | 126 | } | ||
| 127 | 127 | ||||
| 128 | - | func (d *Daemon) resizePane(p *pane, r domain.Rect) { | 128 | + | func (d *Daemon) applyPaneResize(p *pane, r domain.Rect) { |
| 129 | if p == nil { | 129 | if p == nil { | ||
| 130 | return | 130 | return | ||
| 131 | } | 131 | } | ||
M internal/usecase/daemon/picker.go +0 -6
| @@ -410,12 +410,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||
| 410 | 410 | d.touchMRU(targetSess) | |
| 411 | 411 | ac.recordPreviousSession(from) | |
| 412 | 412 | d.mu.Unlock() | |
| 413 | - | if old != nil && old != ac { | |
| 414 | - | // Displacement is a detach lifecycle transition too. Cancel the | |
| 415 | - | // attachment-owned PR #71 timer only after releasing daemon/session | |
| 416 | - | // locks, preserving sendMu > daemon/session lock ordering. | |
| 417 | - | old.cancelResizePaint() | |
| 418 | - | } | |
| 419 | 413 | return old | |
| 420 | 414 | } | |
| 421 | 415 | ||
| @@ -410,12 +410,6 @@ func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetS | |||||
| 410 | d.touchMRU(targetSess) | 410 | d.touchMRU(targetSess) | ||
| 411 | ac.recordPreviousSession(from) | 411 | ac.recordPreviousSession(from) | ||
| 412 | d.mu.Unlock() | 412 | d.mu.Unlock() | ||
| 413 | - | if old != nil && old != ac { | |||
| 414 | - | // Displacement is a detach lifecycle transition too. Cancel the | |||
| 415 | - | // attachment-owned PR #71 timer only after releasing daemon/session | |||
| 416 | - | // locks, preserving sendMu > daemon/session lock ordering. | |||
| 417 | - | old.cancelResizePaint() | |||
| 418 | - | } | |||
| 419 | return old | 413 | return old | ||
| 420 | } | 414 | } | ||
| 421 | 415 | ||||
M internal/usecase/daemon/picker_test.go +4 -8
| @@ -281,16 +281,12 @@ func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) { | |||
| 281 | 281 | ||
| 282 | 282 | d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24}) | |
| 283 | 283 | timer := <-clock.timers | |
| 284 | - | ac2.sendMu.Lock() | |
| 285 | - | before := ac2.resizePaintGeneration | |
| 286 | - | require.True(t, ac2.resizePaintPending) | |
| 287 | - | ac2.sendMu.Unlock() | |
| 284 | + | before := sess2.renderCoordinator().resizeSnapshot().epoch | |
| 288 | 285 | ||
| 289 | 286 | require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})) | |
| 290 | - | ac2.sendMu.Lock() | |
| 291 | - | require.False(t, ac2.resizePaintPending) | |
| 292 | - | require.Equal(t, before+1, ac2.resizePaintGeneration) | |
| 293 | - | ac2.sendMu.Unlock() | |
| 287 | + | // The target coordinator invalidates its scheduled epoch during handoff; | |
| 288 | + | // no attachment-local timer survives the transfer. | |
| 289 | + | require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch) | |
| 294 | 290 | timer.ch <- time.Time{} | |
| 295 | 291 | } | |
| 296 | 292 | ||
| @@ -281,16 +281,12 @@ func TestPickerDisplacementCancelsOldResizePaint(t *testing.T) { | |||||
| 281 | 281 | ||||
| 282 | d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24}) | 282 | d.resize(sess2, ac2, domain.Size{Cols: 100, Rows: 24}) | ||
| 283 | timer := <-clock.timers | 283 | timer := <-clock.timers | ||
| 284 | - | ac2.sendMu.Lock() | 284 | + | before := sess2.renderCoordinator().resizeSnapshot().epoch |
| 285 | - | before := ac2.resizePaintGeneration | |||
| 286 | - | require.True(t, ac2.resizePaintPending) | |||
| 287 | - | ac2.sendMu.Unlock() | |||
| 288 | 285 | ||||
| 289 | require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})) | 286 | require.Same(t, ac2, d.stealClientForTarget(sess1, ac1, sess2, picker.Target{Session: sess2.id})) | ||
| 290 | - | ac2.sendMu.Lock() | 287 | + | // The target coordinator invalidates its scheduled epoch during handoff; |
| 291 | - | require.False(t, ac2.resizePaintPending) | 288 | + | // no attachment-local timer survives the transfer. |
| 292 | - | require.Equal(t, before+1, ac2.resizePaintGeneration) | 289 | + | require.Equal(t, before, sess2.renderCoordinator().resizeSnapshot().epoch) |
| 293 | - | ac2.sendMu.Unlock() | |||
| 294 | timer.ch <- time.Time{} | 290 | timer.ch <- time.Time{} | ||
| 295 | } | 291 | } | ||
| 296 | 292 | ||||
M internal/usecase/daemon/render.go +4 -45
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | 5 | "bytes" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/vev/internal/domain" | |
| 8 | - | "github.com/bnema/vev/internal/ports" | |
| 9 | 8 | "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" | |
| @@ -199,42 +198,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||
| 199 | 198 | // paint draws the composed client frame (active tab plus status bar) and | |
| 200 | 199 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | |
| 201 | 200 | // such as switch/create/close/rename/resize so the repaint is complete. | |
| 202 | - | func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) { | |
| 203 | - | ac.resizePaint.stop() | |
| 204 | - | ac.resizePaintGeneration++ | |
| 205 | - | generation := ac.resizePaintGeneration | |
| 206 | - | ac.resizePaintPending = true | |
| 207 | - | ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) { | |
| 208 | - | d.invalidateForResizeGeneration(sess, ac, generation) | |
| 209 | - | }) | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | // invalidateForResizeGeneration preserves PR #71's attachment-owned timer, | |
| 213 | - | // generation rejection, and cancellation while transferring only its eventual | |
| 214 | - | // render request to the coordinator. | |
| 215 | - | func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) { | |
| 216 | - | ac.sendMu.Lock() | |
| 217 | - | if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation { | |
| 218 | - | ac.sendMu.Unlock() | |
| 219 | - | return | |
| 201 | + | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...uint64) { | |
| 202 | + | attachmentEpoch := uint64(0) | |
| 203 | + | if len(epochs) != 0 { | |
| 204 | + | attachmentEpoch = epochs[0] | |
| 220 | 205 | } | |
| 221 | - | ac.resizePaint.stop() | |
| 222 | - | ac.resizePaintPending = false | |
| 223 | - | ac.sendMu.Unlock() | |
| 224 | - | d.invalidateRender(sess, ac, true, "render.go") | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |
| 228 | - | d.paintForResizeGeneration(sess, ac, reset, 0, 0) | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | // paintCoordinatorWake composes a coordinator wake only for its captured | |
| 232 | - | // attachment incarnation. The epoch is checked after sendMu is acquired. | |
| 233 | - | func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) { | |
| 234 | - | d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch) | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) { | |
| 238 | 206 | tb := sess.activeTab() | |
| 239 | 207 | if tb == nil { | |
| 240 | 208 | return | |
| @@ -258,19 +226,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 258 | 226 | ac.sendMu.Unlock() | |
| 259 | 227 | return | |
| 260 | 228 | } | |
| 261 | - | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | |
| 262 | - | ac.sendMu.Unlock() | |
| 263 | - | return | |
| 264 | - | } | |
| 265 | 229 | // Composition owns attachment sendMu; initialize its lazy overlay state | |
| 266 | 230 | // under that same ownership so concurrent fallback paints cannot observe a | |
| 267 | 231 | // partially published runtime. | |
| 268 | 232 | ac.initOverlays() | |
| 269 | - | if ac.resizePaintPending { | |
| 270 | - | ac.resizePaint.stop() | |
| 271 | - | ac.resizePaintPending = false | |
| 272 | - | reset = true | |
| 273 | - | } | |
| 274 | 233 | overlays := ac.overlays.SnapshotForRender() | |
| 275 | 234 | repaintAttachedClients := false | |
| 276 | 235 | defer func() { | |
| @@ -5,7 +5,6 @@ import ( | |||||
| 5 | "bytes" | 5 | "bytes" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | - | "github.com/bnema/vev/internal/ports" | |||
| 9 | "github.com/bnema/vev/internal/usecase/layout" | 8 | "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" | ||
| @@ -199,42 +198,11 @@ func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) { | |||||
| 199 | // paint draws the composed client frame (active tab plus status bar) and | 198 | // paint draws the composed client frame (active tab plus status bar) and | ||
| 200 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | 199 | // sends the resulting bytes. The renderer shadow is reset on explicit invalidations | ||
| 201 | // such as switch/create/close/rename/resize so the repaint is complete. | 200 | // such as switch/create/close/rename/resize so the repaint is complete. | ||
| 202 | - | func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) { | 201 | + | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, epochs ...uint64) { |
| 203 | - | ac.resizePaint.stop() | 202 | + | attachmentEpoch := uint64(0) |
| 204 | - | ac.resizePaintGeneration++ | 203 | + | if len(epochs) != 0 { |
| 205 | - | generation := ac.resizePaintGeneration | 204 | + | attachmentEpoch = epochs[0] |
| 206 | - | ac.resizePaintPending = true | |||
| 207 | - | ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) { | |||
| 208 | - | d.invalidateForResizeGeneration(sess, ac, generation) | |||
| 209 | - | }) | |||
| 210 | - | } | |||
| 211 | - | ||||
| 212 | - | // invalidateForResizeGeneration preserves PR #71's attachment-owned timer, | |||
| 213 | - | // generation rejection, and cancellation while transferring only its eventual | |||
| 214 | - | // render request to the coordinator. | |||
| 215 | - | func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) { | |||
| 216 | - | ac.sendMu.Lock() | |||
| 217 | - | if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation { | |||
| 218 | - | ac.sendMu.Unlock() | |||
| 219 | - | return | |||
| 220 | } | 205 | } | ||
| 221 | - | ac.resizePaint.stop() | |||
| 222 | - | ac.resizePaintPending = false | |||
| 223 | - | ac.sendMu.Unlock() | |||
| 224 | - | d.invalidateRender(sess, ac, true, "render.go") | |||
| 225 | - | } | |||
| 226 | - | ||||
| 227 | - | func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 228 | - | d.paintForResizeGeneration(sess, ac, reset, 0, 0) | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | // paintCoordinatorWake composes a coordinator wake only for its captured | |||
| 232 | - | // attachment incarnation. The epoch is checked after sendMu is acquired. | |||
| 233 | - | func (d *Daemon) paintCoordinatorWake(sess *session, w renderWake) { | |||
| 234 | - | d.paintForResizeGeneration(sess, w.attachment, w.reset, 0, w.attachmentEpoch) | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, reset bool, resizeGeneration, attachmentEpoch uint64) { | |||
| 238 | tb := sess.activeTab() | 206 | tb := sess.activeTab() | ||
| 239 | if tb == nil { | 207 | if tb == nil { | ||
| 240 | return | 208 | return | ||
| @@ -258,19 +226,10 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 258 | ac.sendMu.Unlock() | 226 | ac.sendMu.Unlock() | ||
| 259 | return | 227 | return | ||
| 260 | } | 228 | } | ||
| 261 | - | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | |||
| 262 | - | ac.sendMu.Unlock() | |||
| 263 | - | return | |||
| 264 | - | } | |||
| 265 | // Composition owns attachment sendMu; initialize its lazy overlay state | 229 | // Composition owns attachment sendMu; initialize its lazy overlay state | ||
| 266 | // under that same ownership so concurrent fallback paints cannot observe a | 230 | // under that same ownership so concurrent fallback paints cannot observe a | ||
| 267 | // partially published runtime. | 231 | // partially published runtime. | ||
| 268 | ac.initOverlays() | 232 | ac.initOverlays() | ||
| 269 | - | if ac.resizePaintPending { | |||
| 270 | - | ac.resizePaint.stop() | |||
| 271 | - | ac.resizePaintPending = false | |||
| 272 | - | reset = true | |||
| 273 | - | } | |||
| 274 | overlays := ac.overlays.SnapshotForRender() | 233 | overlays := ac.overlays.SnapshotForRender() | ||
| 275 | repaintAttachedClients := false | 234 | repaintAttachedClients := false | ||
| 276 | defer func() { | 235 | defer func() { | ||
M internal/usecase/daemon/render_coordinator.go +2 -2
| @@ -845,7 +845,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, | |||
| 845 | 845 | } | |
| 846 | 846 | } | |
| 847 | 847 | if ac != nil { | |
| 848 | - | d.paint(sess, ac, reset) | |
| 848 | + | d.paint(sess, ac, reset, 0) | |
| 849 | 849 | } | |
| 850 | 850 | } | |
| 851 | 851 | ||
| @@ -861,7 +861,7 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo | |||
| 861 | 861 | } | |
| 862 | 862 | } | |
| 863 | 863 | if ac != nil { | |
| 864 | - | d.paint(sess, ac, reset) | |
| 864 | + | d.paint(sess, ac, reset, 0) | |
| 865 | 865 | } | |
| 866 | 866 | } | |
| 867 | 867 | ||
| @@ -845,7 +845,7 @@ func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, | |||||
| 845 | } | 845 | } | ||
| 846 | } | 846 | } | ||
| 847 | if ac != nil { | 847 | if ac != nil { | ||
| 848 | - | d.paint(sess, ac, reset) | 848 | + | d.paint(sess, ac, reset, 0) |
| 849 | } | 849 | } | ||
| 850 | } | 850 | } | ||
| 851 | 851 | ||||
| @@ -861,7 +861,7 @@ func (d *Daemon) invalidateRenderNow(sess *session, ac *attachedClient, reset bo | |||||
| 861 | } | 861 | } | ||
| 862 | } | 862 | } | ||
| 863 | if ac != nil { | 863 | if ac != nil { | ||
| 864 | - | d.paint(sess, ac, reset) | 864 | + | d.paint(sess, ac, reset, 0) |
| 865 | } | 865 | } | ||
| 866 | } | 866 | } | ||
| 867 | 867 | ||||
M internal/usecase/daemon/render_coordinator_test.go +3 -14
| @@ -989,13 +989,8 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||
| 989 | 989 | require.Same(t, owner, snap.source) | |
| 990 | 990 | require.Equal(t, uint64(3), snap.epoch) | |
| 991 | 991 | ||
| 992 | - | // Metadata ownership is independent of the retained PR #71 | |
| 993 | - | // attachment dispatch state. | |
| 994 | - | owner.sendMu.Lock() | |
| 995 | - | generation, pending := owner.resizePaintGeneration, owner.resizePaintPending | |
| 996 | - | owner.sendMu.Unlock() | |
| 997 | - | require.Zero(t, generation, "recording metadata must not touch the PR #71 generation") | |
| 998 | - | require.False(t, pending, "recording metadata must not arm the PR #71 dispatch") | |
| 992 | + | // Resize state is exclusively coordinator-owned. | |
| 993 | + | require.Zero(t, snap.committed) | |
| 999 | 994 | }, | |
| 1000 | 995 | }, | |
| 1001 | 996 | { | |
| @@ -1478,12 +1473,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |||
| 1478 | 1473 | timer := awaitCoordinatorScheduledTimer(t, clk) | |
| 1479 | 1474 | require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline) | |
| 1480 | 1475 | require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline) | |
| 1481 | - | ac.sendMu.Lock() | |
| 1482 | - | generation, pending := ac.resizePaintGeneration, ac.resizePaintPending | |
| 1483 | - | ac.sendMu.Unlock() | |
| 1484 | - | require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment") | |
| 1485 | - | require.True(t, pending, "PR #71 pending dispatch must remain armed") | |
| 1486 | - | ||
| 1487 | 1476 | snap := sess.renderCoordinator().resizeSnapshot() | |
| 1488 | 1477 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size, | |
| 1489 | 1478 | "the coordinator must record the latest requested resize before delegating") | |
| @@ -1525,7 +1514,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |||
| 1525 | 1514 | ||
| 1526 | 1515 | d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30}) | |
| 1527 | 1516 | timer := awaitCoordinatorScheduledTimer(t, clk) | |
| 1528 | - | ac.cancelResizePaint() | |
| 1517 | + | sess.renderCoordinator().noteDetach(ac) | |
| 1529 | 1518 | ||
| 1530 | 1519 | timer.ch <- time.Time{} | |
| 1531 | 1520 | requireNoInvalidation(t, invs) | |
| @@ -989,13 +989,8 @@ func TestRenderCoordinatorResizeMetadata(t *testing.T) { | |||||
| 989 | require.Same(t, owner, snap.source) | 989 | require.Same(t, owner, snap.source) | ||
| 990 | require.Equal(t, uint64(3), snap.epoch) | 990 | require.Equal(t, uint64(3), snap.epoch) | ||
| 991 | 991 | ||||
| 992 | - | // Metadata ownership is independent of the retained PR #71 | 992 | + | // Resize state is exclusively coordinator-owned. |
| 993 | - | // attachment dispatch state. | 993 | + | require.Zero(t, snap.committed) |
| 994 | - | owner.sendMu.Lock() | |||
| 995 | - | generation, pending := owner.resizePaintGeneration, owner.resizePaintPending | |||
| 996 | - | owner.sendMu.Unlock() | |||
| 997 | - | require.Zero(t, generation, "recording metadata must not touch the PR #71 generation") | |||
| 998 | - | require.False(t, pending, "recording metadata must not arm the PR #71 dispatch") | |||
| 999 | }, | 994 | }, | ||
| 1000 | }, | 995 | }, | ||
| 1001 | { | 996 | { | ||
| @@ -1478,12 +1473,6 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |||||
| 1478 | timer := awaitCoordinatorScheduledTimer(t, clk) | 1473 | timer := awaitCoordinatorScheduledTimer(t, clk) | ||
| 1479 | require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline) | 1474 | require.GreaterOrEqual(t, timer.duration, minOutputRenderDeadline) | ||
| 1480 | require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline) | 1475 | require.LessOrEqual(t, timer.duration, maxOutputRenderDeadline) | ||
| 1481 | - | ac.sendMu.Lock() | |||
| 1482 | - | generation, pending := ac.resizePaintGeneration, ac.resizePaintPending | |||
| 1483 | - | ac.sendMu.Unlock() | |||
| 1484 | - | require.Equal(t, uint64(1), generation, "PR #71 generation ownership must stay with the attachment") | |||
| 1485 | - | require.True(t, pending, "PR #71 pending dispatch must remain armed") | |||
| 1486 | - | ||||
| 1487 | snap := sess.renderCoordinator().resizeSnapshot() | 1476 | snap := sess.renderCoordinator().resizeSnapshot() | ||
| 1488 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size, | 1477 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, snap.size, | ||
| 1489 | "the coordinator must record the latest requested resize before delegating") | 1478 | "the coordinator must record the latest requested resize before delegating") | ||
| @@ -1525,7 +1514,7 @@ func TestRenderCoordinatorRetainsPR71ResizeDispatch(t *testing.T) { | |||||
| 1525 | 1514 | ||||
| 1526 | d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30}) | 1515 | d.resize(sess, ac, domain.Size{Cols: 90, Rows: 30}) | ||
| 1527 | timer := awaitCoordinatorScheduledTimer(t, clk) | 1516 | timer := awaitCoordinatorScheduledTimer(t, clk) | ||
| 1528 | - | ac.cancelResizePaint() | 1517 | + | sess.renderCoordinator().noteDetach(ac) |
| 1529 | 1518 | ||||
| 1530 | timer.ch <- time.Time{} | 1519 | timer.ch <- time.Time{} | ||
| 1531 | requireNoInvalidation(t, invs) | 1520 | requireNoInvalidation(t, invs) | ||
M internal/usecase/daemon/resume.go +0 -4
| @@ -48,10 +48,6 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||
| 48 | 48 | if !d.prepareParkAttachment(sess, ac) { | |
| 49 | 49 | return false | |
| 50 | 50 | } | |
| 51 | - | // A parked attachment has no valid visible destination. Cancel its | |
| 52 | - | // resize-idle callback before registering the grace timer; resume performs | |
| 53 | - | // a fresh full paint. | |
| 54 | - | ac.cancelResizePaint() | |
| 55 | 51 | d.mu.Lock() | |
| 56 | 52 | if d.closing { | |
| 57 | 53 | d.mu.Unlock() | |
| @@ -48,10 +48,6 @@ func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||||
| 48 | if !d.prepareParkAttachment(sess, ac) { | 48 | if !d.prepareParkAttachment(sess, ac) { | ||
| 49 | return false | 49 | return false | ||
| 50 | } | 50 | } | ||
| 51 | - | // A parked attachment has no valid visible destination. Cancel its | |||
| 52 | - | // resize-idle callback before registering the grace timer; resume performs | |||
| 53 | - | // a fresh full paint. | |||
| 54 | - | ac.cancelResizePaint() | |||
| 55 | d.mu.Lock() | 51 | d.mu.Lock() | ||
| 56 | if d.closing { | 52 | if d.closing { | ||
| 57 | d.mu.Unlock() | 53 | d.mu.Unlock() | ||
M internal/usecase/daemon/session.go +0 -1
| @@ -724,7 +724,6 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 724 | 724 | rc.noteSessionTeardown() | |
| 725 | 725 | } | |
| 726 | 726 | if ac != nil { | |
| 727 | - | ac.cancelResizePaint() | |
| 728 | 727 | d.unregisterPreview(ac) | |
| 729 | 728 | ac.clearPreviousSession() | |
| 730 | 729 | ac.setSession(nil) | |
| @@ -724,7 +724,6 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 724 | rc.noteSessionTeardown() | 724 | rc.noteSessionTeardown() | ||
| 725 | } | 725 | } | ||
| 726 | if ac != nil { | 726 | if ac != nil { | ||
| 727 | - | ac.cancelResizePaint() | |||
| 728 | d.unregisterPreview(ac) | 727 | d.unregisterPreview(ac) | ||
| 729 | ac.clearPreviousSession() | 728 | ac.clearPreviousSession() | ||
| 730 | ac.setSession(nil) | 729 | ac.setSession(nil) | ||
M internal/usecase/daemon/transactional_resize.go +7 -0
| @@ -90,6 +90,13 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan | |||
| 90 | 90 | tb.mu.Unlock() | |
| 91 | 91 | } | |
| 92 | 92 | for _, m := range plan.members { | |
| 93 | + | // A failed floating resize must retain the committed popup geometry: it | |
| 94 | + | // controls both its frame and mouse/copy hit testing. Normal layout still | |
| 95 | + | // publishes its solved rectangle, while retaining its old screen until a | |
| 96 | + | // successful PTY resize. | |
| 97 | + | if m.isFloating && !m.ok { | |
| 98 | + | continue | |
| 99 | + | } | |
| 93 | 100 | m.pane.mu.Lock() | |
| 94 | 101 | m.pane.rect = m.rect | |
| 95 | 102 | if m.isFloating { | |
| @@ -90,6 +90,13 @@ func (d *Daemon) commitResize(sess *session, ac *attachedClient, plan resizePlan | |||||
| 90 | tb.mu.Unlock() | 90 | tb.mu.Unlock() | ||
| 91 | } | 91 | } | ||
| 92 | for _, m := range plan.members { | 92 | for _, m := range plan.members { | ||
| 93 | + | // A failed floating resize must retain the committed popup geometry: it | |||
| 94 | + | // controls both its frame and mouse/copy hit testing. Normal layout still | |||
| 95 | + | // publishes its solved rectangle, while retaining its old screen until a | |||
| 96 | + | // successful PTY resize. | |||
| 97 | + | if m.isFloating && !m.ok { | |||
| 98 | + | continue | |||
| 99 | + | } | |||
| 93 | m.pane.mu.Lock() | 100 | m.pane.mu.Lock() | ||
| 94 | m.pane.rect = m.rect | 101 | m.pane.rect = m.rect | ||
| 95 | if m.isFloating { | 102 | if m.isFloating { | ||