detached b4f3d9b

perf(daemon): reuse composed frame for damaged paints

commit
b4f3d9be6ac527a6cb7a356116726577407bf34d
author
brice · · 2026-07-08 14:46 UTC
committer
brice · · 2026-07-08 20:59 UTC
parent
56d89b0
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +382 -23
M internal/usecase/daemon/client.go +3 -2
@@ -49,8 +49,9 @@ type attachedClient struct {
4949 nextStateNum uint64
5050 echoAck atomic.Uint64
5151 outputAck atomic.Uint64
52- paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
53- bars barCache // only touched while sendMu is held
52+ paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
53+ bars barCache // only touched while sendMu is held
54+ composed composedFrameCache // only touched while sendMu is held
5455 size domain.Size
5556 keys *keys.Router
5657 sess Guarded[*session]
@@ -49,8 +49,9 @@ type attachedClient struct {
49 nextStateNum uint6449 nextStateNum uint64
50 echoAck atomic.Uint6450 echoAck atomic.Uint64
51 outputAck atomic.Uint6451 outputAck atomic.Uint64
52- paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag52+ paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
53- bars barCache // only touched while sendMu is held53+ bars barCache // only touched while sendMu is held
54+ composed composedFrameCache // only touched while sendMu is held
54 size domain.Size55 size domain.Size
55 keys *keys.Router56 keys *keys.Router
56 sess Guarded[*session]57 sess Guarded[*session]
M internal/usecase/daemon/render.go +117 -21
@@ -332,14 +332,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
332332 return
333333 }
334334 reset = ac.shouldResetOutputState(reset)
335- if reset || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
335+ overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
336+ if reset || overlayActive {
336337 ac.rend.Reset()
337338 ac.bars.Reset()
339+ ac.composed.invalidate()
338340 }
339341 if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
340342 ac.lastCursor.valid = false
341343 }
342- frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars)
344+ var frame renderer.Frame
345+ var damage []renderer.Damage
346+ if overlayActive {
347+ frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil)
348+ } else {
349+ frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
350+ }
343351 if overlays.copyActive {
344352 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
345353 }
@@ -380,11 +388,6 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
380388 if err == nil {
381389 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
382390 }
383- for _, pane := range tb.panesSnapshot() {
384- pane.mu.Lock()
385- pane.screen.ClearDamage()
386- pane.mu.Unlock()
387- }
388391 tb.mu.Unlock()
389392
390393 var serr error
@@ -500,6 +503,16 @@ func (c *barCache) Reset() {
500503 c.bottom = nil
501504 }
502505
506+type composedFrameCache struct {
507+ frame renderer.Frame
508+ valid bool
509+ layoutSnap tabLayoutSnapshot
510+}
511+
512+func (c *composedFrameCache) invalidate() {
513+ c.valid = false
514+}
515+
503516 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
504517 return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)
505518 }
@@ -513,6 +526,18 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
513526 if len(caches) > 0 {
514527 cache = caches[0]
515528 }
529+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
530+}
531+
532+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
533+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
534+}
535+
536+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
537+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
538+}
539+
540+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
516541 p := tb.focusedPane()
517542 styles := newThemeStyles(bars.theme)
518543 if p == nil {
@@ -528,15 +553,34 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
528553 if tb.size.Valid() {
529554 width, screenRows = tb.size.Cols, tb.size.Rows
530555 }
531- frame := renderer.NewFrame(width, screenRows+2)
556+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
557+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
558+ var frame renderer.Frame
559+ if cacheValid {
560+ frame = composed.frame
561+ } else {
562+ frame = renderer.NewFrame(width, screenRows+2)
563+ if composed != nil {
564+ full = true
565+ }
566+ }
567+ if !layoutSame {
568+ full = true
569+ }
570+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
571+ if cacheValid && !layoutSame {
572+ clearFrameRect(frame, contentArea)
573+ }
532574 topBar := frame.Row(0)
533575 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
534- contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap)
535- for y := range screenRows {
536- copy(frame.Row(y+1), contentFrame.Row(y))
537- }
576+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage)
538577 bottomBar := frame.Row(screenRows + 1)
539578 drawStatusBarState(bottomBar, bars, styles)
579+ if composed != nil {
580+ composed.frame = frame
581+ composed.valid = true
582+ composed.layoutSnap = layoutSnap
583+ }
540584 if full {
541585 if cache != nil {
542586 cache.capture(topBar, bottomBar)
@@ -562,20 +606,30 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F
562606
563607 func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
564608 frame := renderer.NewFrame(area.Width, area.Height)
609+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
610+ return frame, damage
611+}
612+
613+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
614+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false)
615+}
616+
617+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage {
618+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
565619 root := tb.tree.Root
566- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == area
620+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
567621 if !ok {
568- placements, ok = layout.Solve(root, area)
622+ placements, ok = layout.Solve(root, contentArea)
569623 }
570624 var fallback *pane
571625 if !ok {
572626 fallback = tb.focusedPane()
573627 if fallback == nil {
574- return frame, nil
628+ return nil
575629 }
576- placements = []layout.Placement{{ID: fallback.id, Content: area}}
630+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
577631 }
578- if ok {
632+ if ok && !cacheValid {
579633 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
580634 }
581635 var damage []renderer.Damage
@@ -588,20 +642,62 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
588642 continue
589643 }
590644 focused := tb.tree.Focus == pl.ID
645+ pl = offsetPlacement(pl, area.X, area.Y)
591646 if pl.TitleBar.Height > 0 {
592647 drawPaneTitleBar(frame, pl, p, focused, theme, "")
648+ if cacheValid {
649+ titleDamage := pl.TitleBar
650+ titleDamage.X -= area.X
651+ titleDamage.Y -= area.Y
652+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
653+ }
593654 }
594655 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
656+ if consumeDamage {
657+ p.mu.Lock()
658+ p.screen.ClearDamage()
659+ p.mu.Unlock()
660+ }
595661 continue
596662 }
597663 p.mu.Lock()
598- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
599- for _, d := range p.screen.Damage() {
600- damage = append(damage, translatePaneDamage(d, pl.Content, area)...)
664+ paneDamage := p.screen.Damage()
665+ if !cacheValid || len(paneDamage) > 0 {
666+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
667+ }
668+ for _, d := range paneDamage {
669+ localContent := pl.Content
670+ localContent.X -= area.X
671+ localContent.Y -= area.Y
672+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
673+ }
674+ if consumeDamage {
675+ p.screen.ClearDamage()
601676 }
602677 p.mu.Unlock()
603678 }
604- return frame, damage
679+ return damage
680+}
681+
682+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
683+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
684+}
685+
686+func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
687+ pl.Content.X += dx
688+ pl.Content.Y += dy
689+ pl.TitleBar.X += dx
690+ pl.TitleBar.Y += dy
691+ return pl
692+}
693+
694+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
695+ blank := renderer.BlankCell()
696+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
697+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
698+ frame.Set(x, y, blank)
699+ }
700+ }
605701 }
606702
607703 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
@@ -332,14 +332,22 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
332 return332 return
333 }333 }
334 reset = ac.shouldResetOutputState(reset)334 reset = ac.shouldResetOutputState(reset)
335- if reset || overlays.copyActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {335+ overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
336+ if reset || overlayActive {
336 ac.rend.Reset()337 ac.rend.Reset()
337 ac.bars.Reset()338 ac.bars.Reset()
339+ ac.composed.invalidate()
338 }340 }
339 if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {341 if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive {
340 ac.lastCursor.valid = false342 ac.lastCursor.valid = false
341 }343 }
342- frame, damage := composeClientFrameWithLayout(bars, tb, reset, layoutSnap, &ac.bars)344+ var frame renderer.Frame
345+ var damage []renderer.Damage
346+ if overlayActive {
347+ frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, nil)
348+ } else {
349+ frame, damage = composeClientFrameWithLayoutCachedConsumeDamage(bars, tb, reset, layoutSnap, &ac.bars, &ac.composed)
350+ }
343 if overlays.copyActive {351 if overlays.copyActive {
344 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)352 frame, damage = composeCopyClientFrame(overlays.copyMode, tb, bars)
345 }353 }
@@ -380,11 +388,6 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
380 if err == nil {388 if err == nil {
381 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)389 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
382 }390 }
383- for _, pane := range tb.panesSnapshot() {
384- pane.mu.Lock()
385- pane.screen.ClearDamage()
386- pane.mu.Unlock()
387- }
388 tb.mu.Unlock()391 tb.mu.Unlock()
389 392
390 var serr error393 var serr error
@@ -500,6 +503,16 @@ func (c *barCache) Reset() {
500 c.bottom = nil503 c.bottom = nil
501 }504 }
502 505
506+type composedFrameCache struct {
507+ frame renderer.Frame
508+ valid bool
509+ layoutSnap tabLayoutSnapshot
510+}
511+
512+func (c *composedFrameCache) invalidate() {
513+ c.valid = false
514+}
515+
503 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {516 func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
504 return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)517 return composeClientFrameWithState(barState{status: sess.statusSegments(), copyFeedback: rightStatus}, tb, full, caches...)
505 }518 }
@@ -513,6 +526,18 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
513 if len(caches) > 0 {526 if len(caches) > 0 {
514 cache = caches[0]527 cache = caches[0]
515 }528 }
529+ return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
530+}
531+
532+func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
533+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
534+}
535+
536+func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
537+ return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
538+}
539+
540+func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
516 p := tb.focusedPane()541 p := tb.focusedPane()
517 styles := newThemeStyles(bars.theme)542 styles := newThemeStyles(bars.theme)
518 if p == nil {543 if p == nil {
@@ -528,15 +553,34 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap
528 if tb.size.Valid() {553 if tb.size.Valid() {
529 width, screenRows = tb.size.Cols, tb.size.Rows554 width, screenRows = tb.size.Cols, tb.size.Rows
530 }555 }
531- frame := renderer.NewFrame(width, screenRows+2)556+ cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
557+ layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
558+ var frame renderer.Frame
559+ if cacheValid {
560+ frame = composed.frame
561+ } else {
562+ frame = renderer.NewFrame(width, screenRows+2)
563+ if composed != nil {
564+ full = true
565+ }
566+ }
567+ if !layoutSame {
568+ full = true
569+ }
570+ contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
571+ if cacheValid && !layoutSame {
572+ clearFrameRect(frame, contentArea)
573+ }
532 topBar := frame.Row(0)574 topBar := frame.Row(0)
533 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)575 drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
534- contentFrame, contentDamage := composeTabFrameWithLayout(tb, domain.Rect{Width: width, Height: screenRows}, bars.theme, layoutSnap)576+ contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage)
535- for y := range screenRows {
536- copy(frame.Row(y+1), contentFrame.Row(y))
537- }
538 bottomBar := frame.Row(screenRows + 1)577 bottomBar := frame.Row(screenRows + 1)
539 drawStatusBarState(bottomBar, bars, styles)578 drawStatusBarState(bottomBar, bars, styles)
579+ if composed != nil {
580+ composed.frame = frame
581+ composed.valid = true
582+ composed.layoutSnap = layoutSnap
583+ }
540 if full {584 if full {
541 if cache != nil {585 if cache != nil {
542 cache.capture(topBar, bottomBar)586 cache.capture(topBar, bottomBar)
@@ -562,20 +606,30 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F
562 606
563 func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {607 func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
564 frame := renderer.NewFrame(area.Width, area.Height)608 frame := renderer.NewFrame(area.Width, area.Height)
609+ damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
610+ return frame, damage
611+}
612+
613+func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
614+ return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false)
615+}
616+
617+func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool) []renderer.Damage {
618+ contentArea := domain.Rect{Width: area.Width, Height: area.Height}
565 root := tb.tree.Root619 root := tb.tree.Root
566- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == area620+ placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
567 if !ok {621 if !ok {
568- placements, ok = layout.Solve(root, area)622+ placements, ok = layout.Solve(root, contentArea)
569 }623 }
570 var fallback *pane624 var fallback *pane
571 if !ok {625 if !ok {
572 fallback = tb.focusedPane()626 fallback = tb.focusedPane()
573 if fallback == nil {627 if fallback == nil {
574- return frame, nil628+ return nil
575 }629 }
576- placements = []layout.Placement{{ID: fallback.id, Content: area}}630+ placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
577 }631 }
578- if ok {632+ if ok && !cacheValid {
579 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))633 drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
580 }634 }
581 var damage []renderer.Damage635 var damage []renderer.Damage
@@ -588,20 +642,62 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l
588 continue642 continue
589 }643 }
590 focused := tb.tree.Focus == pl.ID644 focused := tb.tree.Focus == pl.ID
645+ pl = offsetPlacement(pl, area.X, area.Y)
591 if pl.TitleBar.Height > 0 {646 if pl.TitleBar.Height > 0 {
592 drawPaneTitleBar(frame, pl, p, focused, theme, "")647 drawPaneTitleBar(frame, pl, p, focused, theme, "")
648+ if cacheValid {
649+ titleDamage := pl.TitleBar
650+ titleDamage.X -= area.X
651+ titleDamage.Y -= area.Y
652+ damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
653+ }
593 }654 }
594 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {655 if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
656+ if consumeDamage {
657+ p.mu.Lock()
658+ p.screen.ClearDamage()
659+ p.mu.Unlock()
660+ }
595 continue661 continue
596 }662 }
597 p.mu.Lock()663 p.mu.Lock()
598- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)664+ paneDamage := p.screen.Damage()
599- for _, d := range p.screen.Damage() {665+ if !cacheValid || len(paneDamage) > 0 {
600- damage = append(damage, translatePaneDamage(d, pl.Content, area)...)666+ blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
667+ }
668+ for _, d := range paneDamage {
669+ localContent := pl.Content
670+ localContent.X -= area.X
671+ localContent.Y -= area.Y
672+ damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
673+ }
674+ if consumeDamage {
675+ p.screen.ClearDamage()
601 }676 }
602 p.mu.Unlock()677 p.mu.Unlock()
603 }678 }
604- return frame, damage679+ return damage
680+}
681+
682+func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
683+ return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
684+}
685+
686+func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
687+ pl.Content.X += dx
688+ pl.Content.Y += dy
689+ pl.TitleBar.X += dx
690+ pl.TitleBar.Y += dy
691+ return pl
692+}
693+
694+func clearFrameRect(frame renderer.Frame, r domain.Rect) {
695+ blank := renderer.BlankCell()
696+ for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
697+ for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
698+ frame.Set(x, y, blank)
699+ }
700+ }
605 }701 }
606 702
607 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {703 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
M internal/usecase/daemon/render_test.go +209 -0
@@ -26,6 +26,12 @@ import (
2626
2727 // --- test doubles -----------------------------------------------------------
2828
29+type discardTransport struct{}
30+
31+func (discardTransport) Send(ports.Frame) error { return nil }
32+func (discardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
33+func (discardTransport) Close() error { return nil }
34+
2935 // stubClock returns timers whose channel never fires, so a scheduler under it
3036 // blocks in its debounce loop until the session context is cancelled. Used by
3137
@@ -430,6 +436,147 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test
430436 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
431437 }
432438
439+func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
440+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
441+ left := win.focusedPane()
442+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
443+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
444+ win.tree.Focus = right.id
445+ win.panes[right.id] = right
446+ left.screen.Write([]byte("L"))
447+ right.screen.Write([]byte("R"))
448+ var bars barCache
449+ var composed composedFrameCache
450+ sess := &session{id: "s", name: "work", tabs: []*tab{win}}
451+ composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed)
452+ left.screen.ClearDamage()
453+ right.screen.ClearDamage()
454+ left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
455+ right.screen.Write([]byte("x"))
456+
457+ frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed)
458+
459+ require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
460+ require.Equal(t, 'x', frame.At(22, 1).Rune)
461+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1})
462+}
463+
464+func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
465+ win := newTab(nil, domain.Size{Cols: 20, Rows: 4})
466+ p := win.focusedPane()
467+ p.screen.Write([]byte("old"))
468+ var bars barCache
469+ var composed composedFrameCache
470+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
471+
472+ _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
473+ require.NotEmpty(t, damage)
474+ require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame")
475+
476+ p.screen.Write([]byte("new"))
477+ require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render")
478+
479+ _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
480+ require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming")
481+}
482+
483+func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
484+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
485+ p1 := win.focusedPane()
486+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
487+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
488+ win.tree.Focus = p2.id
489+ win.panes[p2.id] = p2
490+ p1.screen.Write([]byte("hidden"))
491+ p2.screen.Write([]byte("shown"))
492+ var bars barCache
493+ var composed composedFrameCache
494+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
495+
496+ _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
497+
498+ require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars")
499+ require.Empty(t, p2.screen.Damage())
500+}
501+
502+func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
503+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
504+ left := win.focusedPane()
505+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
506+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
507+ win.panes[right.id] = right
508+ left.screen.Write([]byte("L"))
509+ right.screen.Write([]byte("R"))
510+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
511+ var bars barCache
512+ var composed composedFrameCache
513+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme}
514+ win.tree.Focus = left.id
515+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
516+ left.screen.ClearDamage()
517+ right.screen.ClearDamage()
518+ leftWasFocused := composed.frame.At(0, 1).Style
519+ rightWasDimmed := composed.frame.At(21, 1).Style
520+
521+ win.tree.Focus = right.id
522+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
523+
524+ require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed")
525+ require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed")
526+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
527+}
528+
529+func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
530+ win := newTab(nil, domain.Size{Cols: 41, Rows: 5})
531+ left := win.focusedPane()
532+ right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5})
533+ win.panes[right.id] = right
534+ left.screen.Write([]byte("L"))
535+ right.screen.Write([]byte("R"))
536+ var bars barCache
537+ var composed composedFrameCache
538+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
539+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
540+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
541+ require.Equal(t, '│', composed.frame.At(20, 1).Rune)
542+ left.screen.ClearDamage()
543+ right.screen.ClearDamage()
544+
545+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
546+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
547+
548+ require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes")
549+ require.Equal(t, '─', frame.At(20, 3).Rune)
550+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
551+}
552+
553+func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
554+ win := newTab(nil, domain.Size{Cols: 81, Rows: 24})
555+ left := win.focusedPane()
556+ right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24})
557+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
558+ win.tree.Focus = right.id
559+ win.panes[right.id] = right
560+ left.screen.Write([]byte("left"))
561+ right.screen.Write([]byte("right"))
562+ sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0}
563+ ac := &attachedClient{tr: discardTransport{}, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 81, Rows: 26}}
564+ ac.initOverlays()
565+ sess.client = ac
566+ ac.setSession(sess)
567+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
568+ d.paint(sess, ac, true)
569+ left.screen.ClearDamage()
570+ right.screen.ClearDamage()
571+
572+ b.ReportAllocs()
573+ b.ResetTimer()
574+ for i := 0; i < b.N; i++ {
575+ right.screen.Write([]byte("x\b"))
576+ d.paint(sess, ac, false)
577+ }
578+}
579+
433580 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
434581 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
435582 p1 := win.focusedPane()
@@ -454,6 +601,31 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
454601 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
455602 }
456603
604+func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
605+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
606+ p1 := win.focusedPane()
607+ p1.title = "one"
608+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
609+ p2.title = "two"
610+ p2.screen.Write([]byte("T"))
611+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
612+ win.tree.Focus = p2.id
613+ win.panes[p2.id] = p2
614+ var bars barCache
615+ var composed composedFrameCache
616+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
617+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
618+ p1.screen.ClearDamage()
619+ p2.screen.ClearDamage()
620+
621+ p1.title = "renamed"
622+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
623+
624+ require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
625+ require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
626+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
627+}
628+
457629 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
458630 tests := []struct {
459631 name string
@@ -524,6 +696,43 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
524696 }
525697 }
526698
699+func TestOverlayPaintBypassesComposedCache(t *testing.T) {
700+ p, release := newBlockingPTY(t)
701+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
702+ defer release()
703+ pane := sess.tabs[0].focusedPane()
704+ pane.screen.Write([]byte("live"))
705+
706+ d.paint(sess, ac, true)
707+ _ = mustOutputData(t, sends)
708+ require.True(t, ac.composed.valid)
709+ require.NotContains(t, frameText(ac.composed.frame), "Rename session")
710+
711+ d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
712+ shown := string(mustOutputData(t, sends))
713+ require.Contains(t, shown, "Rename session")
714+ require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
715+ require.NotContains(t, frameText(ac.composed.frame), "Rename session")
716+
717+ d.closePrompt(ac)
718+ d.paint(sess, ac, false)
719+ restored := string(mustOutputData(t, sends))
720+ require.NotContains(t, restored, "Rename session")
721+ require.True(t, ac.composed.valid)
722+ cached := frameText(ac.composed.frame)
723+ require.Contains(t, cached, "live")
724+ require.NotContains(t, cached, "Rename session")
725+}
726+
727+func frameText(frame renderer.Frame) string {
728+ var b strings.Builder
729+ for y := 0; y < frame.Height; y++ {
730+ b.WriteString(rowText(frame.Row(y)))
731+ b.WriteByte('\n')
732+ }
733+ return b.String()
734+}
735+
527736 func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
528737 tests := []struct {
529738 name string
@@ -26,6 +26,12 @@ import (
26 26
27 // --- test doubles -----------------------------------------------------------27 // --- test doubles -----------------------------------------------------------
28 28
29+type discardTransport struct{}
30+
31+func (discardTransport) Send(ports.Frame) error { return nil }
32+func (discardTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
33+func (discardTransport) Close() error { return nil }
34+
29 // stubClock returns timers whose channel never fires, so a scheduler under it35 // stubClock returns timers whose channel never fires, so a scheduler under it
30 // blocks in its debounce loop until the session context is cancelled. Used by36 // blocks in its debounce loop until the session context is cancelled. Used by
31 37
@@ -430,6 +436,147 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test
430 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})436 require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
431 }437 }
432 438
439+func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
440+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
441+ left := win.focusedPane()
442+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
443+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
444+ win.tree.Focus = right.id
445+ win.panes[right.id] = right
446+ left.screen.Write([]byte("L"))
447+ right.screen.Write([]byte("R"))
448+ var bars barCache
449+ var composed composedFrameCache
450+ sess := &session{id: "s", name: "work", tabs: []*tab{win}}
451+ composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, true, solveTabLayoutLocked(win), &bars, &composed)
452+ left.screen.ClearDamage()
453+ right.screen.ClearDamage()
454+ left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
455+ right.screen.Write([]byte("x"))
456+
457+ frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments()}, win, false, solveTabLayoutLocked(win), &bars, &composed)
458+
459+ require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
460+ require.Equal(t, 'x', frame.At(22, 1).Rune)
461+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1})
462+}
463+
464+func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
465+ win := newTab(nil, domain.Size{Cols: 20, Rows: 4})
466+ p := win.focusedPane()
467+ p.screen.Write([]byte("old"))
468+ var bars barCache
469+ var composed composedFrameCache
470+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
471+
472+ _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
473+ require.NotEmpty(t, damage)
474+ require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame")
475+
476+ p.screen.Write([]byte("new"))
477+ require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render")
478+
479+ _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
480+ require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming")
481+}
482+
483+func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
484+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
485+ p1 := win.focusedPane()
486+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
487+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
488+ win.tree.Focus = p2.id
489+ win.panes[p2.id] = p2
490+ p1.screen.Write([]byte("hidden"))
491+ p2.screen.Write([]byte("shown"))
492+ var bars barCache
493+ var composed composedFrameCache
494+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
495+
496+ _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
497+
498+ require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars")
499+ require.Empty(t, p2.screen.Damage())
500+}
501+
502+func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
503+ win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
504+ left := win.focusedPane()
505+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
506+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
507+ win.panes[right.id] = right
508+ left.screen.Write([]byte("L"))
509+ right.screen.Write([]byte("R"))
510+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
511+ var bars barCache
512+ var composed composedFrameCache
513+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(), theme: theme}
514+ win.tree.Focus = left.id
515+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
516+ left.screen.ClearDamage()
517+ right.screen.ClearDamage()
518+ leftWasFocused := composed.frame.At(0, 1).Style
519+ rightWasDimmed := composed.frame.At(21, 1).Style
520+
521+ win.tree.Focus = right.id
522+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
523+
524+ require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed")
525+ require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed")
526+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
527+}
528+
529+func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
530+ win := newTab(nil, domain.Size{Cols: 41, Rows: 5})
531+ left := win.focusedPane()
532+ right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5})
533+ win.panes[right.id] = right
534+ left.screen.Write([]byte("L"))
535+ right.screen.Write([]byte("R"))
536+ var bars barCache
537+ var composed composedFrameCache
538+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
539+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
540+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
541+ require.Equal(t, '│', composed.frame.At(20, 1).Rune)
542+ left.screen.ClearDamage()
543+ right.screen.ClearDamage()
544+
545+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
546+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
547+
548+ require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes")
549+ require.Equal(t, '─', frame.At(20, 3).Rune)
550+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
551+}
552+
553+func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
554+ win := newTab(nil, domain.Size{Cols: 81, Rows: 24})
555+ left := win.focusedPane()
556+ right := newPane("pane-2", nil, domain.Size{Cols: 40, Rows: 24})
557+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
558+ win.tree.Focus = right.id
559+ win.panes[right.id] = right
560+ left.screen.Write([]byte("left"))
561+ right.screen.Write([]byte("right"))
562+ sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0}
563+ ac := &attachedClient{tr: discardTransport{}, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 81, Rows: 26}}
564+ ac.initOverlays()
565+ sess.client = ac
566+ ac.setSession(sess)
567+ d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
568+ d.paint(sess, ac, true)
569+ left.screen.ClearDamage()
570+ right.screen.ClearDamage()
571+
572+ b.ReportAllocs()
573+ b.ResetTimer()
574+ for i := 0; i < b.N; i++ {
575+ right.screen.Write([]byte("x\b"))
576+ d.paint(sess, ac, false)
577+ }
578+}
579+
433 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {580 func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
434 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})581 win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
435 p1 := win.focusedPane()582 p1 := win.focusedPane()
@@ -454,6 +601,31 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
454 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")601 require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
455 }602 }
456 603
604+func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
605+ win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
606+ p1 := win.focusedPane()
607+ p1.title = "one"
608+ p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
609+ p2.title = "two"
610+ p2.screen.Write([]byte("T"))
611+ win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
612+ win.tree.Focus = p2.id
613+ win.panes[p2.id] = p2
614+ var bars barCache
615+ var composed composedFrameCache
616+ state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments()}
617+ composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
618+ p1.screen.ClearDamage()
619+ p2.screen.ClearDamage()
620+
621+ p1.title = "renamed"
622+ frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
623+
624+ require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
625+ require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
626+ require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
627+}
628+
457 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {629 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
458 tests := []struct {630 tests := []struct {
459 name string631 name string
@@ -524,6 +696,43 @@ func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
524 }696 }
525 }697 }
526 698
699+func TestOverlayPaintBypassesComposedCache(t *testing.T) {
700+ p, release := newBlockingPTY(t)
701+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
702+ defer release()
703+ pane := sess.tabs[0].focusedPane()
704+ pane.screen.Write([]byte("live"))
705+
706+ d.paint(sess, ac, true)
707+ _ = mustOutputData(t, sends)
708+ require.True(t, ac.composed.valid)
709+ require.NotContains(t, frameText(ac.composed.frame), "Rename session")
710+
711+ d.enterPrompt(sess, ac, " Rename session ", "work", func(string) error { return nil })
712+ shown := string(mustOutputData(t, sends))
713+ require.Contains(t, shown, "Rename session")
714+ require.False(t, ac.composed.valid, "overlay paint must not store the modal-mutated frame in the composed cache")
715+ require.NotContains(t, frameText(ac.composed.frame), "Rename session")
716+
717+ d.closePrompt(ac)
718+ d.paint(sess, ac, false)
719+ restored := string(mustOutputData(t, sends))
720+ require.NotContains(t, restored, "Rename session")
721+ require.True(t, ac.composed.valid)
722+ cached := frameText(ac.composed.frame)
723+ require.Contains(t, cached, "live")
724+ require.NotContains(t, cached, "Rename session")
725+}
726+
727+func frameText(frame renderer.Frame) string {
728+ var b strings.Builder
729+ for y := 0; y < frame.Height; y++ {
730+ b.WriteString(rowText(frame.Row(y)))
731+ b.WriteByte('\n')
732+ }
733+ return b.String()
734+}
735+
527 func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {736 func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
528 tests := []struct {737 tests := []struct {
529 name string738 name string
M internal/usecase/daemon/status_test.go +47 -0
@@ -12,6 +12,7 @@ import (
1212
1313 "github.com/bnema/vev/internal/domain"
1414 "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/layout"
1516 themeui "github.com/bnema/vev/internal/usecase/theme"
1617 "github.com/bnema/vev/pkg/renderer"
1718 )
@@ -21,6 +22,52 @@ import (
2122 // stubClock returns timers whose channel never fires, so a scheduler under it
2223 // blocks in its debounce loop until the session context is cancelled. Used by
2324
25+func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
26+ p, releasePTY := newBlockingPTY(t)
27+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
28+ defer releasePTY()
29+ d.ApplyConfig(domain.Config{Theme: domain.ThemeLight})
30+
31+ win := sess.activeTab()
32+ left := win.focusedPane()
33+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
34+ win.mu.Lock()
35+ win.size = domain.Size{Cols: 41, Rows: 4}
36+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
37+ win.tree.Focus = left.id
38+ win.panes[right.id] = right
39+ win.mu.Unlock()
40+ left.mu.Lock()
41+ left.screen.Resize(20, 4)
42+ left.screen.Write([]byte("L"))
43+ left.mu.Unlock()
44+ right.mu.Lock()
45+ right.screen.Resize(20, 4)
46+ right.screen.Write([]byte("R"))
47+ right.mu.Unlock()
48+
49+ d.paint(sess, ac, true)
50+ ac.sendMu.Lock()
51+ require.True(t, ac.composed.valid)
52+ lightDimmedPane := ac.composed.frame.At(21, 1).Style
53+ lightDivider := ac.composed.frame.At(20, 1).Style
54+ ac.sendMu.Unlock()
55+ left.mu.Lock()
56+ left.screen.ClearDamage()
57+ left.mu.Unlock()
58+ right.mu.Lock()
59+ right.screen.ClearDamage()
60+ right.mu.Unlock()
61+
62+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
63+
64+ ac.sendMu.Lock()
65+ defer ac.sendMu.Unlock()
66+ require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache")
67+ require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
68+ require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
69+}
70+
2471 func TestStatusCompositionGolden(t *testing.T) {
2572 p1, releasePTY1 := newBlockingPTY(t)
2673 p2, releasePTY2 := newBlockingPTY(t)
@@ -12,6 +12,7 @@ import (
12 12
13 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15+ "github.com/bnema/vev/internal/usecase/layout"
15 themeui "github.com/bnema/vev/internal/usecase/theme"16 themeui "github.com/bnema/vev/internal/usecase/theme"
16 "github.com/bnema/vev/pkg/renderer"17 "github.com/bnema/vev/pkg/renderer"
17 )18 )
@@ -21,6 +22,52 @@ import (
21 // stubClock returns timers whose channel never fires, so a scheduler under it22 // stubClock returns timers whose channel never fires, so a scheduler under it
22 // blocks in its debounce loop until the session context is cancelled. Used by23 // blocks in its debounce loop until the session context is cancelled. Used by
23 24
25+func TestApplyConfigThemeRepaintInvalidatesComposedFrameCache(t *testing.T) {
26+ p, releasePTY := newBlockingPTY(t)
27+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
28+ defer releasePTY()
29+ d.ApplyConfig(domain.Config{Theme: domain.ThemeLight})
30+
31+ win := sess.activeTab()
32+ left := win.focusedPane()
33+ right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
34+ win.mu.Lock()
35+ win.size = domain.Size{Cols: 41, Rows: 4}
36+ win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
37+ win.tree.Focus = left.id
38+ win.panes[right.id] = right
39+ win.mu.Unlock()
40+ left.mu.Lock()
41+ left.screen.Resize(20, 4)
42+ left.screen.Write([]byte("L"))
43+ left.mu.Unlock()
44+ right.mu.Lock()
45+ right.screen.Resize(20, 4)
46+ right.screen.Write([]byte("R"))
47+ right.mu.Unlock()
48+
49+ d.paint(sess, ac, true)
50+ ac.sendMu.Lock()
51+ require.True(t, ac.composed.valid)
52+ lightDimmedPane := ac.composed.frame.At(21, 1).Style
53+ lightDivider := ac.composed.frame.At(20, 1).Style
54+ ac.sendMu.Unlock()
55+ left.mu.Lock()
56+ left.screen.ClearDamage()
57+ left.mu.Unlock()
58+ right.mu.Lock()
59+ right.screen.ClearDamage()
60+ right.mu.Unlock()
61+
62+ d.ApplyConfig(domain.Config{Theme: domain.ThemeDark})
63+
64+ ac.sendMu.Lock()
65+ defer ac.sendMu.Unlock()
66+ require.True(t, ac.composed.valid, "reset=false config repaint should rebuild the composed cache")
67+ require.NotEqual(t, lightDimmedPane, ac.composed.frame.At(21, 1).Style, "dimmed pane style must not stay cached across theme reapply")
68+ require.NotEqual(t, lightDivider, ac.composed.frame.At(20, 1).Style, "divider style must not stay cached across theme reapply")
69+}
70+
24 func TestStatusCompositionGolden(t *testing.T) {71 func TestStatusCompositionGolden(t *testing.T) {
25 p1, releasePTY1 := newBlockingPTY(t)72 p1, releasePTY1 := newBlockingPTY(t)
26 p2, releasePTY2 := newBlockingPTY(t)73 p2, releasePTY2 := newBlockingPTY(t)
M internal/usecase/daemon/theme_resolve.go +6 -0
@@ -3,6 +3,11 @@ package daemon
33 import "github.com/bnema/vev/internal/usecase/theme"
44
55 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
6+ if ac != nil {
7+ ac.sendMu.Lock()
8+ defer ac.sendMu.Unlock()
9+ }
10+
611 sess.mu.Lock()
712 if ac != nil {
813 if sess.client != ac {
@@ -18,6 +23,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
1823
1924 if ac != nil {
2025 ac.setTheme(t)
26+ ac.composed.invalidate()
2127 }
2228 for _, tb := range tabs {
2329 tb.mu.Lock()
@@ -3,6 +3,11 @@ package daemon
3 import "github.com/bnema/vev/internal/usecase/theme"3 import "github.com/bnema/vev/internal/usecase/theme"
4 4
5 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {5 func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme, clearUnknownScheme bool) bool {
6+ if ac != nil {
7+ ac.sendMu.Lock()
8+ defer ac.sendMu.Unlock()
9+ }
10+
6 sess.mu.Lock()11 sess.mu.Lock()
7 if ac != nil {12 if ac != nil {
8 if sess.client != ac {13 if sess.client != ac {
@@ -18,6 +23,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme
18 23
19 if ac != nil {24 if ac != nil {
20 ac.setTheme(t)25 ac.setTheme(t)
26+ ac.composed.invalidate()
21 }27 }
22 for _, tb := range tabs {28 for _, tb := range tabs {
23 tb.mu.Lock()29 tb.mu.Lock()

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 2

Tags 13

↑↓ moveenter openesc close