1package daemon
2
3import (
4 renderer "github.com/bnema/vev-vt"
5 "github.com/bnema/vev/internal/domain"
6 scopy "github.com/bnema/vev/internal/usecase/copy"
7 "github.com/bnema/vev/internal/usecase/layout"
8 "github.com/bnema/vev/internal/usecase/picker"
9 themeui "github.com/bnema/vev/internal/usecase/theme"
10 "github.com/bnema/vev/internal/usecase/ui"
11)
12
13// capturedRenderState is the immutable hand-off from authoritative daemon
14// state to composition. Every field is a value or an owned bounded copy; the
15// composer must not retain or consult session, tab, pane, or overlay owners.
16// renderCaptureScratch owns bounded snapshots for one attachment. The complete
17// capture/compose/emit transaction holds that attachment's sendMu, so these
18// buffers cannot be observed or reused before emission completes.
19type renderCaptureScratch struct {
20 state capturedRenderState
21 panes []capturedPaneRenderState
22 placements []layout.Placement
23 dividers []layout.Divider
24 layoutTab *tab
25 layoutGeneration uint64
26 layoutArea domain.Rect
27 layoutFocus layout.PaneID
28 layoutFingerprint string
29 layoutValid bool
30 statusTabs []statusTab
31 mru []recentRouteDisplay
32 ranked []rankedRecent
33 titleIDs []layout.PaneID
34 receipts []damageReceipt
35}
36
37type damageReceipt struct {
38 pane *pane
39 generation uint64
40}
41
42type capturedRenderState struct {
43 attachment *attachedClient // identity only; never dereferenced by composition
44 lease *attachmentLease
45 view attachmentView
46 window domain.Size
47 reset bool
48 layout capturedTabLayout
49 panes []capturedPaneRenderState
50 floating capturedFloatingRenderState
51 bars barState
52 theme themeui.Theme
53 styles themeui.Styles
54 styleGeneration uint64
55 overlays capturedOverlayRenderState
56 preview picker.Preview
57 cursor capturedCursorInputs
58 tabGeneration uint64
59 floatingGeneration uint64
60 receipts []damageReceipt // private capture receipts; compose must not inspect these
61}
62
63type capturedTabLayout struct {
64 area domain.Rect
65 focus layout.PaneID
66 placements []layout.Placement
67 dividers []layout.Divider
68 fingerprint string
69 valid bool
70}
71
72type capturedPaneRenderState struct {
73 id layout.PaneID
74 frame renderer.Frame
75 rawDamage []renderer.Damage
76 damage []renderer.Damage
77 damageGeneration uint64
78 title string
79 titleGeneration uint64
80 placement layout.Placement
81 focused bool
82}
83
84type capturedFloatingRenderState struct {
85 visible bool
86 focused bool
87 pane capturedPaneRenderState
88 geometry floatingGeometry
89 title string
90 generation uint64
91 titleGeneration uint64
92}
93
94type capturedOverlayRenderState struct {
95 copyActive, copySearchActive, pickerActive, paletteActive, promptActive bool
96 noticesOverlayActive, resizeActive bool
97 copyMode *scopy.Mode
98 copyPaneID layout.PaneID
99 statusFeedback string
100 paletteGuidance string
101 copySearch, picker, palette, prompt, noticesOverlay capturedModal
102 notices []domain.Notification
103 noticeOverflow int
104}
105
106type capturedModal struct {
107 active bool
108 title string
109 presentation ui.Presentation
110 inner renderer.Frame
111 focused bool
112}
113
114func (o capturedOverlayRenderState) active() bool {
115 return o.copyActive || o.copySearchActive || o.pickerActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
116}
117
118type capturedCursorInputs struct {
119 row, col, style int
120 hasStyle, visible, renderable, hiddenByOverlay bool
121 content domain.Rect
122}
123
124// capturePaneRenderStateLocked copies only the visible rectangle required by
125// composition. It never copies scrollback/history and never lets the mutable
126// VT frame's Cells or row-offset slices escape pane.mu.
127func capturePaneRenderStateLocked(p *pane, visible domain.Rect) capturedPaneRenderState {
128 return capturePaneRenderStateLockedInto(p, visible, capturedPaneRenderState{})
129}
130
131func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
132 out.id, out.title, out.titleGeneration = p.id, p.displayTitleLocked(), p.title.generation
133 damage := p.screen.CaptureDamage()
134 out.damageGeneration = damage.Generation
135 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
136
137 width := min(max(visible.Width, 0), p.screen.Frame.Width)
138 height := min(max(visible.Height, 0), p.screen.Frame.Height)
139 needsFrame := len(damage.Damage) > 0
140 frameChanged := out.frame.Width != width || out.frame.Height != height
141 if frameChanged {
142 out.frame = renderer.NewFrame(width, height)
143 needsFrame = true
144 }
145 // A retained attachment snapshot is already an immutable copy of this
146 // pane. With no VT damage, retain it rather than copying inactive panes.
147 if needsFrame {
148 for y := range height {
149 copy(out.frame.Row(y), p.screen.Frame.Row(y)[:width])
150 }
151 }
152
153 if frameChanged || uncertainDamage(damage.Damage, p.screen.Frame.Width, p.screen.Frame.Height) {
154 // A cache entry with a new frame has no terminal-shadow equivalent.
155 // Redraw it even when the pane has no VT damage, such as an untouched
156 // pane first made visible after a tab or session switch.
157 out.damage = []renderer.Damage{renderer.FullRedraw()}
158 } else {
159 out.damage = append(out.damage[:0], damage.Damage...)
160 }
161 // Consumption is transactional: capture only snapshots damage. emitFrame
162 // acknowledges this generation after preparation and transport success.
163 return out
164}
165
166func uncertainDamage(damage []renderer.Damage, width, height int) bool {
167 for _, d := range damage {
168 if d.Kind == renderer.DamageFullRedraw {
169 if d != renderer.FullRedraw() {
170 return true
171 }
172 continue
173 }
174 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 {
175 return true
176 }
177 if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
178 return true
179 }
180 }
181 return false
182}
183
184type renderCaptureRequest struct {
185 bars barState
186 overlays capturedOverlayRenderState
187 preview picker.Preview
188 floatingCfg domain.FloatingConfig
189 styles themeui.Styles
190 styleGeneration uint64
191 reset bool
192 lease *attachmentLease
193}
194
195// captureLocalRenderState is the ownership boundary for a local attachment
196// render transaction. Callers hold attachment sendMu; this function then follows
197// session -> tab -> pane lock order. ACK-blocked capture returns before touching
198// VT damage, and every captured pane records a receipt for successful emission.
199func captureLocalRenderState(
200 sess *session,
201 ac *attachedClient,
202 request renderCaptureRequest,
203) (*capturedRenderState, bool) {
204 bars := request.bars
205 overlays := request.overlays
206 preview := request.preview
207 floatingCfg := request.floatingCfg
208 reset := request.reset
209 lease := request.lease
210 if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
211 return nil, false
212 }
213 sess.mu.Lock()
214 _, owned := sess.attachments[ac]
215 sess.mu.Unlock()
216 if !owned {
217 return nil, false
218 }
219 tb, focusedPane := sess.paneForAttachment(ac)
220 if tb == nil {
221 tb = sess.tabForAttachment(ac)
222 }
223 if tb == nil {
224 return nil, false
225 }
226
227 // A reset is an attachment-local capture boundary. The shared pane damage
228 // receipt may have been acknowledged by another fan-out attachment before
229 // this capture runs, so retaining this attachment's prior pane snapshots
230 // would compose an old frame with a fresh reset epoch.
231 if reset {
232 ac.captureFrames = nil
233 }
234 scratch := &ac.renderScratch
235 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
236 bars.status.tabs = scratch.statusTabs
237 scratch.mru = append(scratch.mru[:0], bars.mru...)
238 bars.mru = scratch.mru
239 if bars.rankedRecent != nil {
240 scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
241 bars.rankedRecent = scratch.ranked
242 }
243 if len(preview.Rows) > 0 {
244 preview = clonePickerPreview(preview)
245 } else {
246 preview = picker.Preview{Width: preview.Width, Height: preview.Height}
247 }
248
249 tb.mu.Lock()
250 defer tb.mu.Unlock()
251 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
252 var focus layout.PaneID
253 if focusedPane != nil && tb.panes[focusedPane.id] == focusedPane {
254 focus = focusedPane.id
255 }
256 layoutSnap := tabLayoutSnapshot{
257 area: area, focus: focus, placements: scratch.placements,
258 dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
259 }
260 if scratch.layoutTab != tb || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
261 layoutSnap = solveTabLayoutLocked(tb)
262 layoutSnap.focus = focus
263 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
264 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
265 scratch.layoutTab = tb
266 scratch.layoutGeneration = tb.layoutGeneration
267 scratch.layoutArea = area
268 scratch.layoutFocus = focus
269 scratch.layoutFingerprint = layoutSnap.fingerprint
270 scratch.layoutValid = layoutSnap.ok
271 layoutSnap.placements = scratch.placements
272 layoutSnap.dividers = scratch.dividers
273 }
274 state := &scratch.state
275 view := ac.viewSnapshot()
276 window := domain.Size{}
277 if view.windowSet {
278 window = ac.sizeSnapshot()
279 }
280 *state = capturedRenderState{
281 attachment: ac, lease: lease, view: view, window: window,
282 reset: reset, bars: bars, theme: bars.theme,
283 styles: request.styles, styleGeneration: request.styleGeneration,
284 overlays: overlays, preview: preview,
285 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
286 floatingGeneration: tb.floating.generation,
287 receipts: scratch.receipts[:0],
288 }
289 state.panes = scratch.panes[:0]
290 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
291 if ac.captureFrames == nil {
292 ac.captureFrames = make(map[*pane]capturedPaneRenderState)
293 }
294 for _, placement := range layoutSnap.placements {
295 p := tb.panes[placement.ID]
296 if p == nil {
297 continue
298 }
299 visible := placement.Content
300 if placement.Collapsed {
301 visible = domain.Rect{}
302 }
303 p.mu.Lock()
304 captured := capturePaneRenderStateLockedInto(p, visible, ac.captureFrames[p])
305 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
306 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
307 if captured.focused {
308 state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
309 }
310 p.mu.Unlock()
311 translated := captured.rawDamage[:0]
312 for _, damage := range captured.damage {
313 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
314 }
315 captured.rawDamage, captured.damage = captured.damage, translated
316 ac.captureFrames[p] = captured
317 state.panes = append(state.panes, captured)
318 }
319 scratch.panes = state.panes
320 if tb.floating.state == floatingVisible && tb.floating.pane != nil {
321 p := tb.floating.pane
322 p.mu.Lock()
323 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
324 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.captureFrames[p])
325 seen := false
326 for _, receipt := range state.receipts {
327 if receipt.pane == p {
328 seen = true
329 break
330 }
331 }
332 if !seen {
333 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
334 }
335 ac.captureFrames[p] = captured
336 // A visible floating pane is the terminal input target, so its structural
337 // border carries the focused semantic role independently of its content.
338 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
339 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
340 p.mu.Unlock()
341 }
342 scratch.receipts = state.receipts
343 return state, true
344}
345
346// captureRenderState preserves the focused local test seam. Production
347// rendering delegates through *session.captureRenderState.
348func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
349 return captureLocalRenderState(sess, ac, request)
350}
351
352// copyRankedRecentInto preserves the non-nil empty slice that selects
353// contextual recent-session mode while retaining scratch capacity for reuse.
354func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
355 if len(src) == 0 {
356 if dst != nil {
357 return dst[:0]
358 }
359 return []rankedRecent{}
360 }
361 return append(dst[:0], src...)
362}
363
364func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
365 style, hasStyle := p.screen.CursorStyle()
366 hidden := overlays.copyActive || overlays.copySearchActive || overlays.pickerActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
367 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}
368}
369
370func clonePickerPreview(in picker.Preview) picker.Preview {
371 out := picker.Preview{Width: in.Width, Height: in.Height, Rows: make([][]renderer.Cell, len(in.Rows))}
372 for i := range in.Rows {
373 out.Rows[i] = append([]renderer.Cell(nil), in.Rows[i]...)
374 }
375 return out
376}
377
378// appendStackPaneIDs returns exactly the panes whose solved placement can own a
379// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
380func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
381 if node == nil {
382 return dst
383 }
384 if node.Kind == layout.Stack {
385 for _, child := range node.Children {
386 if child != nil && child.Kind == layout.Leaf {
387 dst = append(dst, child.Leaf)
388 }
389 }
390 return dst
391 }
392 for _, child := range node.Children {
393 dst = appendStackPaneIDs(dst, child)
394 }
395 return dst
396}