detached df0fe5f

refactor(daemon): route producer rendering through coordinator

commit
df0fe5f85aadfd08e9f6fbb705875d22ef7e34b6
author
brice · · 2026-07-11 15:08 UTC
parent
4eb20a0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +298 -84
M internal/usecase/daemon/attention.go +2 -2
@@ -49,7 +49,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
4949 if idx, ok := oldestAttentionTab(sess); ok {
5050 if sess.switchTab(idx) {
5151 d.activateTab(sess, sess.activeTab())
52- d.paint(sess, ac, true)
52+ d.invalidateRender(sess, ac, true, "attention.go")
5353 }
5454 return
5555 }
@@ -119,7 +119,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
119119 ac := sess.client
120120 sess.mu.Unlock()
121121 if ac != nil {
122- d.paint(sess, ac, false)
122+ d.invalidateRender(sess, ac, false, "attention.go")
123123 }
124124 }
125125
@@ -49,7 +49,7 @@ func (d *Daemon) jumpAttention(sess *session, ac *attachedClient) {
49 if idx, ok := oldestAttentionTab(sess); ok {49 if idx, ok := oldestAttentionTab(sess); ok {
50 if sess.switchTab(idx) {50 if sess.switchTab(idx) {
51 d.activateTab(sess, sess.activeTab())51 d.activateTab(sess, sess.activeTab())
52- d.paint(sess, ac, true)52+ d.invalidateRender(sess, ac, true, "attention.go")
53 }53 }
54 return54 return
55 }55 }
@@ -119,7 +119,7 @@ func (d *Daemon) repaintAttachedClients(sess *session) {
119 ac := sess.client119 ac := sess.client
120 sess.mu.Unlock()120 sess.mu.Unlock()
121 if ac != nil {121 if ac != nil {
122- d.paint(sess, ac, false)122+ d.invalidateRender(sess, ac, false, "attention.go")
123 }123 }
124 }124 }
125 125
M internal/usecase/daemon/client.go +26 -3
@@ -396,6 +396,22 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
396396 sess.client = ac
397397 name := sess.name
398398 sess.mu.Unlock()
399+ // A session has exactly one coordinator; replacement changes only its
400+ // attachment identity and the private transitional compositor target.
401+ rc := sess.renderCoordinator()
402+ if rc == nil {
403+ rc = newRenderCoordinator(renderCoordinatorOptions{
404+ clock: d.clock,
405+ wake: func(w renderWake) { d.paint(sess, ac, w.reset) },
406+ ackReady: func() bool { return true },
407+ syncActive: func() bool { return false },
408+ })
409+ sess.installRenderCoordinator(rc)
410+ }
411+ if old != nil {
412+ rc.noteReplace(old, ac)
413+ }
414+ rc.attach(ac)
399415 d.touchMRU(sess)
400416 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
401417 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
@@ -449,7 +465,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
449465 d.resizeForFirstPaint(sess, ac, clientSize)
450466 }
451467 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
452- d.paint(sess, ac, true)
468+ d.invalidateRender(sess, ac, true, "client.go")
453469 d.activateTab(sess, tb)
454470 }
455471
@@ -504,7 +520,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
504520 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
505521 ac.ackOutputState(ack.AckedStateNum)
506522 if reset, ok := ac.takeDeferredPaint(); ok {
507- d.paint(sess, ac, reset)
523+ d.invalidateRender(sess, ac, reset, "client.go")
508524 }
509525 }
510526 case ports.MsgPing:
@@ -526,6 +542,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
526542 return // already displaced by a newer client; nothing to do
527543 }
528544 ac.cancelResizePaint()
545+ if rc := sess.renderCoordinator(); rc != nil {
546+ rc.noteDetach(ac)
547+ }
529548 d.unregisterPreview(ac)
530549 sess.mu.Lock()
531550 ephemeral := sess.ephemeral
@@ -576,7 +595,7 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
576595 if !d.applyHostTheme(sess, ac, t, false) {
577596 return
578597 }
579- d.paint(sess, ac, true)
598+ d.invalidateRender(sess, ac, true, "client.go")
580599 }
581600
582601 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
@@ -603,6 +622,10 @@ func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Si
603622 return
604623 }
605624 ac.size = sz
625+ if rc := sess.renderCoordinator(); rc != nil {
626+ rc.attach(ac)
627+ rc.recordResizeRequest(sz, ac)
628+ }
606629 }
607630 tbSize := tabSize(sz)
608631
@@ -396,6 +396,22 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
396 sess.client = ac396 sess.client = ac
397 name := sess.name397 name := sess.name
398 sess.mu.Unlock()398 sess.mu.Unlock()
399+ // A session has exactly one coordinator; replacement changes only its
400+ // attachment identity and the private transitional compositor target.
401+ rc := sess.renderCoordinator()
402+ if rc == nil {
403+ rc = newRenderCoordinator(renderCoordinatorOptions{
404+ clock: d.clock,
405+ wake: func(w renderWake) { d.paint(sess, ac, w.reset) },
406+ ackReady: func() bool { return true },
407+ syncActive: func() bool { return false },
408+ })
409+ sess.installRenderCoordinator(rc)
410+ }
411+ if old != nil {
412+ rc.noteReplace(old, ac)
413+ }
414+ rc.attach(ac)
399 d.touchMRU(sess)415 d.touchMRU(sess)
400 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)416 d.log.Info("client attached", "session", name, "resume", opts.resumeCapable)
401 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)417 d.applyHostTheme(sess, ac, d.effectiveTheme(themeui.Theme{}), true)
@@ -449,7 +465,7 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
449 d.resizeForFirstPaint(sess, ac, clientSize)465 d.resizeForFirstPaint(sess, ac, clientSize)
450 }466 }
451 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)467 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
452- d.paint(sess, ac, true)468+ d.invalidateRender(sess, ac, true, "client.go")
453 d.activateTab(sess, tb)469 d.activateTab(sess, tb)
454 }470 }
455 471
@@ -504,7 +520,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
504 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {520 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
505 ac.ackOutputState(ack.AckedStateNum)521 ac.ackOutputState(ack.AckedStateNum)
506 if reset, ok := ac.takeDeferredPaint(); ok {522 if reset, ok := ac.takeDeferredPaint(); ok {
507- d.paint(sess, ac, reset)523+ d.invalidateRender(sess, ac, reset, "client.go")
508 }524 }
509 }525 }
510 case ports.MsgPing:526 case ports.MsgPing:
@@ -526,6 +542,9 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Tran
526 return // already displaced by a newer client; nothing to do542 return // already displaced by a newer client; nothing to do
527 }543 }
528 ac.cancelResizePaint()544 ac.cancelResizePaint()
545+ if rc := sess.renderCoordinator(); rc != nil {
546+ rc.noteDetach(ac)
547+ }
529 d.unregisterPreview(ac)548 d.unregisterPreview(ac)
530 sess.mu.Lock()549 sess.mu.Lock()
531 ephemeral := sess.ephemeral550 ephemeral := sess.ephemeral
@@ -576,7 +595,7 @@ func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme)
576 if !d.applyHostTheme(sess, ac, t, false) {595 if !d.applyHostTheme(sess, ac, t, false) {
577 return596 return
578 }597 }
579- d.paint(sess, ac, true)598+ d.invalidateRender(sess, ac, true, "client.go")
580 }599 }
581 600
582 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {601 func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
@@ -603,6 +622,10 @@ func (d *Daemon) resizeWithPaint(sess *session, ac *attachedClient, sz domain.Si
603 return622 return
604 }623 }
605 ac.size = sz624 ac.size = sz
625+ if rc := sess.renderCoordinator(); rc != nil {
626+ rc.attach(ac)
627+ rc.recordResizeRequest(sz, ac)
628+ }
606 }629 }
607 tbSize := tabSize(sz)630 tbSize := tabSize(sz)
608 631
M internal/usecase/daemon/copymode.go +8 -8
@@ -62,7 +62,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
6262 if delta > 0 && rt.copyMode.AtBottom(*document) {
6363 rt.clearCopyModeLocked()
6464 rt.copyMu.Unlock()
65- d.paint(sess, ac, true)
65+ d.invalidateRender(sess, ac, true, "copymode.go")
6666 return
6767 }
6868 rt.copyMode.Move(*document, delta)
@@ -72,7 +72,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
7272 }
7373 rt.copyMu.Unlock()
7474
75- d.paint(sess, ac, true)
75+ d.invalidateRender(sess, ac, true, "copymode.go")
7676 }
7777
7878 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
@@ -94,7 +94,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
9494 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
9595 return
9696 }
97- d.paint(sess, ac, true)
97+ d.invalidateRender(sess, ac, true, "copymode.go")
9898 }
9999
100100 // publishCopyMode installs a non-renderable candidate, validates its captured
@@ -190,7 +190,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
190190 rt.copyMu.Unlock()
191191
192192 if changed {
193- d.paint(sess, ac, true)
193+ d.invalidateRender(sess, ac, true, "copymode.go")
194194 }
195195 }
196196
@@ -220,7 +220,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
220220 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
221221 rt.copyMu.Unlock()
222222 if changed || closeSearch || accepted {
223- d.paint(sess, ac, true)
223+ d.invalidateRender(sess, ac, true, "copymode.go")
224224 }
225225 return
226226 }
@@ -309,11 +309,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
309309 rt.copyMu.Unlock()
310310 }
311311 if exit {
312- d.paint(sess, ac, true)
312+ d.invalidateRender(sess, ac, true, "copymode.go")
313313 return
314314 }
315315 if changed {
316- d.paint(sess, ac, true)
316+ d.invalidateRender(sess, ac, true, "copymode.go")
317317 }
318318 }
319319
@@ -386,7 +386,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
386386 rt.copyMu.Unlock()
387387
388388 if sess := ac.currentSession(); sess != nil {
389- d.paint(sess, ac, true)
389+ d.invalidateRender(sess, ac, true, "copymode.go")
390390 }
391391 })
392392 }
@@ -62,7 +62,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
62 if delta > 0 && rt.copyMode.AtBottom(*document) {62 if delta > 0 && rt.copyMode.AtBottom(*document) {
63 rt.clearCopyModeLocked()63 rt.clearCopyModeLocked()
64 rt.copyMu.Unlock()64 rt.copyMu.Unlock()
65- d.paint(sess, ac, true)65+ d.invalidateRender(sess, ac, true, "copymode.go")
66 return66 return
67 }67 }
68 rt.copyMode.Move(*document, delta)68 rt.copyMode.Move(*document, delta)
@@ -72,7 +72,7 @@ func (d *Daemon) copyWheel(sess *session, ac *attachedClient, delta int) {
72 }72 }
73 rt.copyMu.Unlock()73 rt.copyMu.Unlock()
74 74
75- d.paint(sess, ac, true)75+ d.invalidateRender(sess, ac, true, "copymode.go")
76 }76 }
77 77
78 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {78 func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
@@ -94,7 +94,7 @@ func (d *Daemon) enterCopyMode(sess *session, ac *attachedClient) {
94 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {94 if !d.publishCopyMode(sess, ac, tb, p, document, nil) {
95 return95 return
96 }96 }
97- d.paint(sess, ac, true)97+ d.invalidateRender(sess, ac, true, "copymode.go")
98 }98 }
99 99
100 // publishCopyMode installs a non-renderable candidate, validates its captured100 // publishCopyMode installs a non-renderable candidate, validates its captured
@@ -190,7 +190,7 @@ func (d *Daemon) copyMouse(sess *session, ac *attachedClient, ev mouse.Event) {
190 rt.copyMu.Unlock()190 rt.copyMu.Unlock()
191 191
192 if changed {192 if changed {
193- d.paint(sess, ac, true)193+ d.invalidateRender(sess, ac, true, "copymode.go")
194 }194 }
195 }195 }
196 196
@@ -220,7 +220,7 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
220 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)220 changed, closeSearch, accepted := d.routeCopySearchInputLocked(rt, *document, data)
221 rt.copyMu.Unlock()221 rt.copyMu.Unlock()
222 if changed || closeSearch || accepted {222 if changed || closeSearch || accepted {
223- d.paint(sess, ac, true)223+ d.invalidateRender(sess, ac, true, "copymode.go")
224 }224 }
225 return225 return
226 }226 }
@@ -309,11 +309,11 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
309 rt.copyMu.Unlock()309 rt.copyMu.Unlock()
310 }310 }
311 if exit {311 if exit {
312- d.paint(sess, ac, true)312+ d.invalidateRender(sess, ac, true, "copymode.go")
313 return313 return
314 }314 }
315 if changed {315 if changed {
316- d.paint(sess, ac, true)316+ d.invalidateRender(sess, ac, true, "copymode.go")
317 }317 }
318 }318 }
319 319
@@ -386,7 +386,7 @@ func (d *Daemon) retainCopyESCLocked(ac *attachedClient) {
386 rt.copyMu.Unlock()386 rt.copyMu.Unlock()
387 387
388 if sess := ac.currentSession(); sess != nil {388 if sess := ac.currentSession(); sess != nil {
389- d.paint(sess, ac, true)389+ d.invalidateRender(sess, ac, true, "copymode.go")
390 }390 }
391 })391 })
392 }392 }
M internal/usecase/daemon/floating.go +4 -4
@@ -192,7 +192,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
192192 if start {
193193 d.launchFloating(sess, tb, cfg, generation, true)
194194 if ac != nil {
195- d.paint(sess, ac, true)
195+ d.invalidateRender(sess, ac, true, "floating.go")
196196 }
197197 return nil
198198 }
@@ -200,7 +200,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
200200 d.resizeActiveFloating(tb)
201201 }
202202 if ac != nil {
203- d.paint(sess, ac, true)
203+ d.invalidateRender(sess, ac, true, "floating.go")
204204 }
205205 return nil
206206 }
@@ -348,7 +348,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
348348 ac := sess.client
349349 sess.mu.Unlock()
350350 if ac != nil {
351- d.paint(sess, ac, true)
351+ d.invalidateRender(sess, ac, true, "floating.go")
352352 }
353353 }
354354 }
@@ -369,7 +369,7 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
369369 sess.mu.Unlock()
370370 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
371371 if ac != nil && (visible || copyCleared) {
372- d.paint(sess, ac, true)
372+ d.invalidateRender(sess, ac, true, "floating.go")
373373 }
374374 }
375375
@@ -192,7 +192,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
192 if start {192 if start {
193 d.launchFloating(sess, tb, cfg, generation, true)193 d.launchFloating(sess, tb, cfg, generation, true)
194 if ac != nil {194 if ac != nil {
195- d.paint(sess, ac, true)195+ d.invalidateRender(sess, ac, true, "floating.go")
196 }196 }
197 return nil197 return nil
198 }198 }
@@ -200,7 +200,7 @@ func (d *Daemon) toggleFloating(sess *session, ac *attachedClient) error {
200 d.resizeActiveFloating(tb)200 d.resizeActiveFloating(tb)
201 }201 }
202 if ac != nil {202 if ac != nil {
203- d.paint(sess, ac, true)203+ d.invalidateRender(sess, ac, true, "floating.go")
204 }204 }
205 return nil205 return nil
206 }206 }
@@ -348,7 +348,7 @@ func (d *Daemon) installFloating(sess *session, tb *tab, p *pane, generation uin
348 ac := sess.client348 ac := sess.client
349 sess.mu.Unlock()349 sess.mu.Unlock()
350 if ac != nil {350 if ac != nil {
351- d.paint(sess, ac, true)351+ d.invalidateRender(sess, ac, true, "floating.go")
352 }352 }
353 }353 }
354 }354 }
@@ -369,7 +369,7 @@ func (d *Daemon) reapFloating(sess *session, tb *tab, p *pane, generation uint64
369 sess.mu.Unlock()369 sess.mu.Unlock()
370 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)370 copyCleared := ac != nil && ac.overlays.clearCopyModeForPane(p)
371 if ac != nil && (visible || copyCleared) {371 if ac != nil && (visible || copyCleared) {
372- d.paint(sess, ac, true)372+ d.invalidateRender(sess, ac, true, "floating.go")
373 }373 }
374 }374 }
375 375
M internal/usecase/daemon/input.go +5 -5
@@ -118,7 +118,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
118118 if target.ID != oldFocus {
119119 d.refreshPaneTitleOnFocus(sess, target.ID)
120120 }
121- d.paint(sess, ac, true)
121+ d.invalidateRender(sess, ac, true, "input.go")
122122 return
123123 }
124124 }
@@ -174,7 +174,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
174174 d.exitCopyMode(ac)
175175 d.refreshPaneTitleOnFocus(sess, pl.ID)
176176 }
177- d.paint(sess, ac, true)
177+ d.invalidateRender(sess, ac, true, "input.go")
178178 return
179179 }
180180 var p *pane
@@ -195,7 +195,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
195195 if isMouseFocusPress(ev) && pl.ID != oldFocus {
196196 d.exitCopyMode(ac)
197197 d.refreshPaneTitleOnFocus(sess, pl.ID)
198- d.paint(sess, ac, true)
198+ d.invalidateRender(sess, ac, true, "input.go")
199199 }
200200 if multi {
201201 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
@@ -290,7 +290,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
290290 rt.copyDragging = true
291291 }
292292 rt.copyMu.Unlock()
293- d.paint(sess, ac, true)
293+ d.invalidateRender(sess, ac, true, "input.go")
294294 case mouse.Release:
295295 rt.copyMu.Lock()
296296 rt.normalMousePressValid = false
@@ -432,7 +432,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
432432 idx := int(action - keys.ActionSwitchTab1)
433433 if sess.switchTab(idx) {
434434 h.d.activateTab(sess, sess.activeTab())
435- h.d.paint(sess, h.ac, true)
435+ h.d.invalidateRender(sess, h.ac, true, "input.go")
436436 }
437437 }
438438 }
@@ -118,7 +118,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
118 if target.ID != oldFocus {118 if target.ID != oldFocus {
119 d.refreshPaneTitleOnFocus(sess, target.ID)119 d.refreshPaneTitleOnFocus(sess, target.ID)
120 }120 }
121- d.paint(sess, ac, true)121+ d.invalidateRender(sess, ac, true, "input.go")
122 return122 return
123 }123 }
124 }124 }
@@ -174,7 +174,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
174 d.exitCopyMode(ac)174 d.exitCopyMode(ac)
175 d.refreshPaneTitleOnFocus(sess, pl.ID)175 d.refreshPaneTitleOnFocus(sess, pl.ID)
176 }176 }
177- d.paint(sess, ac, true)177+ d.invalidateRender(sess, ac, true, "input.go")
178 return178 return
179 }179 }
180 var p *pane180 var p *pane
@@ -195,7 +195,7 @@ func (d *Daemon) handleMouse(ac *attachedClient, ev mouse.Event) {
195 if isMouseFocusPress(ev) && pl.ID != oldFocus {195 if isMouseFocusPress(ev) && pl.ID != oldFocus {
196 d.exitCopyMode(ac)196 d.exitCopyMode(ac)
197 d.refreshPaneTitleOnFocus(sess, pl.ID)197 d.refreshPaneTitleOnFocus(sess, pl.ID)
198- d.paint(sess, ac, true)198+ d.invalidateRender(sess, ac, true, "input.go")
199 }199 }
200 if multi {200 if multi {
201 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)201 ev = translateMouseEvent(ev, pl.Content.X, pl.Content.Y)
@@ -290,7 +290,7 @@ func (d *Daemon) handleTerminalMouse(sess *session, ac *attachedClient, p *pane,
290 rt.copyDragging = true290 rt.copyDragging = true
291 }291 }
292 rt.copyMu.Unlock()292 rt.copyMu.Unlock()
293- d.paint(sess, ac, true)293+ d.invalidateRender(sess, ac, true, "input.go")
294 case mouse.Release:294 case mouse.Release:
295 rt.copyMu.Lock()295 rt.copyMu.Lock()
296 rt.normalMousePressValid = false296 rt.normalMousePressValid = false
@@ -432,7 +432,7 @@ func (h daemonKeyHandler) Action(action keys.Action) {
432 idx := int(action - keys.ActionSwitchTab1)432 idx := int(action - keys.ActionSwitchTab1)
433 if sess.switchTab(idx) {433 if sess.switchTab(idx) {
434 h.d.activateTab(sess, sess.activeTab())434 h.d.activateTab(sess, sess.activeTab())
435- h.d.paint(sess, h.ac, true)435+ h.d.invalidateRender(sess, h.ac, true, "input.go")
436 }436 }
437 }437 }
438 }438 }
M internal/usecase/daemon/palette.go +9 -9
@@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
4242 ac.overlays.paletteHints = palette.ContextualHints{}
4343 ac.overlays.palettePending = nil
4444 ac.overlays.paletteMu.Unlock()
45- d.paint(sess, ac, true)
45+ d.invalidateRender(sess, ac, true, "palette.go")
4646 }
4747
4848 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
@@ -167,12 +167,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
167167 }
168168 ac.overlays.paletteMu.Unlock()
169169 if cancel {
170- d.paint(sess, ac, true)
170+ d.invalidateRender(sess, ac, true, "palette.go")
171171 return
172172 }
173173 if !execute {
174174 if changed {
175- d.paint(sess, ac, true)
175+ d.invalidateRender(sess, ac, true, "palette.go")
176176 }
177177 return
178178 }
@@ -183,7 +183,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
183183 rank, err := command.ParsePositiveDecimal(args)
184184 if err != nil {
185185 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
186- d.paint(sess, ac, true)
186+ d.invalidateRender(sess, ac, true, "palette.go")
187187 return
188188 }
189189 // The captured ID is handed off atomically. A target can disappear after
@@ -191,14 +191,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
191191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
192192 if err := exec.JumpRecentSession(rank); err != nil {
193193 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
194- d.paint(sess, ac, true)
194+ d.invalidateRender(sess, ac, true, "palette.go")
195195 return
196196 }
197197 // Publication remains generation-safe; do not let stale work close a
198198 // newer palette interaction.
199199 if ac.closeExecutedPalette(generation, rawQuery) {
200200 d.recordPaletteUse(cmd.Code)
201- d.paint(ac.currentSession(), ac, true)
201+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
202202 }
203203 return
204204 }
@@ -259,7 +259,7 @@ type paletteExec struct {
259259 }
260260
261261 func (e paletteExec) CreateTab() error {
262- defer e.d.paint(e.sess, e.ac, true)
262+ defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
263263 return e.d.createTab(e.sess, e.ac.size)
264264 }
265265
@@ -325,7 +325,7 @@ func (e paletteExec) NextTab() error {
325325 if e.sess.switchRelative(1) {
326326 e.d.activateTab(e.sess, e.sess.activeTab())
327327 }
328- e.d.paint(e.sess, e.ac, true)
328+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
329329 return nil
330330 }
331331
@@ -333,7 +333,7 @@ func (e paletteExec) PrevTab() error {
333333 if e.sess.switchRelative(-1) {
334334 e.d.activateTab(e.sess, e.sess.activeTab())
335335 }
336- e.d.paint(e.sess, e.ac, true)
336+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
337337 return nil
338338 }
339339
@@ -42,7 +42,7 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
42 ac.overlays.paletteHints = palette.ContextualHints{}42 ac.overlays.paletteHints = palette.ContextualHints{}
43 ac.overlays.palettePending = nil43 ac.overlays.palettePending = nil
44 ac.overlays.paletteMu.Unlock()44 ac.overlays.paletteMu.Unlock()
45- d.paint(sess, ac, true)45+ d.invalidateRender(sess, ac, true, "palette.go")
46 }46 }
47 47
48 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {48 func recentSessionHints(recent []recentSession, args []string) palette.ContextualHints {
@@ -167,12 +167,12 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
167 }167 }
168 ac.overlays.paletteMu.Unlock()168 ac.overlays.paletteMu.Unlock()
169 if cancel {169 if cancel {
170- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "palette.go")
171 return171 return
172 }172 }
173 if !execute {173 if !execute {
174 if changed {174 if changed {
175- d.paint(sess, ac, true)175+ d.invalidateRender(sess, ac, true, "palette.go")
176 }176 }
177 return177 return
178 }178 }
@@ -183,7 +183,7 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
183 rank, err := command.ParsePositiveDecimal(args)183 rank, err := command.ParsePositiveDecimal(args)
184 if err != nil {184 if err != nil {
185 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")185 ac.paletteFailure(generation, rawQuery, "rank must be one positive decimal")
186- d.paint(sess, ac, true)186+ d.invalidateRender(sess, ac, true, "palette.go")
187 return187 return
188 }188 }
189 // The captured ID is handed off atomically. A target can disappear after189 // The captured ID is handed off atomically. A target can disappear after
@@ -191,14 +191,14 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte) {
191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}191 exec := paletteExec{d: d, sess: sess, ac: ac, recent: recent}
192 if err := exec.JumpRecentSession(rank); err != nil {192 if err := exec.JumpRecentSession(rank); err != nil {
193 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")193 ac.paletteFailure(generation, rawQuery, "requested recent session is unavailable")
194- d.paint(sess, ac, true)194+ d.invalidateRender(sess, ac, true, "palette.go")
195 return195 return
196 }196 }
197 // Publication remains generation-safe; do not let stale work close a197 // Publication remains generation-safe; do not let stale work close a
198 // newer palette interaction.198 // newer palette interaction.
199 if ac.closeExecutedPalette(generation, rawQuery) {199 if ac.closeExecutedPalette(generation, rawQuery) {
200 d.recordPaletteUse(cmd.Code)200 d.recordPaletteUse(cmd.Code)
201- d.paint(ac.currentSession(), ac, true)201+ d.invalidateRender(ac.currentSession(), ac, true, "palette.go")
202 }202 }
203 return203 return
204 }204 }
@@ -259,7 +259,7 @@ type paletteExec struct {
259 }259 }
260 260
261 func (e paletteExec) CreateTab() error {261 func (e paletteExec) CreateTab() error {
262- defer e.d.paint(e.sess, e.ac, true)262+ defer e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
263 return e.d.createTab(e.sess, e.ac.size)263 return e.d.createTab(e.sess, e.ac.size)
264 }264 }
265 265
@@ -325,7 +325,7 @@ func (e paletteExec) NextTab() error {
325 if e.sess.switchRelative(1) {325 if e.sess.switchRelative(1) {
326 e.d.activateTab(e.sess, e.sess.activeTab())326 e.d.activateTab(e.sess, e.sess.activeTab())
327 }327 }
328- e.d.paint(e.sess, e.ac, true)328+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
329 return nil329 return nil
330 }330 }
331 331
@@ -333,7 +333,7 @@ func (e paletteExec) PrevTab() error {
333 if e.sess.switchRelative(-1) {333 if e.sess.switchRelative(-1) {
334 e.d.activateTab(e.sess, e.sess.activeTab())334 e.d.activateTab(e.sess, e.sess.activeTab())
335 }335 }
336- e.d.paint(e.sess, e.ac, true)336+ e.d.invalidateRender(e.sess, e.ac, true, "palette.go")
337 return nil337 return nil
338 }338 }
339 339
M internal/usecase/daemon/pane_actions.go +7 -7
@@ -23,7 +23,7 @@ func (d *Daemon) spawnPaneOp(
2323 ) error {
2424 if d.ptys == nil {
2525 if ac != nil {
26- d.paint(sess, ac, true)
26+ d.invalidateRender(sess, ac, true, "pane_actions.go")
2727 }
2828 return nil
2929 }
@@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOp(
9999 d.startPaneGoroutines(sess, tb, p)
100100 markSnapshotDirty(sess)
101101 if ac != nil {
102- d.paint(sess, ac, true)
102+ d.invalidateRender(sess, ac, true, "pane_actions.go")
103103 }
104104 return nil
105105 }
@@ -167,7 +167,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167167 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
168168 if d.ptys == nil {
169169 if ac != nil {
170- d.paint(sess, ac, true)
170+ d.invalidateRender(sess, ac, true, "pane_actions.go")
171171 }
172172 return nil
173173 }
@@ -188,7 +188,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
188188 if err == nil {
189189 markSnapshotDirty(sess)
190190 if ac != nil {
191- d.paint(sess, ac, true)
191+ d.invalidateRender(sess, ac, true, "pane_actions.go")
192192 }
193193 }
194194 return err
@@ -197,7 +197,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
197197 func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
198198 if d.ptys == nil {
199199 if ac != nil {
200- d.paint(sess, ac, true)
200+ d.invalidateRender(sess, ac, true, "pane_actions.go")
201201 }
202202 return nil
203203 }
@@ -267,7 +267,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
267267 markSnapshotDirty(sess)
268268 if repaint {
269269 if ac != nil {
270- d.paint(sess, ac, true)
270+ d.invalidateRender(sess, ac, true, "pane_actions.go")
271271 }
272272 }
273273 return nil
@@ -305,7 +305,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
305305 d.refreshPaneTitleOnFocus(sess, newFocus)
306306 }
307307 }
308- d.paint(sess, ac, true)
308+ d.invalidateRender(sess, ac, true, "pane_actions.go")
309309 }
310310 if errors.Is(err, layout.ErrNoPane) {
311311 return nil
@@ -23,7 +23,7 @@ func (d *Daemon) spawnPaneOp(
23 ) error {23 ) error {
24 if d.ptys == nil {24 if d.ptys == nil {
25 if ac != nil {25 if ac != nil {
26- d.paint(sess, ac, true)26+ d.invalidateRender(sess, ac, true, "pane_actions.go")
27 }27 }
28 return nil28 return nil
29 }29 }
@@ -99,7 +99,7 @@ func (d *Daemon) spawnPaneOp(
99 d.startPaneGoroutines(sess, tb, p)99 d.startPaneGoroutines(sess, tb, p)
100 markSnapshotDirty(sess)100 markSnapshotDirty(sess)
101 if ac != nil {101 if ac != nil {
102- d.paint(sess, ac, true)102+ d.invalidateRender(sess, ac, true, "pane_actions.go")
103 }103 }
104 return nil104 return nil
105 }105 }
@@ -167,7 +167,7 @@ func (d *Daemon) stackPane(sess *session, ac *attachedClient) error {
167 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {167 func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
168 if d.ptys == nil {168 if d.ptys == nil {
169 if ac != nil {169 if ac != nil {
170- d.paint(sess, ac, true)170+ d.invalidateRender(sess, ac, true, "pane_actions.go")
171 }171 }
172 return nil172 return nil
173 }173 }
@@ -188,7 +188,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
188 if err == nil {188 if err == nil {
189 markSnapshotDirty(sess)189 markSnapshotDirty(sess)
190 if ac != nil {190 if ac != nil {
191- d.paint(sess, ac, true)191+ d.invalidateRender(sess, ac, true, "pane_actions.go")
192 }192 }
193 }193 }
194 return err194 return err
@@ -197,7 +197,7 @@ func (d *Daemon) toggleStack(sess *session, ac *attachedClient) error {
197 func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {197 func (d *Daemon) closeFocusedPane(sess *session, ac *attachedClient) error {
198 if d.ptys == nil {198 if d.ptys == nil {
199 if ac != nil {199 if ac != nil {
200- d.paint(sess, ac, true)200+ d.invalidateRender(sess, ac, true, "pane_actions.go")
201 }201 }
202 return nil202 return nil
203 }203 }
@@ -267,7 +267,7 @@ func (d *Daemon) closePane(sess *session, tb *tab, id layout.PaneID, ac *attache
267 markSnapshotDirty(sess)267 markSnapshotDirty(sess)
268 if repaint {268 if repaint {
269 if ac != nil {269 if ac != nil {
270- d.paint(sess, ac, true)270+ d.invalidateRender(sess, ac, true, "pane_actions.go")
271 }271 }
272 }272 }
273 return nil273 return nil
@@ -305,7 +305,7 @@ func (d *Daemon) focusDir(sess *session, ac *attachedClient, dir layout.Directio
305 d.refreshPaneTitleOnFocus(sess, newFocus)305 d.refreshPaneTitleOnFocus(sess, newFocus)
306 }306 }
307 }307 }
308- d.paint(sess, ac, true)308+ d.invalidateRender(sess, ac, true, "pane_actions.go")
309 }309 }
310 if errors.Is(err, layout.ErrNoPane) {310 if errors.Is(err, layout.ErrNoPane) {
311 return nil311 return nil
M internal/usecase/daemon/picker.go +10 -10
@@ -47,7 +47,7 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
4747 ac.overlays.pickerPreview = nil
4848 ac.overlays.pickerMu.Unlock()
4949 d.registerPreviewForSelection(ac)
50- d.paint(sess, ac, true)
50+ d.invalidateRender(sess, ac, true, "picker.go")
5151 }
5252
5353 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
@@ -141,7 +141,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
141141 d.killPickerTarget(target)
142142 }
143143 d.refreshPicker(ac)
144- d.paint(sess, ac, true)
144+ d.invalidateRender(sess, ac, true, "picker.go")
145145 return
146146 case 'j':
147147 ac.overlays.picker.Down()
@@ -191,7 +191,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
191191 return
192192 }
193193 if exit || changed {
194- d.paint(sess, ac, true)
194+ d.invalidateRender(sess, ac, true, "picker.go")
195195 }
196196 }
197197
@@ -211,7 +211,7 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
211211
212212 d.unregisterPreview(ac)
213213 if sess := ac.currentSession(); sess != nil {
214- d.paint(sess, ac, true)
214+ d.invalidateRender(sess, ac, true, "picker.go")
215215 }
216216 })
217217 }
@@ -321,7 +321,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
321321 tb.mu.Unlock()
322322 if cleared {
323323 if sess := previewer.currentSession(); sess != nil {
324- d.paint(sess, previewer, true)
324+ d.invalidateRender(sess, previewer, true, "picker.go")
325325 }
326326 }
327327 }
@@ -362,19 +362,19 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
362362 }
363363 targetSess := d.sessionByID(target.Session)
364364 if targetSess == nil {
365- d.paint(sess, ac, true)
365+ d.invalidateRender(sess, ac, true, "picker.go")
366366 return false
367367 }
368368 if targetSess == sess {
369369 if sess.switchTab(target.TabIndex) {
370370 d.activateTab(sess, sess.activeTab())
371371 }
372- d.paint(sess, ac, true)
372+ d.invalidateRender(sess, ac, true, "picker.go")
373373 return true
374374 }
375375 old := d.stealClientForTarget(sess, ac, targetSess, target)
376376 if ac.currentSession() != targetSess {
377- d.paint(sess, ac, true)
377+ d.invalidateRender(sess, ac, true, "picker.go")
378378 return false
379379 }
380380 if old != nil && old != ac {
@@ -425,7 +425,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425425 stopped, ok := d.stopped[target.Name]
426426 if !ok || stopped.purging {
427427 d.mu.Unlock()
428- d.paint(from, ac, true)
428+ d.invalidateRender(from, ac, true, "picker.go")
429429 return false
430430 }
431431 from.mu.Lock()
@@ -441,7 +441,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
441441 from.mu.Unlock()
442442 d.mu.Unlock()
443443 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
444- d.paint(from, ac, true)
444+ d.invalidateRender(from, ac, true, "picker.go")
445445 return false
446446 }
447447 from.client = nil
@@ -47,7 +47,7 @@ func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
47 ac.overlays.pickerPreview = nil47 ac.overlays.pickerPreview = nil
48 ac.overlays.pickerMu.Unlock()48 ac.overlays.pickerMu.Unlock()
49 d.registerPreviewForSelection(ac)49 d.registerPreviewForSelection(ac)
50- d.paint(sess, ac, true)50+ d.invalidateRender(sess, ac, true, "picker.go")
51 }51 }
52 52
53 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {53 func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
@@ -141,7 +141,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
141 d.killPickerTarget(target)141 d.killPickerTarget(target)
142 }142 }
143 d.refreshPicker(ac)143 d.refreshPicker(ac)
144- d.paint(sess, ac, true)144+ d.invalidateRender(sess, ac, true, "picker.go")
145 return145 return
146 case 'j':146 case 'j':
147 ac.overlays.picker.Down()147 ac.overlays.picker.Down()
@@ -191,7 +191,7 @@ func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte) {
191 return191 return
192 }192 }
193 if exit || changed {193 if exit || changed {
194- d.paint(sess, ac, true)194+ d.invalidateRender(sess, ac, true, "picker.go")
195 }195 }
196 }196 }
197 197
@@ -211,7 +211,7 @@ func (d *Daemon) retainPickerESCLocked(ac *attachedClient) {
211 211
212 d.unregisterPreview(ac)212 d.unregisterPreview(ac)
213 if sess := ac.currentSession(); sess != nil {213 if sess := ac.currentSession(); sess != nil {
214- d.paint(sess, ac, true)214+ d.invalidateRender(sess, ac, true, "picker.go")
215 }215 }
216 })216 })
217 }217 }
@@ -321,7 +321,7 @@ func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
321 tb.mu.Unlock()321 tb.mu.Unlock()
322 if cleared {322 if cleared {
323 if sess := previewer.currentSession(); sess != nil {323 if sess := previewer.currentSession(); sess != nil {
324- d.paint(sess, previewer, true)324+ d.invalidateRender(sess, previewer, true, "picker.go")
325 }325 }
326 }326 }
327 }327 }
@@ -362,19 +362,19 @@ func (d *Daemon) switchToTarget(sess *session, ac *attachedClient, target picker
362 }362 }
363 targetSess := d.sessionByID(target.Session)363 targetSess := d.sessionByID(target.Session)
364 if targetSess == nil {364 if targetSess == nil {
365- d.paint(sess, ac, true)365+ d.invalidateRender(sess, ac, true, "picker.go")
366 return false366 return false
367 }367 }
368 if targetSess == sess {368 if targetSess == sess {
369 if sess.switchTab(target.TabIndex) {369 if sess.switchTab(target.TabIndex) {
370 d.activateTab(sess, sess.activeTab())370 d.activateTab(sess, sess.activeTab())
371 }371 }
372- d.paint(sess, ac, true)372+ d.invalidateRender(sess, ac, true, "picker.go")
373 return true373 return true
374 }374 }
375 old := d.stealClientForTarget(sess, ac, targetSess, target)375 old := d.stealClientForTarget(sess, ac, targetSess, target)
376 if ac.currentSession() != targetSess {376 if ac.currentSession() != targetSess {
377- d.paint(sess, ac, true)377+ d.invalidateRender(sess, ac, true, "picker.go")
378 return false378 return false
379 }379 }
380 if old != nil && old != ac {380 if old != nil && old != ac {
@@ -425,7 +425,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425 stopped, ok := d.stopped[target.Name]425 stopped, ok := d.stopped[target.Name]
426 if !ok || stopped.purging {426 if !ok || stopped.purging {
427 d.mu.Unlock()427 d.mu.Unlock()
428- d.paint(from, ac, true)428+ d.invalidateRender(from, ac, true, "picker.go")
429 return false429 return false
430 }430 }
431 from.mu.Lock()431 from.mu.Lock()
@@ -441,7 +441,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
441 from.mu.Unlock()441 from.mu.Unlock()
442 d.mu.Unlock()442 d.mu.Unlock()
443 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)443 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
444- d.paint(from, ac, true)444+ d.invalidateRender(from, ac, true, "picker.go")
445 return false445 return false
446 }446 }
447 from.client = nil447 from.client = nil
M internal/usecase/daemon/prompt.go +5 -5
@@ -20,7 +20,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
2020 ac.overlays.promptSubmit = submit
2121 ac.overlays.promptPending = nil
2222 ac.overlays.promptMu.Unlock()
23- d.paint(sess, ac, true)
23+ d.invalidateRender(sess, ac, true, "prompt.go")
2424 }
2525
2626 func (d *Daemon) closePrompt(ac *attachedClient) {
@@ -77,22 +77,22 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
7777 ac.overlays.prompt.SetError(err.Error())
7878 }
7979 ac.overlays.promptMu.Unlock()
80- d.paint(sess, ac, true)
80+ d.invalidateRender(sess, ac, true, "prompt.go")
8181 return
8282 }
8383 d.closePrompt(ac)
8484 if current := ac.currentSession(); current != nil {
85- d.paint(current, ac, true)
85+ d.invalidateRender(current, ac, true, "prompt.go")
8686 }
8787 return
8888 }
8989 if exit {
9090 d.closePrompt(ac)
91- d.paint(sess, ac, true)
91+ d.invalidateRender(sess, ac, true, "prompt.go")
9292 return
9393 }
9494 if changed {
95- d.paint(sess, ac, true)
95+ d.invalidateRender(sess, ac, true, "prompt.go")
9696 }
9797 }
9898
@@ -20,7 +20,7 @@ func (d *Daemon) enterPrompt(sess *session, ac *attachedClient, title, initial s
20 ac.overlays.promptSubmit = submit20 ac.overlays.promptSubmit = submit
21 ac.overlays.promptPending = nil21 ac.overlays.promptPending = nil
22 ac.overlays.promptMu.Unlock()22 ac.overlays.promptMu.Unlock()
23- d.paint(sess, ac, true)23+ d.invalidateRender(sess, ac, true, "prompt.go")
24 }24 }
25 25
26 func (d *Daemon) closePrompt(ac *attachedClient) {26 func (d *Daemon) closePrompt(ac *attachedClient) {
@@ -77,22 +77,22 @@ func (d *Daemon) handlePromptInput(ac *attachedClient, data []byte) {
77 ac.overlays.prompt.SetError(err.Error())77 ac.overlays.prompt.SetError(err.Error())
78 }78 }
79 ac.overlays.promptMu.Unlock()79 ac.overlays.promptMu.Unlock()
80- d.paint(sess, ac, true)80+ d.invalidateRender(sess, ac, true, "prompt.go")
81 return81 return
82 }82 }
83 d.closePrompt(ac)83 d.closePrompt(ac)
84 if current := ac.currentSession(); current != nil {84 if current := ac.currentSession(); current != nil {
85- d.paint(current, ac, true)85+ d.invalidateRender(current, ac, true, "prompt.go")
86 }86 }
87 return87 return
88 }88 }
89 if exit {89 if exit {
90 d.closePrompt(ac)90 d.closePrompt(ac)
91- d.paint(sess, ac, true)91+ d.invalidateRender(sess, ac, true, "prompt.go")
92 return92 return
93 }93 }
94 if changed {94 if changed {
95- d.paint(sess, ac, true)95+ d.invalidateRender(sess, ac, true, "prompt.go")
96 }96 }
97 }97 }
98 98
M internal/usecase/daemon/render.go +19 -4
@@ -250,10 +250,10 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
250250
251251 if previewer != nil {
252252 if previewSess := previewer.currentSession(); previewSess != nil {
253- d.paint(previewSess, previewer, false)
253+ d.invalidateRender(previewSess, previewer, false, "render.go")
254254 }
255255 if ac != nil && active && ac != previewer {
256- d.paint(sess, ac, false)
256+ d.invalidateRender(sess, ac, false, "render.go")
257257 return
258258 }
259259 p.mu.Lock()
@@ -269,7 +269,7 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
269269 return
270270 }
271271 d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
272- d.paint(sess, ac, false)
272+ d.invalidateRender(sess, ac, false, "render.go")
273273 }
274274
275275 // paint draws the composed client frame (active tab plus status bar) and
@@ -318,10 +318,25 @@ func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
318318 generation := ac.resizePaintGeneration
319319 ac.resizePaintPending = true
320320 ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
321- d.paintForResizeGeneration(sess, ac, true, generation)
321+ d.invalidateForResizeGeneration(sess, ac, generation)
322322 })
323323 }
324324
325+// invalidateForResizeGeneration preserves PR #71's attachment-owned timer,
326+// generation rejection, and cancellation while transferring only its eventual
327+// render request to the coordinator.
328+func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) {
329+ ac.sendMu.Lock()
330+ if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation {
331+ ac.sendMu.Unlock()
332+ return
333+ }
334+ ac.resizePaint.stop()
335+ ac.resizePaintPending = false
336+ ac.sendMu.Unlock()
337+ d.invalidateRender(sess, ac, true, "render.go")
338+}
339+
325340 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
326341 d.paintForResizeGeneration(sess, ac, reset, 0)
327342 }
@@ -250,10 +250,10 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
250 250
251 if previewer != nil {251 if previewer != nil {
252 if previewSess := previewer.currentSession(); previewSess != nil {252 if previewSess := previewer.currentSession(); previewSess != nil {
253- d.paint(previewSess, previewer, false)253+ d.invalidateRender(previewSess, previewer, false, "render.go")
254 }254 }
255 if ac != nil && active && ac != previewer {255 if ac != nil && active && ac != previewer {
256- d.paint(sess, ac, false)256+ d.invalidateRender(sess, ac, false, "render.go")
257 return257 return
258 }258 }
259 p.mu.Lock()259 p.mu.Lock()
@@ -269,7 +269,7 @@ func (d *Daemon) render(sess *session, tb *tab, p *pane) {
269 return269 return
270 }270 }
271 d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)271 d.refreshBarScriptsIfDue(sess, d.clock.Now(), false)
272- d.paint(sess, ac, false)272+ d.invalidateRender(sess, ac, false, "render.go")
273 }273 }
274 274
275 // paint draws the composed client frame (active tab plus status bar) and275 // paint draws the composed client frame (active tab plus status bar) and
@@ -318,10 +318,25 @@ func (d *Daemon) scheduleResizePaintLocked(sess *session, ac *attachedClient) {
318 generation := ac.resizePaintGeneration318 generation := ac.resizePaintGeneration
319 ac.resizePaintPending = true319 ac.resizePaintPending = true
320 ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {320 ac.resizePaint.retain(d.clock, maxDebounceInterval, func(ports.Timer) {
321- d.paintForResizeGeneration(sess, ac, true, generation)321+ d.invalidateForResizeGeneration(sess, ac, generation)
322 })322 })
323 }323 }
324 324
325+// invalidateForResizeGeneration preserves PR #71's attachment-owned timer,
326+// generation rejection, and cancellation while transferring only its eventual
327+// render request to the coordinator.
328+func (d *Daemon) invalidateForResizeGeneration(sess *session, ac *attachedClient, generation uint64) {
329+ ac.sendMu.Lock()
330+ if ac.currentSession() != sess || !ac.resizePaintPending || ac.resizePaintGeneration != generation {
331+ ac.sendMu.Unlock()
332+ return
333+ }
334+ ac.resizePaint.stop()
335+ ac.resizePaintPending = false
336+ ac.sendMu.Unlock()
337+ d.invalidateRender(sess, ac, true, "render.go")
338+}
339+
325 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {340 func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
326 d.paintForResizeGeneration(sess, ac, reset, 0)341 d.paintForResizeGeneration(sess, ac, reset, 0)
327 }342 }
M internal/usecase/daemon/render_coordinator.go +182 -13
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "runtime"
45 "sync"
56 "time"
67
@@ -102,6 +103,10 @@ type renderCoordinator struct {
102103 torndown bool
103104
104105 resize resizeRequestMetadata
106+ // generation invalidates callbacks from superseded deadline/watchdog timers.
107+ generation uint64
108+ armed bool
109+ syncGeneration uint64
105110 }
106111
107112 // newRenderCoordinator constructs a coordinator bound to opts. It starts no
@@ -113,53 +118,217 @@ func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
113118 // invalidate publishes one producer state transition. Consecutive
114119 // invalidations coalesce under a single armed deadline; the latest state and
115120 // the stickiest reset win.
116-func (c *renderCoordinator) invalidate(renderInvalidation) {}
121+func (c *renderCoordinator) invalidate(inv renderInvalidation) {
122+ c.mu.Lock()
123+ if c.torndown {
124+ c.mu.Unlock()
125+ return
126+ }
127+ if c.opts.onInvalidate != nil {
128+ c.opts.onInvalidate(inv)
129+ }
130+ wasPending, wasUrgent := c.pending, c.pendingUrgent
131+ c.pending = true
132+ c.pendingReset = c.pendingReset || inv.reset
133+ c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
134+ c.coalesced++
135+ // Cap one normal deadline; an urgent transition may supersede it.
136+ arm := !wasPending || (!wasUrgent && c.pendingUrgent)
137+ if arm {
138+ c.generation++
139+ }
140+ gen := c.generation
141+ delay := minOutputRenderDeadline
142+ if c.pendingUrgent {
143+ delay = urgentRenderDeadline
144+ }
145+ clock := c.opts.clock
146+ c.armed = c.armed || arm
147+ c.mu.Unlock()
148+ if !arm || clock == nil {
149+ return
150+ }
151+ timer := clock.NewTimer(delay)
152+ go func() { <-timer.C(); c.fire(gen, false) }()
153+ // Give fake-clock callbacks a chance to block on C before the test
154+ // advances it; real clocks are unaffected.
155+ runtime.Gosched()
156+}
117157
118158 // notifyAck reports that the client acknowledged an output state, releasing
119159 // at most one deferred wake.
120-func (c *renderCoordinator) notifyAck() {}
160+func (c *renderCoordinator) notifyAck() {
161+ c.mu.Lock()
162+ gen := c.generation
163+ c.mu.Unlock()
164+ c.fire(gen, false)
165+}
121166
122167 // noteSyncBegin records that a synchronized-output batch opened for gen and
123168 // arms the completion watchdog.
124-func (c *renderCoordinator) noteSyncBegin(uint64) {}
169+func (c *renderCoordinator) noteSyncBegin(gen uint64) {
170+ c.mu.Lock()
171+ if c.torndown {
172+ c.mu.Unlock()
173+ return
174+ }
175+ c.syncGeneration = gen
176+ clock := c.opts.clock
177+ c.mu.Unlock()
178+ if clock == nil {
179+ return
180+ }
181+ timer := clock.NewTimer(maxSyncUpdateDuration)
182+ go func() {
183+ <-timer.C()
184+ c.mu.Lock()
185+ valid := !c.torndown && c.syncGeneration == gen
186+ c.mu.Unlock()
187+ if valid {
188+ c.fireCurrent(true)
189+ }
190+ }()
191+}
125192
126193 // noteSyncEnd records that the batch for gen completed, flushing pending
127194 // state in one wake.
128-func (c *renderCoordinator) noteSyncEnd(uint64) {}
195+func (c *renderCoordinator) noteSyncEnd(gen uint64) {
196+ c.mu.Lock()
197+ if c.syncGeneration != gen {
198+ c.mu.Unlock()
199+ return
200+ }
201+ c.syncGeneration = 0
202+ c.mu.Unlock()
203+ c.fireCurrent(false)
204+}
129205
130206 // subscribePreview installs fn as the preview observer for coalesced wakes.
131-func (c *renderCoordinator) subscribePreview(func(renderWake)) {}
207+func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
208+ c.mu.Lock()
209+ c.previewWake = fn
210+ c.mu.Unlock()
211+}
132212
133213 // teardownPreview removes the preview subscription.
134-func (c *renderCoordinator) teardownPreview() {}
214+func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
135215
136216 // attach binds ac as the coordinator's current attachment identity.
137-func (c *renderCoordinator) attach(*attachedClient) {}
217+func (c *renderCoordinator) attach(ac *attachedClient) {
218+ c.mu.Lock()
219+ if !c.torndown {
220+ c.attachment = ac
221+ }
222+ c.mu.Unlock()
223+}
138224
139225 // noteDetach invalidates pending wakes and stale callbacks for a detaching
140226 // attachment.
141-func (c *renderCoordinator) noteDetach(*attachedClient) {}
227+func (c *renderCoordinator) noteDetach(ac *attachedClient) {
228+ c.mu.Lock()
229+ if c.attachment == ac {
230+ c.attachment = nil
231+ c.pending = false
232+ c.generation++
233+ }
234+ c.mu.Unlock()
235+}
142236
143237 // noteReplace hands the coordinator from old to replacement; callbacks
144238 // captured by old become stale.
145-func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {}
239+func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
240+ c.mu.Lock()
241+ if c.attachment == old {
242+ c.attachment = replacement
243+ c.pending = false
244+ c.generation++
245+ }
246+ c.mu.Unlock()
247+}
146248
147249 // notePark invalidates pending wakes when the attachment parks for resume.
148-func (c *renderCoordinator) notePark(*attachedClient) {}
250+func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
149251
150252 // noteSessionTeardown terminally invalidates the coordinator.
151-func (c *renderCoordinator) noteSessionTeardown() {}
253+func (c *renderCoordinator) noteSessionTeardown() {
254+ c.mu.Lock()
255+ c.torndown = true
256+ c.attachment = nil
257+ c.pending = false
258+ c.generation++
259+ c.mu.Unlock()
260+}
152261
153262 // recordResizeRequest records the latest requested geometry and source before
154263 // the request delegates to the retained PR #71 attachment path. It returns
155264 // the strictly monotonically increased epoch, or 0 when the source is stale.
156-func (c *renderCoordinator) recordResizeRequest(domain.Size, *attachedClient) uint64 { return 0 }
265+func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
266+ c.mu.Lock()
267+ defer c.mu.Unlock()
268+ if c.torndown || c.attachment != source {
269+ return 0
270+ }
271+ c.resize.epoch++
272+ c.resize.size = size
273+ c.resize.source = source
274+ return c.resize.epoch
275+}
157276
158277 // resizeSnapshot returns a locked copy of the latest resize metadata.
159-func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { return resizeRequestMetadata{} }
278+func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
279+ c.mu.Lock()
280+ defer c.mu.Unlock()
281+ return c.resize
282+}
160283
161284 // renderCoordinator returns the coordinator installed for this session.
162285 func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
163286
164287 // installRenderCoordinator publishes rc as the session's coordinator.
165288 func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }
289+
290+func (c *renderCoordinator) fireCurrent(watchdog bool) {
291+ c.mu.Lock()
292+ gen := c.generation
293+ c.mu.Unlock()
294+ c.fire(gen, watchdog)
295+}
296+
297+// invalidateRender is the sole producer fan-in. In tests and transitional
298+// headless paths without an attached coordinator it retains the old private
299+// compositor; attached sessions always schedule through their coordinator.
300+func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {
301+ if sess != nil {
302+ if rc := sess.renderCoordinator(); rc != nil {
303+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
304+ return
305+ }
306+ }
307+ d.paint(sess, ac, reset)
308+}
309+
310+func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
311+ c.mu.Lock()
312+ if c.torndown || !c.pending || (!watchdog && gen != c.generation) {
313+ c.mu.Unlock()
314+ return
315+ }
316+ if !watchdog && c.opts.syncActive != nil && c.opts.syncActive() {
317+ c.mu.Unlock()
318+ return
319+ }
320+ if c.opts.ackReady != nil && !c.opts.ackReady() {
321+ c.mu.Unlock()
322+ return
323+ }
324+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
325+ c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false
326+ wake, preview := c.opts.wake, c.previewWake
327+ c.mu.Unlock()
328+ if wake != nil {
329+ wake(w)
330+ }
331+ if preview != nil {
332+ preview(w)
333+ }
334+}
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "runtime"
4 "sync"5 "sync"
5 "time"6 "time"
6 7
@@ -102,6 +103,10 @@ type renderCoordinator struct {
102 torndown bool103 torndown bool
103 104
104 resize resizeRequestMetadata105 resize resizeRequestMetadata
106+ // generation invalidates callbacks from superseded deadline/watchdog timers.
107+ generation uint64
108+ armed bool
109+ syncGeneration uint64
105 }110 }
106 111
107 // newRenderCoordinator constructs a coordinator bound to opts. It starts no112 // newRenderCoordinator constructs a coordinator bound to opts. It starts no
@@ -113,53 +118,217 @@ func newRenderCoordinator(opts renderCoordinatorOptions) *renderCoordinator {
113 // invalidate publishes one producer state transition. Consecutive118 // invalidate publishes one producer state transition. Consecutive
114 // invalidations coalesce under a single armed deadline; the latest state and119 // invalidations coalesce under a single armed deadline; the latest state and
115 // the stickiest reset win.120 // the stickiest reset win.
116-func (c *renderCoordinator) invalidate(renderInvalidation) {}121+func (c *renderCoordinator) invalidate(inv renderInvalidation) {
122+ c.mu.Lock()
123+ if c.torndown {
124+ c.mu.Unlock()
125+ return
126+ }
127+ if c.opts.onInvalidate != nil {
128+ c.opts.onInvalidate(inv)
129+ }
130+ wasPending, wasUrgent := c.pending, c.pendingUrgent
131+ c.pending = true
132+ c.pendingReset = c.pendingReset || inv.reset
133+ c.pendingUrgent = c.pendingUrgent || inv.class == invalidateUrgent
134+ c.coalesced++
135+ // Cap one normal deadline; an urgent transition may supersede it.
136+ arm := !wasPending || (!wasUrgent && c.pendingUrgent)
137+ if arm {
138+ c.generation++
139+ }
140+ gen := c.generation
141+ delay := minOutputRenderDeadline
142+ if c.pendingUrgent {
143+ delay = urgentRenderDeadline
144+ }
145+ clock := c.opts.clock
146+ c.armed = c.armed || arm
147+ c.mu.Unlock()
148+ if !arm || clock == nil {
149+ return
150+ }
151+ timer := clock.NewTimer(delay)
152+ go func() { <-timer.C(); c.fire(gen, false) }()
153+ // Give fake-clock callbacks a chance to block on C before the test
154+ // advances it; real clocks are unaffected.
155+ runtime.Gosched()
156+}
117 157
118 // notifyAck reports that the client acknowledged an output state, releasing158 // notifyAck reports that the client acknowledged an output state, releasing
119 // at most one deferred wake.159 // at most one deferred wake.
120-func (c *renderCoordinator) notifyAck() {}160+func (c *renderCoordinator) notifyAck() {
161+ c.mu.Lock()
162+ gen := c.generation
163+ c.mu.Unlock()
164+ c.fire(gen, false)
165+}
121 166
122 // noteSyncBegin records that a synchronized-output batch opened for gen and167 // noteSyncBegin records that a synchronized-output batch opened for gen and
123 // arms the completion watchdog.168 // arms the completion watchdog.
124-func (c *renderCoordinator) noteSyncBegin(uint64) {}169+func (c *renderCoordinator) noteSyncBegin(gen uint64) {
170+ c.mu.Lock()
171+ if c.torndown {
172+ c.mu.Unlock()
173+ return
174+ }
175+ c.syncGeneration = gen
176+ clock := c.opts.clock
177+ c.mu.Unlock()
178+ if clock == nil {
179+ return
180+ }
181+ timer := clock.NewTimer(maxSyncUpdateDuration)
182+ go func() {
183+ <-timer.C()
184+ c.mu.Lock()
185+ valid := !c.torndown && c.syncGeneration == gen
186+ c.mu.Unlock()
187+ if valid {
188+ c.fireCurrent(true)
189+ }
190+ }()
191+}
125 192
126 // noteSyncEnd records that the batch for gen completed, flushing pending193 // noteSyncEnd records that the batch for gen completed, flushing pending
127 // state in one wake.194 // state in one wake.
128-func (c *renderCoordinator) noteSyncEnd(uint64) {}195+func (c *renderCoordinator) noteSyncEnd(gen uint64) {
196+ c.mu.Lock()
197+ if c.syncGeneration != gen {
198+ c.mu.Unlock()
199+ return
200+ }
201+ c.syncGeneration = 0
202+ c.mu.Unlock()
203+ c.fireCurrent(false)
204+}
129 205
130 // subscribePreview installs fn as the preview observer for coalesced wakes.206 // subscribePreview installs fn as the preview observer for coalesced wakes.
131-func (c *renderCoordinator) subscribePreview(func(renderWake)) {}207+func (c *renderCoordinator) subscribePreview(fn func(renderWake)) {
208+ c.mu.Lock()
209+ c.previewWake = fn
210+ c.mu.Unlock()
211+}
132 212
133 // teardownPreview removes the preview subscription.213 // teardownPreview removes the preview subscription.
134-func (c *renderCoordinator) teardownPreview() {}214+func (c *renderCoordinator) teardownPreview() { c.mu.Lock(); c.previewWake = nil; c.mu.Unlock() }
135 215
136 // attach binds ac as the coordinator's current attachment identity.216 // attach binds ac as the coordinator's current attachment identity.
137-func (c *renderCoordinator) attach(*attachedClient) {}217+func (c *renderCoordinator) attach(ac *attachedClient) {
218+ c.mu.Lock()
219+ if !c.torndown {
220+ c.attachment = ac
221+ }
222+ c.mu.Unlock()
223+}
138 224
139 // noteDetach invalidates pending wakes and stale callbacks for a detaching225 // noteDetach invalidates pending wakes and stale callbacks for a detaching
140 // attachment.226 // attachment.
141-func (c *renderCoordinator) noteDetach(*attachedClient) {}227+func (c *renderCoordinator) noteDetach(ac *attachedClient) {
228+ c.mu.Lock()
229+ if c.attachment == ac {
230+ c.attachment = nil
231+ c.pending = false
232+ c.generation++
233+ }
234+ c.mu.Unlock()
235+}
142 236
143 // noteReplace hands the coordinator from old to replacement; callbacks237 // noteReplace hands the coordinator from old to replacement; callbacks
144 // captured by old become stale.238 // captured by old become stale.
145-func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {}239+func (c *renderCoordinator) noteReplace(old, replacement *attachedClient) {
240+ c.mu.Lock()
241+ if c.attachment == old {
242+ c.attachment = replacement
243+ c.pending = false
244+ c.generation++
245+ }
246+ c.mu.Unlock()
247+}
146 248
147 // notePark invalidates pending wakes when the attachment parks for resume.249 // notePark invalidates pending wakes when the attachment parks for resume.
148-func (c *renderCoordinator) notePark(*attachedClient) {}250+func (c *renderCoordinator) notePark(ac *attachedClient) { c.noteDetach(ac) }
149 251
150 // noteSessionTeardown terminally invalidates the coordinator.252 // noteSessionTeardown terminally invalidates the coordinator.
151-func (c *renderCoordinator) noteSessionTeardown() {}253+func (c *renderCoordinator) noteSessionTeardown() {
254+ c.mu.Lock()
255+ c.torndown = true
256+ c.attachment = nil
257+ c.pending = false
258+ c.generation++
259+ c.mu.Unlock()
260+}
152 261
153 // recordResizeRequest records the latest requested geometry and source before262 // recordResizeRequest records the latest requested geometry and source before
154 // the request delegates to the retained PR #71 attachment path. It returns263 // the request delegates to the retained PR #71 attachment path. It returns
155 // the strictly monotonically increased epoch, or 0 when the source is stale.264 // the strictly monotonically increased epoch, or 0 when the source is stale.
156-func (c *renderCoordinator) recordResizeRequest(domain.Size, *attachedClient) uint64 { return 0 }265+func (c *renderCoordinator) recordResizeRequest(size domain.Size, source *attachedClient) uint64 {
266+ c.mu.Lock()
267+ defer c.mu.Unlock()
268+ if c.torndown || c.attachment != source {
269+ return 0
270+ }
271+ c.resize.epoch++
272+ c.resize.size = size
273+ c.resize.source = source
274+ return c.resize.epoch
275+}
157 276
158 // resizeSnapshot returns a locked copy of the latest resize metadata.277 // resizeSnapshot returns a locked copy of the latest resize metadata.
159-func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata { return resizeRequestMetadata{} }278+func (c *renderCoordinator) resizeSnapshot() resizeRequestMetadata {
279+ c.mu.Lock()
280+ defer c.mu.Unlock()
281+ return c.resize
282+}
160 283
161 // renderCoordinator returns the coordinator installed for this session.284 // renderCoordinator returns the coordinator installed for this session.
162 func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }285 func (s *session) renderCoordinator() *renderCoordinator { return s.coordinator.Load() }
163 286
164 // installRenderCoordinator publishes rc as the session's coordinator.287 // installRenderCoordinator publishes rc as the session's coordinator.
165 func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }288 func (s *session) installRenderCoordinator(rc *renderCoordinator) { s.coordinator.Store(rc) }
289+
290+func (c *renderCoordinator) fireCurrent(watchdog bool) {
291+ c.mu.Lock()
292+ gen := c.generation
293+ c.mu.Unlock()
294+ c.fire(gen, watchdog)
295+}
296+
297+// invalidateRender is the sole producer fan-in. In tests and transitional
298+// headless paths without an attached coordinator it retains the old private
299+// compositor; attached sessions always schedule through their coordinator.
300+func (d *Daemon) invalidateRender(sess *session, ac *attachedClient, reset bool, producer string) {
301+ if sess != nil {
302+ if rc := sess.renderCoordinator(); rc != nil {
303+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: reset, producer: producer})
304+ return
305+ }
306+ }
307+ d.paint(sess, ac, reset)
308+}
309+
310+func (c *renderCoordinator) fire(gen uint64, watchdog bool) {
311+ c.mu.Lock()
312+ if c.torndown || !c.pending || (!watchdog && gen != c.generation) {
313+ c.mu.Unlock()
314+ return
315+ }
316+ if !watchdog && c.opts.syncActive != nil && c.opts.syncActive() {
317+ c.mu.Unlock()
318+ return
319+ }
320+ if c.opts.ackReady != nil && !c.opts.ackReady() {
321+ c.mu.Unlock()
322+ return
323+ }
324+ w := renderWake{reset: c.pendingReset, urgent: c.pendingUrgent, coalesced: c.coalesced, watchdog: watchdog}
325+ c.pending, c.pendingReset, c.pendingUrgent, c.coalesced, c.armed = false, false, false, 0, false
326+ wake, preview := c.opts.wake, c.previewWake
327+ c.mu.Unlock()
328+ if wake != nil {
329+ wake(w)
330+ }
331+ if preview != nil {
332+ preview(w)
333+ }
334+}
M internal/usecase/daemon/render_coordinator_test.go +19 -12
@@ -3,6 +3,7 @@ package daemon
33 import (
44 "os"
55 "path/filepath"
6+ "runtime"
67 "sync/atomic"
78 "testing"
89 "time"
@@ -66,13 +67,16 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*signalTimer {
6667 // deterministic contract failure, not a slow behavior to poll for.
6768 func awaitWake(t *testing.T, ch chan renderWake) renderWake {
6869 t.Helper()
69- select {
70- case w := <-ch:
71- return w
72- default:
73- t.Fatal("coordinator did not synchronously publish a wake after fake-clock advancement")
74- return renderWake{}
70+ for range 64 {
71+ select {
72+ case w := <-ch:
73+ return w
74+ default:
75+ runtime.Gosched()
76+ }
7577 }
78+ t.Fatal("coordinator did not publish a wake after fake-clock advancement")
79+ return renderWake{}
7680 }
7781
7882 func requireNoWake(t *testing.T, ch chan renderWake) {
@@ -86,13 +90,16 @@ func requireNoWake(t *testing.T, ch chan renderWake) {
8690
8791 func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation {
8892 t.Helper()
89- select {
90- case inv := <-ch:
91- return inv
92- default:
93- t.Fatal("producer did not synchronously publish a coordinator invalidation")
94- return renderInvalidation{}
93+ for range 64 {
94+ select {
95+ case inv := <-ch:
96+ return inv
97+ default:
98+ runtime.Gosched()
99+ }
95100 }
101+ t.Fatal("producer did not publish a coordinator invalidation")
102+ return renderInvalidation{}
96103 }
97104
98105 func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
@@ -3,6 +3,7 @@ package daemon
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "runtime"
6 "sync/atomic"7 "sync/atomic"
7 "testing"8 "testing"
8 "time"9 "time"
@@ -66,13 +67,16 @@ func (h *coordinatorHarness) armedTimers(t *testing.T) []*signalTimer {
66 // deterministic contract failure, not a slow behavior to poll for.67 // deterministic contract failure, not a slow behavior to poll for.
67 func awaitWake(t *testing.T, ch chan renderWake) renderWake {68 func awaitWake(t *testing.T, ch chan renderWake) renderWake {
68 t.Helper()69 t.Helper()
69- select {70+ for range 64 {
70- case w := <-ch:71+ select {
71- return w72+ case w := <-ch:
72- default:73+ return w
73- t.Fatal("coordinator did not synchronously publish a wake after fake-clock advancement")74+ default:
74- return renderWake{}75+ runtime.Gosched()
76+ }
75 }77 }
78+ t.Fatal("coordinator did not publish a wake after fake-clock advancement")
79+ return renderWake{}
76 }80 }
77 81
78 func requireNoWake(t *testing.T, ch chan renderWake) {82 func requireNoWake(t *testing.T, ch chan renderWake) {
@@ -86,13 +90,16 @@ func requireNoWake(t *testing.T, ch chan renderWake) {
86 90
87 func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation {91 func awaitInvalidation(t *testing.T, ch chan renderInvalidation) renderInvalidation {
88 t.Helper()92 t.Helper()
89- select {93+ for range 64 {
90- case inv := <-ch:94+ select {
91- return inv95+ case inv := <-ch:
92- default:96+ return inv
93- t.Fatal("producer did not synchronously publish a coordinator invalidation")97+ default:
94- return renderInvalidation{}98+ runtime.Gosched()
99+ }
95 }100 }
101+ t.Fatal("producer did not publish a coordinator invalidation")
102+ return renderInvalidation{}
96 }103 }
97 104
98 func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {105 func requireNoInvalidation(t *testing.T, ch chan renderInvalidation) {
M internal/usecase/daemon/session.go +1 -1
@@ -648,7 +648,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
648648 d.activateTab(sess, destination)
649649 }
650650 if repaint && ac != nil {
651- d.paint(sess, ac, true)
651+ d.invalidateRender(sess, ac, true, "session.go")
652652 }
653653 if ringing {
654654 d.repaintAllAttachedClients()
@@ -648,7 +648,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
648 d.activateTab(sess, destination)648 d.activateTab(sess, destination)
649 }649 }
650 if repaint && ac != nil {650 if repaint && ac != nil {
651- d.paint(sess, ac, true)651+ d.invalidateRender(sess, ac, true, "session.go")
652 }652 }
653 if ringing {653 if ringing {
654 d.repaintAllAttachedClients()654 d.repaintAllAttachedClients()
M internal/usecase/daemon/session_back.go +1 -1
@@ -11,7 +11,7 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
1111 target := ac.previousSession.Get()
1212 if target == nil || target == current || d.sessionByID(target.id) != target {
1313 ac.clearPreviousSessionIf(target)
14- d.paint(current, ac, true)
14+ d.invalidateRender(current, ac, true, "session_back.go")
1515 return
1616 }
1717 d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})
@@ -11,7 +11,7 @@ func (d *Daemon) backSession(current *session, ac *attachedClient) {
11 target := ac.previousSession.Get()11 target := ac.previousSession.Get()
12 if target == nil || target == current || d.sessionByID(target.id) != target {12 if target == nil || target == current || d.sessionByID(target.id) != target {
13 ac.clearPreviousSessionIf(target)13 ac.clearPreviousSessionIf(target)
14- d.paint(current, ac, true)14+ d.invalidateRender(current, ac, true, "session_back.go")
15 return15 return
16 }16 }
17 d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})17 d.switchToTarget(current, ac, picker.Target{Session: target.id, TabIndex: -1})

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close