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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 49 | 49 | nextStateNum uint64 | |
| 50 | 50 | echoAck atomic.Uint64 | |
| 51 | 51 | 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 | |
| 54 | 55 | size domain.Size | |
| 55 | 56 | keys *keys.Router | |
| 56 | 57 | sess Guarded[*session] | |
| @@ -49,8 +49,9 @@ type attachedClient struct { | |||||
| 49 | nextStateNum uint64 | 49 | nextStateNum uint64 | ||
| 50 | echoAck atomic.Uint64 | 50 | echoAck atomic.Uint64 | ||
| 51 | outputAck atomic.Uint64 | 51 | outputAck atomic.Uint64 | ||
| 52 | - | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag | 52 | + | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag |
| 53 | - | bars barCache // only touched while sendMu is held | 53 | + | bars barCache // only touched while sendMu is held |
| 54 | + | composed composedFrameCache // only touched while sendMu is held | |||
| 54 | size domain.Size | 55 | size domain.Size | ||
| 55 | keys *keys.Router | 56 | 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) { | |||
| 332 | 332 | return | |
| 333 | 333 | } | |
| 334 | 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 | 337 | ac.rend.Reset() | |
| 337 | 338 | ac.bars.Reset() | |
| 339 | + | ac.composed.invalidate() | |
| 338 | 340 | } | |
| 339 | 341 | if reset || overlays.pickerActive || overlays.paletteActive || overlays.promptActive { | |
| 340 | 342 | 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 | 351 | if overlays.copyActive { | |
| 344 | 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 | 388 | if err == nil { | |
| 381 | 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 | 391 | tb.mu.Unlock() | |
| 389 | 392 | ||
| 390 | 393 | var serr error | |
| @@ -500,6 +503,16 @@ func (c *barCache) Reset() { | |||
| 500 | 503 | 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 | 516 | func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) { | |
| 504 | 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 | 526 | if len(caches) > 0 { | |
| 514 | 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 | 541 | p := tb.focusedPane() | |
| 517 | 542 | styles := newThemeStyles(bars.theme) | |
| 518 | 543 | if p == nil { | |
| @@ -528,15 +553,34 @@ func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap | |||
| 528 | 553 | if tb.size.Valid() { | |
| 529 | 554 | 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 | 574 | topBar := frame.Row(0) | |
| 533 | 575 | 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) | |
| 538 | 577 | bottomBar := frame.Row(screenRows + 1) | |
| 539 | 578 | drawStatusBarState(bottomBar, bars, styles) | |
| 579 | + | if composed != nil { | |
| 580 | + | composed.frame = frame | |
| 581 | + | composed.valid = true | |
| 582 | + | composed.layoutSnap = layoutSnap | |
| 583 | + | } | |
| 540 | 584 | if full { | |
| 541 | 585 | if cache != nil { | |
| 542 | 586 | cache.capture(topBar, bottomBar) | |
| @@ -562,20 +606,30 @@ func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.F | |||
| 562 | 606 | ||
| 563 | 607 | func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) { | |
| 564 | 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 | 619 | 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 | |
| 567 | 621 | if !ok { | |
| 568 | - | placements, ok = layout.Solve(root, area) | |
| 622 | + | placements, ok = layout.Solve(root, contentArea) | |
| 569 | 623 | } | |
| 570 | 624 | var fallback *pane | |
| 571 | 625 | if !ok { | |
| 572 | 626 | fallback = tb.focusedPane() | |
| 573 | 627 | if fallback == nil { | |
| 574 | - | return frame, nil | |
| 628 | + | 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 | 633 | drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme)) | |
| 580 | 634 | } | |
| 581 | 635 | var damage []renderer.Damage | |
| @@ -588,20 +642,62 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l | |||
| 588 | 642 | continue | |
| 589 | 643 | } | |
| 590 | 644 | focused := tb.tree.Focus == pl.ID | |
| 645 | + | pl = offsetPlacement(pl, area.X, area.Y) | |
| 591 | 646 | if pl.TitleBar.Height > 0 { | |
| 592 | 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 | 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 | 661 | continue | |
| 596 | 662 | } | |
| 597 | 663 | 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() | |
| 601 | 676 | } | |
| 602 | 677 | p.mu.Unlock() | |
| 603 | 678 | } | |
| 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 | + | } | |
| 605 | 701 | } | |
| 606 | 702 | ||
| 607 | 703 | 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 | return | 332 | 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 = false | 342 | 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 error | 393 | var serr error | ||
| @@ -500,6 +503,16 @@ func (c *barCache) Reset() { | |||||
| 500 | c.bottom = nil | 503 | 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.Rows | 554 | 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.Root | 619 | 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 |
| 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 *pane | 624 | 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, nil | 628 | + | 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.Damage | 635 | var damage []renderer.Damage | ||
| @@ -588,20 +642,62 @@ func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, l | |||||
| 588 | continue | 642 | continue | ||
| 589 | } | 643 | } | ||
| 590 | focused := tb.tree.Focus == pl.ID | 644 | 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 | continue | 661 | 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, 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 | + | } | |||
| 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 ( | |||
| 26 | 26 | ||
| 27 | 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 | 35 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 30 | 36 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 31 | 37 | ||
| @@ -430,6 +436,147 @@ func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *test | |||
| 430 | 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 | 580 | func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |
| 434 | 581 | win := newTab(nil, domain.Size{Cols: 20, Rows: 5}) | |
| 435 | 582 | p1 := win.focusedPane() | |
| @@ -454,6 +601,31 @@ func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) { | |||
| 454 | 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 | 629 | func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) { | |
| 458 | 630 | tests := []struct { | |
| 459 | 631 | 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 | 736 | func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) { | |
| 528 | 737 | tests := []struct { | |
| 529 | 738 | 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 it | 35 | // 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 by | 36 | // 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 string | 631 | 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 string | 738 | name string | ||
M internal/usecase/daemon/status_test.go +47 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 15 | 16 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 16 | 17 | "github.com/bnema/vev/pkg/renderer" | |
| 17 | 18 | ) | |
| @@ -21,6 +22,52 @@ import ( | |||
| 21 | 22 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 22 | 23 | // 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 | 71 | func TestStatusCompositionGolden(t *testing.T) { | |
| 25 | 72 | p1, releasePTY1 := newBlockingPTY(t) | |
| 26 | 73 | 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 it | 22 | // 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 by | 23 | // 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 | |||
| 3 | 3 | import "github.com/bnema/vev/internal/usecase/theme" | |
| 4 | 4 | ||
| 5 | 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 | 11 | sess.mu.Lock() | |
| 7 | 12 | if ac != nil { | |
| 8 | 13 | if sess.client != ac { | |
| @@ -18,6 +23,7 @@ func (d *Daemon) applyHostTheme(sess *session, ac *attachedClient, t theme.Theme | |||
| 18 | 23 | ||
| 19 | 24 | if ac != nil { | |
| 20 | 25 | ac.setTheme(t) | |
| 26 | + | ac.composed.invalidate() | |
| 21 | 27 | } | |
| 22 | 28 | for _, tb := range tabs { | |
| 23 | 29 | 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() | ||