feat(daemon): establish render capture ownership
- commit
- eb37c8312302fd2a738155fa5fe30918f746f165
- author
- brice · · 2026-07-12 05:00 UTC
- parent
- c405747
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +425 -23
M internal/usecase/daemon/floating_render.go +13 -7
| @@ -109,16 +109,22 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||
| 109 | 109 | // Committed popup geometry is content-relative; translate it exactly once | |
| 110 | 110 | // while holding the same lock that protects the committed state and frame. | |
| 111 | 111 | geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y) | |
| 112 | - | title := p.displayTitleLocked() | |
| 113 | - | titleGeneration := p.title.generation | |
| 114 | - | screenDamage := p.screen.Damage() | |
| 115 | - | blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme) | |
| 112 | + | previousCapture := capturedPaneRenderState{} | |
| 113 | + | if cache != nil { | |
| 114 | + | previousCapture = cache.floatingCaptured | |
| 115 | + | } | |
| 116 | + | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture) | |
| 117 | + | if cache != nil { | |
| 118 | + | cache.floatingCaptured = captured | |
| 119 | + | } | |
| 120 | + | p.mu.Unlock() | |
| 121 | + | title := captured.title | |
| 122 | + | titleGeneration := captured.titleGeneration | |
| 123 | + | blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme) | |
| 116 | 124 | damage := append([]renderer.Damage(nil), baseDamage...) | |
| 117 | - | for _, d := range screenDamage { | |
| 125 | + | for _, d := range captured.damage { | |
| 118 | 126 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | |
| 119 | 127 | } | |
| 120 | - | p.screen.ClearDamage() | |
| 121 | - | p.mu.Unlock() | |
| 122 | 128 | ||
| 123 | 129 | // The committed geometry, rather than the requested config geometry, is | |
| 124 | 130 | // what was actually rendered. Track it so movement (including a same-size | |
| @@ -109,16 +109,22 @@ func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p * | |||||
| 109 | // Committed popup geometry is content-relative; translate it exactly once | 109 | // Committed popup geometry is content-relative; translate it exactly once | ||
| 110 | // while holding the same lock that protects the committed state and frame. | 110 | // while holding the same lock that protects the committed state and frame. | ||
| 111 | geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y) | 111 | geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y) | ||
| 112 | - | title := p.displayTitleLocked() | 112 | + | previousCapture := capturedPaneRenderState{} |
| 113 | - | titleGeneration := p.title.generation | 113 | + | if cache != nil { |
| 114 | - | screenDamage := p.screen.Damage() | 114 | + | previousCapture = cache.floatingCaptured |
| 115 | - | blitPaneFrame(frame, geometry.Inner, p.screen.Frame, false, theme) | 115 | + | } |
| 116 | + | captured := capturePaneRenderStateLockedInto(p, geometry.Inner, damageCaptureConsume, previousCapture) | |||
| 117 | + | if cache != nil { | |||
| 118 | + | cache.floatingCaptured = captured | |||
| 119 | + | } | |||
| 120 | + | p.mu.Unlock() | |||
| 121 | + | title := captured.title | |||
| 122 | + | titleGeneration := captured.titleGeneration | |||
| 123 | + | blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme) | |||
| 116 | damage := append([]renderer.Damage(nil), baseDamage...) | 124 | damage := append([]renderer.Damage(nil), baseDamage...) | ||
| 117 | - | for _, d := range screenDamage { | 125 | + | for _, d := range captured.damage { |
| 118 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | 126 | damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...) | ||
| 119 | } | 127 | } | ||
| 120 | - | p.screen.ClearDamage() | |||
| 121 | - | p.mu.Unlock() | |||
| 122 | 128 | ||||
| 123 | // The committed geometry, rather than the requested config geometry, is | 129 | // The committed geometry, rather than the requested config geometry, is | ||
| 124 | // what was actually rendered. Track it so movement (including a same-size | 130 | // what was actually rendered. Track it so movement (including a same-size | ||
M internal/usecase/daemon/render.go +36 -8
| @@ -292,6 +292,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||
| 292 | 292 | ac.sendMu.Unlock() | |
| 293 | 293 | return | |
| 294 | 294 | } | |
| 295 | + | // Capacity is checked before any destructive capture. The coordinator is | |
| 296 | + | // the normal gate, but this ownership check also protects direct resize and | |
| 297 | + | // test paint paths from consuming damage that cannot be emitted. | |
| 298 | + | if ac.output != nil && ac.output.atCapacity() { | |
| 299 | + | ac.sendMu.Unlock() | |
| 300 | + | return | |
| 301 | + | } | |
| 295 | 302 | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | |
| 296 | 303 | ac.sendMu.Unlock() | |
| 297 | 304 | return | |
| @@ -598,6 +605,7 @@ type composedFrameCache struct { | |||
| 598 | 605 | floatingGeneration uint64 | |
| 599 | 606 | floatingGeometry floatingGeometry | |
| 600 | 607 | floatingTitleGeneration uint64 | |
| 608 | + | floatingCaptured capturedPaneRenderState | |
| 601 | 609 | } | |
| 602 | 610 | ||
| 603 | 611 | func (c *composedFrameCache) invalidate() { | |
| @@ -762,18 +770,42 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||
| 762 | 770 | titleGenerations[pl.ID] = generation | |
| 763 | 771 | } | |
| 764 | 772 | } | |
| 773 | + | mode := damageCapturePreview | |
| 774 | + | if consumeDamage { | |
| 775 | + | mode = damageCaptureConsume | |
| 776 | + | } | |
| 765 | 777 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | |
| 766 | 778 | if consumeDamage { | |
| 767 | 779 | p.mu.Lock() | |
| 768 | - | p.screen.ClearDamage() | |
| 780 | + | _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode) | |
| 769 | 781 | p.mu.Unlock() | |
| 770 | 782 | } | |
| 771 | 783 | continue | |
| 772 | 784 | } | |
| 773 | - | p.mu.Lock() | |
| 774 | - | paneDamage := p.screen.Damage() | |
| 785 | + | var paneFrame renderer.Frame | |
| 786 | + | var paneDamage []renderer.Damage | |
| 787 | + | if consumeDamage { | |
| 788 | + | p.mu.Lock() | |
| 789 | + | captured := capturePaneRenderStateLocked(p, pl.Content, mode) | |
| 790 | + | p.mu.Unlock() | |
| 791 | + | paneFrame, paneDamage = captured.frame, captured.damage | |
| 792 | + | } else { | |
| 793 | + | p.mu.Lock() | |
| 794 | + | paneDamage = p.screen.Damage() | |
| 795 | + | if !cacheValid || len(paneDamage) > 0 { | |
| 796 | + | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | |
| 797 | + | } | |
| 798 | + | for _, d := range paneDamage { | |
| 799 | + | localContent := pl.Content | |
| 800 | + | localContent.X -= area.X | |
| 801 | + | localContent.Y -= area.Y | |
| 802 | + | damage = append(damage, translatePaneDamage(d, localContent, contentArea)...) | |
| 803 | + | } | |
| 804 | + | p.mu.Unlock() | |
| 805 | + | continue | |
| 806 | + | } | |
| 775 | 807 | if !cacheValid || len(paneDamage) > 0 { | |
| 776 | - | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | |
| 808 | + | blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme) | |
| 777 | 809 | } | |
| 778 | 810 | for _, d := range paneDamage { | |
| 779 | 811 | localContent := pl.Content | |
| @@ -781,10 +813,6 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||
| 781 | 813 | localContent.Y -= area.Y | |
| 782 | 814 | damage = append(damage, translatePaneDamage(d, localContent, contentArea)...) | |
| 783 | 815 | } | |
| 784 | - | if consumeDamage { | |
| 785 | - | p.screen.ClearDamage() | |
| 786 | - | } | |
| 787 | - | p.mu.Unlock() | |
| 788 | 816 | } | |
| 789 | 817 | return damage | |
| 790 | 818 | } | |
| @@ -292,6 +292,13 @@ func (d *Daemon) paintForResizeGeneration(sess *session, ac *attachedClient, res | |||||
| 292 | ac.sendMu.Unlock() | 292 | ac.sendMu.Unlock() | ||
| 293 | return | 293 | return | ||
| 294 | } | 294 | } | ||
| 295 | + | // Capacity is checked before any destructive capture. The coordinator is | |||
| 296 | + | // the normal gate, but this ownership check also protects direct resize and | |||
| 297 | + | // test paint paths from consuming damage that cannot be emitted. | |||
| 298 | + | if ac.output != nil && ac.output.atCapacity() { | |||
| 299 | + | ac.sendMu.Unlock() | |||
| 300 | + | return | |||
| 301 | + | } | |||
| 295 | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | 302 | if resizeGeneration != 0 && (!ac.resizePaintPending || ac.resizePaintGeneration != resizeGeneration) { | ||
| 296 | ac.sendMu.Unlock() | 303 | ac.sendMu.Unlock() | ||
| 297 | return | 304 | return | ||
| @@ -598,6 +605,7 @@ type composedFrameCache struct { | |||||
| 598 | floatingGeneration uint64 | 605 | floatingGeneration uint64 | ||
| 599 | floatingGeometry floatingGeometry | 606 | floatingGeometry floatingGeometry | ||
| 600 | floatingTitleGeneration uint64 | 607 | floatingTitleGeneration uint64 | ||
| 608 | + | floatingCaptured capturedPaneRenderState | |||
| 601 | } | 609 | } | ||
| 602 | 610 | ||||
| 603 | func (c *composedFrameCache) invalidate() { | 611 | func (c *composedFrameCache) invalidate() { | ||
| @@ -762,18 +770,42 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||||
| 762 | titleGenerations[pl.ID] = generation | 770 | titleGenerations[pl.ID] = generation | ||
| 763 | } | 771 | } | ||
| 764 | } | 772 | } | ||
| 773 | + | mode := damageCapturePreview | |||
| 774 | + | if consumeDamage { | |||
| 775 | + | mode = damageCaptureConsume | |||
| 776 | + | } | |||
| 765 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | 777 | if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 { | ||
| 766 | if consumeDamage { | 778 | if consumeDamage { | ||
| 767 | p.mu.Lock() | 779 | p.mu.Lock() | ||
| 768 | - | p.screen.ClearDamage() | 780 | + | _ = capturePaneRenderStateLocked(p, domain.Rect{}, mode) |
| 769 | p.mu.Unlock() | 781 | p.mu.Unlock() | ||
| 770 | } | 782 | } | ||
| 771 | continue | 783 | continue | ||
| 772 | } | 784 | } | ||
| 773 | - | p.mu.Lock() | 785 | + | var paneFrame renderer.Frame |
| 774 | - | paneDamage := p.screen.Damage() | 786 | + | var paneDamage []renderer.Damage |
| 787 | + | if consumeDamage { | |||
| 788 | + | p.mu.Lock() | |||
| 789 | + | captured := capturePaneRenderStateLocked(p, pl.Content, mode) | |||
| 790 | + | p.mu.Unlock() | |||
| 791 | + | paneFrame, paneDamage = captured.frame, captured.damage | |||
| 792 | + | } else { | |||
| 793 | + | p.mu.Lock() | |||
| 794 | + | paneDamage = p.screen.Damage() | |||
| 795 | + | if !cacheValid || len(paneDamage) > 0 { | |||
| 796 | + | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | |||
| 797 | + | } | |||
| 798 | + | for _, d := range paneDamage { | |||
| 799 | + | localContent := pl.Content | |||
| 800 | + | localContent.X -= area.X | |||
| 801 | + | localContent.Y -= area.Y | |||
| 802 | + | damage = append(damage, translatePaneDamage(d, localContent, contentArea)...) | |||
| 803 | + | } | |||
| 804 | + | p.mu.Unlock() | |||
| 805 | + | continue | |||
| 806 | + | } | |||
| 775 | if !cacheValid || len(paneDamage) > 0 { | 807 | if !cacheValid || len(paneDamage) > 0 { | ||
| 776 | - | blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme) | 808 | + | blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme) |
| 777 | } | 809 | } | ||
| 778 | for _, d := range paneDamage { | 810 | for _, d := range paneDamage { | ||
| 779 | localContent := pl.Content | 811 | localContent := pl.Content | ||
| @@ -781,10 +813,6 @@ func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area do | |||||
| 781 | localContent.Y -= area.Y | 813 | localContent.Y -= area.Y | ||
| 782 | damage = append(damage, translatePaneDamage(d, localContent, contentArea)...) | 814 | damage = append(damage, translatePaneDamage(d, localContent, contentArea)...) | ||
| 783 | } | 815 | } | ||
| 784 | - | if consumeDamage { | |||
| 785 | - | p.screen.ClearDamage() | |||
| 786 | - | } | |||
| 787 | - | p.mu.Unlock() | |||
| 788 | } | 816 | } | ||
| 789 | return damage | 817 | return damage | ||
| 790 | } | 818 | } | ||
A internal/usecase/daemon/render_capture.go +215 -0
| @@ -0,0 +1,215 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |
| 6 | + | "github.com/bnema/vev/internal/usecase/picker" | |
| 7 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // damageCaptureMode makes damage ownership explicit. Primary active-session | |
| 12 | + | // capture is destructive; picker preview observes the same VT state without | |
| 13 | + | // acknowledging it. | |
| 14 | + | type damageCaptureMode uint8 | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | damageCapturePreview damageCaptureMode = iota | |
| 18 | + | damageCaptureConsume | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // capturedRenderState is the immutable hand-off from authoritative daemon | |
| 22 | + | // state to composition. Every field is a value or an owned bounded copy; the | |
| 23 | + | // composer must not retain or consult session, tab, pane, or overlay owners. | |
| 24 | + | type capturedRenderState struct { | |
| 25 | + | attachment *attachedClient // identity only; never dereferenced by composition | |
| 26 | + | attachmentEpoch uint64 | |
| 27 | + | reset bool | |
| 28 | + | layout capturedTabLayout | |
| 29 | + | panes []capturedPaneRenderState | |
| 30 | + | floating capturedFloatingRenderState | |
| 31 | + | bars barState | |
| 32 | + | theme themeui.Theme | |
| 33 | + | overlays capturedOverlayRenderState | |
| 34 | + | preview picker.Preview | |
| 35 | + | cursor capturedCursorInputs | |
| 36 | + | tabGeneration uint64 | |
| 37 | + | floatingGeneration uint64 | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | type capturedTabLayout struct { | |
| 41 | + | area domain.Rect | |
| 42 | + | focus layout.PaneID | |
| 43 | + | placements []layout.Placement | |
| 44 | + | fingerprint string | |
| 45 | + | valid bool | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | type capturedPaneRenderState struct { | |
| 49 | + | id layout.PaneID | |
| 50 | + | frame renderer.Frame | |
| 51 | + | rawDamage []renderer.Damage | |
| 52 | + | damage []renderer.Damage | |
| 53 | + | damageGeneration uint64 | |
| 54 | + | title string | |
| 55 | + | titleGeneration uint64 | |
| 56 | + | placement layout.Placement | |
| 57 | + | focused bool | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | type capturedFloatingRenderState struct { | |
| 61 | + | visible bool | |
| 62 | + | pane capturedPaneRenderState | |
| 63 | + | geometry floatingGeometry | |
| 64 | + | title string | |
| 65 | + | generation uint64 | |
| 66 | + | titleGeneration uint64 | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | type capturedOverlayRenderState struct { | |
| 70 | + | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |
| 71 | + | copyTarget domain.Rect | |
| 72 | + | copyFeedback string | |
| 73 | + | paletteGuidance string | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | type capturedCursorInputs struct { | |
| 77 | + | row, col, style int | |
| 78 | + | hasStyle, visible, renderable, hiddenByOverlay bool | |
| 79 | + | content domain.Rect | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // capturePaneRenderStateLocked copies only the visible rectangle required by | |
| 83 | + | // composition. It never copies scrollback/history and never lets the mutable | |
| 84 | + | // VT frame's Cells or row-offset slices escape pane.mu. | |
| 85 | + | func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptureMode) capturedPaneRenderState { | |
| 86 | + | return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{}) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState { | |
| 90 | + | out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation | |
| 91 | + | damage := p.screen.CaptureDamage() | |
| 92 | + | out.damageGeneration = damage.Generation | |
| 93 | + | out.rawDamage = append(out.rawDamage[:0], damage.Damage...) | |
| 94 | + | ||
| 95 | + | width := min(max(visible.Width, 0), p.screen.Frame.Width) | |
| 96 | + | height := min(max(visible.Height, 0), p.screen.Frame.Height) | |
| 97 | + | if out.frame.Width != width || out.frame.Height != height { | |
| 98 | + | out.frame = renderer.NewFrame(width, height) | |
| 99 | + | } | |
| 100 | + | for y := range height { | |
| 101 | + | copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width]) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | |
| 105 | + | out.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 106 | + | } else { | |
| 107 | + | out.damage = append(out.damage[:0], damage.Damage...) | |
| 108 | + | } | |
| 109 | + | if mode == damageCaptureConsume { | |
| 110 | + | if !p.screen.AcknowledgeDamage(damage.Generation) { | |
| 111 | + | out.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | return out | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func uncertainDamage(damage []renderer.Damage, width, height int) bool { | |
| 118 | + | for _, d := range damage { | |
| 119 | + | if d.Kind == renderer.DamageFullRedraw { | |
| 120 | + | if d != renderer.FullRedraw() { | |
| 121 | + | return true | |
| 122 | + | } | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | if d.Kind < renderer.DamageText || d.Kind > renderer.DamageScrollUp || d.X < 0 || d.Y < 0 || d.Width <= 0 || d.Height <= 0 || d.X+d.Width > width || d.Y+d.Height > height { | |
| 126 | + | return true | |
| 127 | + | } | |
| 128 | + | if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) { | |
| 129 | + | return true | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return false | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // captureRenderState is the ownership boundary for a render transaction. | |
| 136 | + | // Callers hold attachment sendMu; this function then follows session -> tab -> | |
| 137 | + | // pane lock order. ACK-blocked primary capture returns before touching VT | |
| 138 | + | // damage. Preview mode is always non-destructive. | |
| 139 | + | func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) { | |
| 140 | + | if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) { | |
| 141 | + | return nil, false | |
| 142 | + | } | |
| 143 | + | sess.mu.Lock() | |
| 144 | + | if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) { | |
| 145 | + | sess.mu.Unlock() | |
| 146 | + | return nil, false | |
| 147 | + | } | |
| 148 | + | tb := sess.tabs[sess.active] | |
| 149 | + | epoch := ac.coordinatorEpoch.Load() | |
| 150 | + | sess.mu.Unlock() | |
| 151 | + | ||
| 152 | + | bars.status.tabs = append([]statusTab(nil), bars.status.tabs...) | |
| 153 | + | bars.mru = append([]recentSession(nil), bars.mru...) | |
| 154 | + | bars.rankedRecent = append([]rankedRecent(nil), bars.rankedRecent...) | |
| 155 | + | preview = clonePickerPreview(preview) | |
| 156 | + | ||
| 157 | + | tb.mu.Lock() | |
| 158 | + | defer tb.mu.Unlock() | |
| 159 | + | layoutSnap := solveTabLayoutLocked(tb) | |
| 160 | + | state := &capturedRenderState{ | |
| 161 | + | attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme, | |
| 162 | + | overlays: overlays, preview: preview, | |
| 163 | + | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: append([]layout.Placement(nil), layoutSnap.placements...), fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| 164 | + | floatingGeneration: tb.floating.generation, | |
| 165 | + | } | |
| 166 | + | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | |
| 167 | + | for _, placement := range layoutSnap.placements { | |
| 168 | + | p := tb.panes[placement.ID] | |
| 169 | + | if p == nil { | |
| 170 | + | continue | |
| 171 | + | } | |
| 172 | + | visible := placement.Content | |
| 173 | + | if placement.Collapsed { | |
| 174 | + | visible = domain.Rect{} | |
| 175 | + | } | |
| 176 | + | p.mu.Lock() | |
| 177 | + | captured := capturePaneRenderStateLocked(p, visible, mode) | |
| 178 | + | captured.placement = placement | |
| 179 | + | captured.focused = placement.ID == layoutSnap.focus | |
| 180 | + | if captured.focused { | |
| 181 | + | state.cursor = captureCursorInputsLocked(p, placement.Content, overlays) | |
| 182 | + | } | |
| 183 | + | p.mu.Unlock() | |
| 184 | + | translated := make([]renderer.Damage, 0, len(captured.damage)) | |
| 185 | + | for _, damage := range captured.damage { | |
| 186 | + | translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) | |
| 187 | + | } | |
| 188 | + | captured.damage = translated | |
| 189 | + | state.panes = append(state.panes, captured) | |
| 190 | + | } | |
| 191 | + | if tb.floating.state == floatingVisible && tb.floating.pane != nil { | |
| 192 | + | p := tb.floating.pane | |
| 193 | + | p.mu.Lock() | |
| 194 | + | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, domain.FloatingConfig{Width: 100, Height: 100})) | |
| 195 | + | captured := capturePaneRenderStateLocked(p, geometry.Inner, mode) | |
| 196 | + | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |
| 197 | + | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |
| 198 | + | p.mu.Unlock() | |
| 199 | + | } | |
| 200 | + | return state, true | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | |
| 204 | + | style, hasStyle := p.screen.CursorStyle() | |
| 205 | + | hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |
| 206 | + | return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content} | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func clonePickerPreview(in picker.Preview) picker.Preview { | |
| 210 | + | out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))} | |
| 211 | + | for i := range in.Rows { | |
| 212 | + | out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...) | |
| 213 | + | } | |
| 214 | + | return out | |
| 215 | + | } | |
| @@ -0,0 +1,215 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/internal/usecase/layout" | |||
| 6 | + | "github.com/bnema/vev/internal/usecase/picker" | |||
| 7 | + | themeui "github.com/bnema/vev/internal/usecase/theme" | |||
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // damageCaptureMode makes damage ownership explicit. Primary active-session | |||
| 12 | + | // capture is destructive; picker preview observes the same VT state without | |||
| 13 | + | // acknowledging it. | |||
| 14 | + | type damageCaptureMode uint8 | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | damageCapturePreview damageCaptureMode = iota | |||
| 18 | + | damageCaptureConsume | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // capturedRenderState is the immutable hand-off from authoritative daemon | |||
| 22 | + | // state to composition. Every field is a value or an owned bounded copy; the | |||
| 23 | + | // composer must not retain or consult session, tab, pane, or overlay owners. | |||
| 24 | + | type capturedRenderState struct { | |||
| 25 | + | attachment *attachedClient // identity only; never dereferenced by composition | |||
| 26 | + | attachmentEpoch uint64 | |||
| 27 | + | reset bool | |||
| 28 | + | layout capturedTabLayout | |||
| 29 | + | panes []capturedPaneRenderState | |||
| 30 | + | floating capturedFloatingRenderState | |||
| 31 | + | bars barState | |||
| 32 | + | theme themeui.Theme | |||
| 33 | + | overlays capturedOverlayRenderState | |||
| 34 | + | preview picker.Preview | |||
| 35 | + | cursor capturedCursorInputs | |||
| 36 | + | tabGeneration uint64 | |||
| 37 | + | floatingGeneration uint64 | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | type capturedTabLayout struct { | |||
| 41 | + | area domain.Rect | |||
| 42 | + | focus layout.PaneID | |||
| 43 | + | placements []layout.Placement | |||
| 44 | + | fingerprint string | |||
| 45 | + | valid bool | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | type capturedPaneRenderState struct { | |||
| 49 | + | id layout.PaneID | |||
| 50 | + | frame renderer.Frame | |||
| 51 | + | rawDamage []renderer.Damage | |||
| 52 | + | damage []renderer.Damage | |||
| 53 | + | damageGeneration uint64 | |||
| 54 | + | title string | |||
| 55 | + | titleGeneration uint64 | |||
| 56 | + | placement layout.Placement | |||
| 57 | + | focused bool | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | type capturedFloatingRenderState struct { | |||
| 61 | + | visible bool | |||
| 62 | + | pane capturedPaneRenderState | |||
| 63 | + | geometry floatingGeometry | |||
| 64 | + | title string | |||
| 65 | + | generation uint64 | |||
| 66 | + | titleGeneration uint64 | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | type capturedOverlayRenderState struct { | |||
| 70 | + | copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool | |||
| 71 | + | copyTarget domain.Rect | |||
| 72 | + | copyFeedback string | |||
| 73 | + | paletteGuidance string | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | type capturedCursorInputs struct { | |||
| 77 | + | row, col, style int | |||
| 78 | + | hasStyle, visible, renderable, hiddenByOverlay bool | |||
| 79 | + | content domain.Rect | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // capturePaneRenderStateLocked copies only the visible rectangle required by | |||
| 83 | + | // composition. It never copies scrollback/history and never lets the mutable | |||
| 84 | + | // VT frame's Cells or row-offset slices escape pane.mu. | |||
| 85 | + | func capturePaneRenderStateLocked(p *pane, visible domain.Rect, mode damageCaptureMode) capturedPaneRenderState { | |||
| 86 | + | return capturePaneRenderStateLockedInto(p, visible, mode, capturedPaneRenderState{}) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, mode damageCaptureMode, out capturedPaneRenderState) capturedPaneRenderState { | |||
| 90 | + | out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation | |||
| 91 | + | damage := p.screen.CaptureDamage() | |||
| 92 | + | out.damageGeneration = damage.Generation | |||
| 93 | + | out.rawDamage = append(out.rawDamage[:0], damage.Damage...) | |||
| 94 | + | ||||
| 95 | + | width := min(max(visible.Width, 0), p.screen.Frame.Width) | |||
| 96 | + | height := min(max(visible.Height, 0), p.screen.Frame.Height) | |||
| 97 | + | if out.frame.Width != width || out.frame.Height != height { | |||
| 98 | + | out.frame = renderer.NewFrame(width, height) | |||
| 99 | + | } | |||
| 100 | + | for y := range height { | |||
| 101 | + | copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width]) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | if uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) { | |||
| 105 | + | out.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 106 | + | } else { | |||
| 107 | + | out.damage = append(out.damage[:0], damage.Damage...) | |||
| 108 | + | } | |||
| 109 | + | if mode == damageCaptureConsume { | |||
| 110 | + | if !p.screen.AcknowledgeDamage(damage.Generation) { | |||
| 111 | + | out.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | return out | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func uncertainDamage(damage []renderer.Damage, width, height int) bool { | |||
| 118 | + | for _, d := range damage { | |||
| 119 | + | if d.Kind == renderer.DamageFullRedraw { | |||
| 120 | + | if d != renderer.FullRedraw() { | |||
| 121 | + | return true | |||
| 122 | + | } | |||
| 123 | + | continue | |||
| 124 | + | } | |||
| 125 | + | if d.Kind < renderer.DamageText || d.Kind > renderer.DamageScrollUp || d.X < 0 || d.Y < 0 || d.Width <= 0 || d.Height <= 0 || d.X+d.Width > width || d.Y+d.Height > height { | |||
| 126 | + | return true | |||
| 127 | + | } | |||
| 128 | + | if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) { | |||
| 129 | + | return true | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | return false | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | // captureRenderState is the ownership boundary for a render transaction. | |||
| 136 | + | // Callers hold attachment sendMu; this function then follows session -> tab -> | |||
| 137 | + | // pane lock order. ACK-blocked primary capture returns before touching VT | |||
| 138 | + | // damage. Preview mode is always non-destructive. | |||
| 139 | + | func captureRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, reset bool, mode damageCaptureMode) (*capturedRenderState, bool) { | |||
| 140 | + | if sess == nil || ac == nil || (mode == damageCaptureConsume && ac.output != nil && ac.output.atCapacity()) { | |||
| 141 | + | return nil, false | |||
| 142 | + | } | |||
| 143 | + | sess.mu.Lock() | |||
| 144 | + | if sess.client != ac || sess.active < 0 || sess.active >= len(sess.tabs) { | |||
| 145 | + | sess.mu.Unlock() | |||
| 146 | + | return nil, false | |||
| 147 | + | } | |||
| 148 | + | tb := sess.tabs[sess.active] | |||
| 149 | + | epoch := ac.coordinatorEpoch.Load() | |||
| 150 | + | sess.mu.Unlock() | |||
| 151 | + | ||||
| 152 | + | bars.status.tabs = append([]statusTab(nil), bars.status.tabs...) | |||
| 153 | + | bars.mru = append([]recentSession(nil), bars.mru...) | |||
| 154 | + | bars.rankedRecent = append([]rankedRecent(nil), bars.rankedRecent...) | |||
| 155 | + | preview = clonePickerPreview(preview) | |||
| 156 | + | ||||
| 157 | + | tb.mu.Lock() | |||
| 158 | + | defer tb.mu.Unlock() | |||
| 159 | + | layoutSnap := solveTabLayoutLocked(tb) | |||
| 160 | + | state := &capturedRenderState{ | |||
| 161 | + | attachment: ac, attachmentEpoch: epoch, reset: reset, bars: bars, theme: bars.theme, | |||
| 162 | + | overlays: overlays, preview: preview, | |||
| 163 | + | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: append([]layout.Placement(nil), layoutSnap.placements...), fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |||
| 164 | + | floatingGeneration: tb.floating.generation, | |||
| 165 | + | } | |||
| 166 | + | state.tabGeneration = uint64(len(layoutSnap.fingerprint)) | |||
| 167 | + | for _, placement := range layoutSnap.placements { | |||
| 168 | + | p := tb.panes[placement.ID] | |||
| 169 | + | if p == nil { | |||
| 170 | + | continue | |||
| 171 | + | } | |||
| 172 | + | visible := placement.Content | |||
| 173 | + | if placement.Collapsed { | |||
| 174 | + | visible = domain.Rect{} | |||
| 175 | + | } | |||
| 176 | + | p.mu.Lock() | |||
| 177 | + | captured := capturePaneRenderStateLocked(p, visible, mode) | |||
| 178 | + | captured.placement = placement | |||
| 179 | + | captured.focused = placement.ID == layoutSnap.focus | |||
| 180 | + | if captured.focused { | |||
| 181 | + | state.cursor = captureCursorInputsLocked(p, placement.Content, overlays) | |||
| 182 | + | } | |||
| 183 | + | p.mu.Unlock() | |||
| 184 | + | translated := make([]renderer.Damage, 0, len(captured.damage)) | |||
| 185 | + | for _, damage := range captured.damage { | |||
| 186 | + | translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) | |||
| 187 | + | } | |||
| 188 | + | captured.damage = translated | |||
| 189 | + | state.panes = append(state.panes, captured) | |||
| 190 | + | } | |||
| 191 | + | if tb.floating.state == floatingVisible && tb.floating.pane != nil { | |||
| 192 | + | p := tb.floating.pane | |||
| 193 | + | p.mu.Lock() | |||
| 194 | + | geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, domain.FloatingConfig{Width: 100, Height: 100})) | |||
| 195 | + | captured := capturePaneRenderStateLocked(p, geometry.Inner, mode) | |||
| 196 | + | state.floating = capturedFloatingRenderState{visible: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} | |||
| 197 | + | state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) | |||
| 198 | + | p.mu.Unlock() | |||
| 199 | + | } | |||
| 200 | + | return state, true | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { | |||
| 204 | + | style, hasStyle := p.screen.CursorStyle() | |||
| 205 | + | hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |||
| 206 | + | return capturedCursorInputs{row: p.screen.CursorRow(), col: p.screen.CursorCol(), style: style, hasStyle: hasStyle, visible: p.screen.CursorVisible(), renderable: content.Width > 0 && content.Height > 0, hiddenByOverlay: hidden, content: content} | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func clonePickerPreview(in picker.Preview) picker.Preview { | |||
| 210 | + | out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))} | |||
| 211 | + | for i := range in.Rows { | |||
| 212 | + | out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...) | |||
| 213 | + | } | |||
| 214 | + | return out | |||
| 215 | + | } | |||
A internal/usecase/daemon/render_pipeline_test.go +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) { | |
| 12 | + | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |
| 13 | + | p.screen.ClearDamage() | |
| 14 | + | p.screen.Write([]byte("old")) | |
| 15 | + | ||
| 16 | + | p.mu.Lock() | |
| 17 | + | captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1}, damageCaptureConsume) | |
| 18 | + | p.mu.Unlock() | |
| 19 | + | ||
| 20 | + | require.Equal(t, 3, captured.frame.Width) | |
| 21 | + | require.Equal(t, 1, captured.frame.Height) | |
| 22 | + | require.Empty(t, p.screen.Damage()) | |
| 23 | + | p.mu.Lock() | |
| 24 | + | p.screen.Write([]byte("new")) | |
| 25 | + | p.mu.Unlock() | |
| 26 | + | require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestCapturePaneRenderStatePreviewIsNonDestructive(t *testing.T) { | |
| 30 | + | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |
| 31 | + | p.screen.ClearDamage() | |
| 32 | + | p.screen.Write([]byte("preview")) | |
| 33 | + | ||
| 34 | + | p.mu.Lock() | |
| 35 | + | _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCapturePreview) | |
| 36 | + | p.mu.Unlock() | |
| 37 | + | ||
| 38 | + | require.NotEmpty(t, p.screen.Damage()) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) { | |
| 42 | + | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |
| 43 | + | p.screen.ClearDamage() | |
| 44 | + | p.screen.Write([]byte("x")) | |
| 45 | + | p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1} | |
| 46 | + | ||
| 47 | + | p.mu.Lock() | |
| 48 | + | captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCaptureConsume) | |
| 49 | + | p.mu.Unlock() | |
| 50 | + | ||
| 51 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) { | |
| 55 | + | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |
| 56 | + | p.screen.ClearDamage() | |
| 57 | + | p.screen.Write([]byte("hidden")) | |
| 58 | + | ||
| 59 | + | p.mu.Lock() | |
| 60 | + | captured := capturePaneRenderStateLocked(p, domain.Rect{}, damageCaptureConsume) | |
| 61 | + | p.mu.Unlock() | |
| 62 | + | ||
| 63 | + | require.Zero(t, captured.frame.Width) | |
| 64 | + | require.Zero(t, captured.frame.Height) | |
| 65 | + | require.Empty(t, p.screen.Damage(), "capture owner cleans pending hidden-pane damage") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |
| 69 | + | pty, release := newBlockingPTY(t) | |
| 70 | + | t.Cleanup(release) | |
| 71 | + | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | |
| 72 | + | ac := sess.client | |
| 73 | + | ac.sendMu.Lock() | |
| 74 | + | ac.output.next = ac.output.maxOutstanding | |
| 75 | + | ac.output.acked = 0 | |
| 76 | + | ac.sendMu.Unlock() | |
| 77 | + | p := sess.tabs[0].focusedPane() | |
| 78 | + | p.mu.Lock() | |
| 79 | + | p.screen.ClearDamage() | |
| 80 | + | p.screen.Write([]byte("blocked")) | |
| 81 | + | p.mu.Unlock() | |
| 82 | + | ||
| 83 | + | d.paint(sess, ac, false) | |
| 84 | + | ||
| 85 | + | p.mu.Lock() | |
| 86 | + | require.NotEmpty(t, p.screen.Damage()) | |
| 87 | + | p.mu.Unlock() | |
| 88 | + | select { | |
| 89 | + | case frame := <-sends: | |
| 90 | + | t.Fatalf("ACK-blocked paint sent %#v", frame) | |
| 91 | + | default: | |
| 92 | + | } | |
| 93 | + | } | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestCapturePaneRenderStateOwnsVisibleFrameAndConsumesDamage(t *testing.T) { | |||
| 12 | + | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |||
| 13 | + | p.screen.ClearDamage() | |||
| 14 | + | p.screen.Write([]byte("old")) | |||
| 15 | + | ||||
| 16 | + | p.mu.Lock() | |||
| 17 | + | captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 3, Height: 1}, damageCaptureConsume) | |||
| 18 | + | p.mu.Unlock() | |||
| 19 | + | ||||
| 20 | + | require.Equal(t, 3, captured.frame.Width) | |||
| 21 | + | require.Equal(t, 1, captured.frame.Height) | |||
| 22 | + | require.Empty(t, p.screen.Damage()) | |||
| 23 | + | p.mu.Lock() | |||
| 24 | + | p.screen.Write([]byte("new")) | |||
| 25 | + | p.mu.Unlock() | |||
| 26 | + | require.Equal(t, 'o', captured.frame.At(0, 0).Rune, "capture must not alias the mutable VT frame") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestCapturePaneRenderStatePreviewIsNonDestructive(t *testing.T) { | |||
| 30 | + | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |||
| 31 | + | p.screen.ClearDamage() | |||
| 32 | + | p.screen.Write([]byte("preview")) | |||
| 33 | + | ||||
| 34 | + | p.mu.Lock() | |||
| 35 | + | _ = capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCapturePreview) | |||
| 36 | + | p.mu.Unlock() | |||
| 37 | + | ||||
| 38 | + | require.NotEmpty(t, p.screen.Damage()) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestCapturePaneRenderStateMalformedDamageFallsBackToFullRedraw(t *testing.T) { | |||
| 42 | + | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |||
| 43 | + | p.screen.ClearDamage() | |||
| 44 | + | p.screen.Write([]byte("x")) | |||
| 45 | + | p.screen.Damage()[0] = renderer.Damage{Kind: renderer.DamageText, X: -1, Y: 0, Width: 4, Height: 1} | |||
| 46 | + | ||||
| 47 | + | p.mu.Lock() | |||
| 48 | + | captured := capturePaneRenderStateLocked(p, domain.Rect{Width: 8, Height: 2}, damageCaptureConsume) | |||
| 49 | + | p.mu.Unlock() | |||
| 50 | + | ||||
| 51 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, captured.damage) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestCapturePaneRenderStateCollapsedConsumesBoundedly(t *testing.T) { | |||
| 55 | + | p := newPane("p", nil, domain.Size{Cols: 8, Rows: 2}) | |||
| 56 | + | p.screen.ClearDamage() | |||
| 57 | + | p.screen.Write([]byte("hidden")) | |||
| 58 | + | ||||
| 59 | + | p.mu.Lock() | |||
| 60 | + | captured := capturePaneRenderStateLocked(p, domain.Rect{}, damageCaptureConsume) | |||
| 61 | + | p.mu.Unlock() | |||
| 62 | + | ||||
| 63 | + | require.Zero(t, captured.frame.Width) | |||
| 64 | + | require.Zero(t, captured.frame.Height) | |||
| 65 | + | require.Empty(t, p.screen.Damage(), "capture owner cleans pending hidden-pane damage") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||
| 69 | + | pty, release := newBlockingPTY(t) | |||
| 70 | + | t.Cleanup(release) | |||
| 71 | + | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | |||
| 72 | + | ac := sess.client | |||
| 73 | + | ac.sendMu.Lock() | |||
| 74 | + | ac.output.next = ac.output.maxOutstanding | |||
| 75 | + | ac.output.acked = 0 | |||
| 76 | + | ac.sendMu.Unlock() | |||
| 77 | + | p := sess.tabs[0].focusedPane() | |||
| 78 | + | p.mu.Lock() | |||
| 79 | + | p.screen.ClearDamage() | |||
| 80 | + | p.screen.Write([]byte("blocked")) | |||
| 81 | + | p.mu.Unlock() | |||
| 82 | + | ||||
| 83 | + | d.paint(sess, ac, false) | |||
| 84 | + | ||||
| 85 | + | p.mu.Lock() | |||
| 86 | + | require.NotEmpty(t, p.screen.Damage()) | |||
| 87 | + | p.mu.Unlock() | |||
| 88 | + | select { | |||
| 89 | + | case frame := <-sends: | |||
| 90 | + | t.Fatalf("ACK-blocked paint sent %#v", frame) | |||
| 91 | + | default: | |||
| 92 | + | } | |||
| 93 | + | } | |||
M pkg/vt/screen.go +35 -8
| @@ -86,10 +86,11 @@ type Screen struct { | |||
| 86 | 86 | defaultColorsKnown bool | |
| 87 | 87 | terminalTitle string | |
| 88 | 88 | ||
| 89 | - | damage []renderer.Damage | |
| 90 | - | escapeBuf []byte | |
| 91 | - | csiScratch []int | |
| 92 | - | sgrScratch []int | |
| 89 | + | damage []renderer.Damage | |
| 90 | + | damageGeneration uint64 | |
| 91 | + | escapeBuf []byte | |
| 92 | + | csiScratch []int | |
| 93 | + | sgrScratch []int | |
| 93 | 94 | ||
| 94 | 95 | scrollTop int | |
| 95 | 96 | scrollBottom int | |
| @@ -112,10 +113,11 @@ type Screen struct { | |||
| 112 | 113 | ||
| 113 | 114 | func NewScreen(width, height int) *Screen { | |
| 114 | 115 | s := &Screen{ | |
| 115 | - | Frame: renderer.NewFrame(width, height), | |
| 116 | - | Style: renderer.DefaultStyle(), | |
| 117 | - | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 118 | - | cursorVisible: true, | |
| 116 | + | Frame: renderer.NewFrame(width, height), | |
| 117 | + | Style: renderer.DefaultStyle(), | |
| 118 | + | damage: []renderer.Damage{renderer.FullRedraw()}, | |
| 119 | + | damageGeneration: 1, | |
| 120 | + | cursorVisible: true, | |
| 119 | 121 | } | |
| 120 | 122 | s.resetScrollRegion() | |
| 121 | 123 | return s | |
| @@ -162,11 +164,34 @@ func (s *Screen) Resize(width, height int) { | |||
| 162 | 164 | s.fullRedraw() | |
| 163 | 165 | } | |
| 164 | 166 | ||
| 167 | + | // DamageCapture is an immutable copy of pending damage at one screen generation. | |
| 168 | + | type DamageCapture struct { | |
| 169 | + | Damage []renderer.Damage | |
| 170 | + | Generation uint64 | |
| 171 | + | } | |
| 172 | + | ||
| 165 | 173 | // Damage returns the current damage list. The caller must not modify the | |
| 166 | 174 | // returned slice; ClearDamage must be called after the damage is consumed. | |
| 167 | 175 | func (s *Screen) Damage() []renderer.Damage { return s.damage } | |
| 168 | 176 | func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | |
| 169 | 177 | ||
| 178 | + | // CaptureDamage snapshots pending damage without consuming it. | |
| 179 | + | func (s *Screen) CaptureDamage() DamageCapture { | |
| 180 | + | return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration} | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // AcknowledgeDamage consumes a capture only if no screen mutation occurred | |
| 184 | + | // since it was taken. A stale acknowledgement conservatively requests a full | |
| 185 | + | // redraw, ensuring intervening writes remain visible to the next capture. | |
| 186 | + | func (s *Screen) AcknowledgeDamage(generation uint64) bool { | |
| 187 | + | if generation != s.damageGeneration { | |
| 188 | + | s.fullRedraw() | |
| 189 | + | return false | |
| 190 | + | } | |
| 191 | + | s.ClearDamage() | |
| 192 | + | return true | |
| 193 | + | } | |
| 194 | + | ||
| 170 | 195 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | |
| 171 | 196 | // is currently enabled by the child process. | |
| 172 | 197 | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | |
| @@ -599,6 +624,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||
| 599 | 624 | } | |
| 600 | 625 | ||
| 601 | 626 | func (s *Screen) record(d renderer.Damage) { | |
| 627 | + | s.damageGeneration++ | |
| 602 | 628 | // Replace FullRedraw with the first concrete damage item. | |
| 603 | 629 | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |
| 604 | 630 | s.damage[0] = d | |
| @@ -616,6 +642,7 @@ func (s *Screen) record(d renderer.Damage) { | |||
| 616 | 642 | } | |
| 617 | 643 | ||
| 618 | 644 | func (s *Screen) fullRedraw() { | |
| 645 | + | s.damageGeneration++ | |
| 619 | 646 | s.damage = []renderer.Damage{renderer.FullRedraw()} | |
| 620 | 647 | } | |
| 621 | 648 | ||
| @@ -86,10 +86,11 @@ type Screen struct { | |||||
| 86 | defaultColorsKnown bool | 86 | defaultColorsKnown bool | ||
| 87 | terminalTitle string | 87 | terminalTitle string | ||
| 88 | 88 | ||||
| 89 | - | damage []renderer.Damage | 89 | + | damage []renderer.Damage |
| 90 | - | escapeBuf []byte | 90 | + | damageGeneration uint64 |
| 91 | - | csiScratch []int | 91 | + | escapeBuf []byte |
| 92 | - | sgrScratch []int | 92 | + | csiScratch []int |
| 93 | + | sgrScratch []int | |||
| 93 | 94 | ||||
| 94 | scrollTop int | 95 | scrollTop int | ||
| 95 | scrollBottom int | 96 | scrollBottom int | ||
| @@ -112,10 +113,11 @@ type Screen struct { | |||||
| 112 | 113 | ||||
| 113 | func NewScreen(width, height int) *Screen { | 114 | func NewScreen(width, height int) *Screen { | ||
| 114 | s := &Screen{ | 115 | s := &Screen{ | ||
| 115 | - | Frame: renderer.NewFrame(width, height), | 116 | + | Frame: renderer.NewFrame(width, height), |
| 116 | - | Style: renderer.DefaultStyle(), | 117 | + | Style: renderer.DefaultStyle(), |
| 117 | - | damage: []renderer.Damage{renderer.FullRedraw()}, | 118 | + | damage: []renderer.Damage{renderer.FullRedraw()}, |
| 118 | - | cursorVisible: true, | 119 | + | damageGeneration: 1, |
| 120 | + | cursorVisible: true, | |||
| 119 | } | 121 | } | ||
| 120 | s.resetScrollRegion() | 122 | s.resetScrollRegion() | ||
| 121 | return s | 123 | return s | ||
| @@ -162,11 +164,34 @@ func (s *Screen) Resize(width, height int) { | |||||
| 162 | s.fullRedraw() | 164 | s.fullRedraw() | ||
| 163 | } | 165 | } | ||
| 164 | 166 | ||||
| 167 | + | // DamageCapture is an immutable copy of pending damage at one screen generation. | |||
| 168 | + | type DamageCapture struct { | |||
| 169 | + | Damage []renderer.Damage | |||
| 170 | + | Generation uint64 | |||
| 171 | + | } | |||
| 172 | + | ||||
| 165 | // Damage returns the current damage list. The caller must not modify the | 173 | // Damage returns the current damage list. The caller must not modify the | ||
| 166 | // returned slice; ClearDamage must be called after the damage is consumed. | 174 | // returned slice; ClearDamage must be called after the damage is consumed. | ||
| 167 | func (s *Screen) Damage() []renderer.Damage { return s.damage } | 175 | func (s *Screen) Damage() []renderer.Damage { return s.damage } | ||
| 168 | func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | 176 | func (s *Screen) ClearDamage() { s.damage = s.damage[:0] } | ||
| 169 | 177 | ||||
| 178 | + | // CaptureDamage snapshots pending damage without consuming it. | |||
| 179 | + | func (s *Screen) CaptureDamage() DamageCapture { | |||
| 180 | + | return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration} | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | // AcknowledgeDamage consumes a capture only if no screen mutation occurred | |||
| 184 | + | // since it was taken. A stale acknowledgement conservatively requests a full | |||
| 185 | + | // redraw, ensuring intervening writes remain visible to the next capture. | |||
| 186 | + | func (s *Screen) AcknowledgeDamage(generation uint64) bool { | |||
| 187 | + | if generation != s.damageGeneration { | |||
| 188 | + | s.fullRedraw() | |||
| 189 | + | return false | |||
| 190 | + | } | |||
| 191 | + | s.ClearDamage() | |||
| 192 | + | return true | |||
| 193 | + | } | |||
| 194 | + | ||||
| 170 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | 195 | // SyncUpdateActive reports whether DEC private mode 2026 (synchronized update) | ||
| 171 | // is currently enabled by the child process. | 196 | // is currently enabled by the child process. | ||
| 172 | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | 197 | func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive } | ||
| @@ -599,6 +624,7 @@ func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) { | |||||
| 599 | } | 624 | } | ||
| 600 | 625 | ||||
| 601 | func (s *Screen) record(d renderer.Damage) { | 626 | func (s *Screen) record(d renderer.Damage) { | ||
| 627 | + | s.damageGeneration++ | |||
| 602 | // Replace FullRedraw with the first concrete damage item. | 628 | // Replace FullRedraw with the first concrete damage item. | ||
| 603 | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | 629 | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | ||
| 604 | s.damage[0] = d | 630 | s.damage[0] = d | ||
| @@ -616,6 +642,7 @@ func (s *Screen) record(d renderer.Damage) { | |||||
| 616 | } | 642 | } | ||
| 617 | 643 | ||||
| 618 | func (s *Screen) fullRedraw() { | 644 | func (s *Screen) fullRedraw() { | ||
| 645 | + | s.damageGeneration++ | |||
| 619 | s.damage = []renderer.Damage{renderer.FullRedraw()} | 646 | s.damage = []renderer.Damage{renderer.FullRedraw()} | ||
| 620 | } | 647 | } | ||
| 621 | 648 | ||||
M pkg/vt/screen_test.go +33 -0
| @@ -3284,3 +3284,36 @@ func TestScreenAltScreenActiveAccessor(t *testing.T) { | |||
| 3284 | 3284 | t.Fatal("alt screen should be inactive after exit") | |
| 3285 | 3285 | } | |
| 3286 | 3286 | } | |
| 3287 | + | ||
| 3288 | + | func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) { | |
| 3289 | + | s := NewScreen(8, 2) | |
| 3290 | + | s.ClearDamage() | |
| 3291 | + | s.Write([]byte("a")) | |
| 3292 | + | ||
| 3293 | + | captured := s.CaptureDamage() | |
| 3294 | + | require.NotEmpty(t, captured.Damage) | |
| 3295 | + | s.Write([]byte("b")) | |
| 3296 | + | ||
| 3297 | + | require.False(t, s.AcknowledgeDamage(captured.Generation)) | |
| 3298 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively") | |
| 3299 | + | } | |
| 3300 | + | ||
| 3301 | + | func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) { | |
| 3302 | + | s := NewScreen(8, 2) | |
| 3303 | + | s.ClearDamage() | |
| 3304 | + | s.Write([]byte("a")) | |
| 3305 | + | ||
| 3306 | + | captured := s.CaptureDamage() | |
| 3307 | + | require.True(t, s.AcknowledgeDamage(captured.Generation)) | |
| 3308 | + | require.Empty(t, s.Damage()) | |
| 3309 | + | } | |
| 3310 | + | ||
| 3311 | + | func TestDamageCaptureOwnsDamageSlice(t *testing.T) { | |
| 3312 | + | s := NewScreen(8, 2) | |
| 3313 | + | s.ClearDamage() | |
| 3314 | + | s.Write([]byte("a")) | |
| 3315 | + | ||
| 3316 | + | captured := s.CaptureDamage() | |
| 3317 | + | s.ClearDamage() | |
| 3318 | + | require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage") | |
| 3319 | + | } | |
| @@ -3284,3 +3284,36 @@ func TestScreenAltScreenActiveAccessor(t *testing.T) { | |||||
| 3284 | t.Fatal("alt screen should be inactive after exit") | 3284 | t.Fatal("alt screen should be inactive after exit") | ||
| 3285 | } | 3285 | } | ||
| 3286 | } | 3286 | } | ||
| 3287 | + | ||||
| 3288 | + | func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) { | |||
| 3289 | + | s := NewScreen(8, 2) | |||
| 3290 | + | s.ClearDamage() | |||
| 3291 | + | s.Write([]byte("a")) | |||
| 3292 | + | ||||
| 3293 | + | captured := s.CaptureDamage() | |||
| 3294 | + | require.NotEmpty(t, captured.Damage) | |||
| 3295 | + | s.Write([]byte("b")) | |||
| 3296 | + | ||||
| 3297 | + | require.False(t, s.AcknowledgeDamage(captured.Generation)) | |||
| 3298 | + | require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively") | |||
| 3299 | + | } | |||
| 3300 | + | ||||
| 3301 | + | func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) { | |||
| 3302 | + | s := NewScreen(8, 2) | |||
| 3303 | + | s.ClearDamage() | |||
| 3304 | + | s.Write([]byte("a")) | |||
| 3305 | + | ||||
| 3306 | + | captured := s.CaptureDamage() | |||
| 3307 | + | require.True(t, s.AcknowledgeDamage(captured.Generation)) | |||
| 3308 | + | require.Empty(t, s.Damage()) | |||
| 3309 | + | } | |||
| 3310 | + | ||||
| 3311 | + | func TestDamageCaptureOwnsDamageSlice(t *testing.T) { | |||
| 3312 | + | s := NewScreen(8, 2) | |||
| 3313 | + | s.ClearDamage() | |||
| 3314 | + | s.Write([]byte("a")) | |||
| 3315 | + | ||||
| 3316 | + | captured := s.CaptureDamage() | |||
| 3317 | + | s.ClearDamage() | |||
| 3318 | + | require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage") | |||
| 3319 | + | } | |||