1package daemon
2
3import (
4 renderer "github.com/bnema/vev-vt"
5 vevgraphics "github.com/bnema/vev-vt/graphics"
6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/protocol"
8 scopy "github.com/bnema/vev/internal/usecase/copy"
9 "github.com/bnema/vev/internal/usecase/layout"
10 themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/internal/usecase/ui"
12)
13
14// capturedRenderState is the immutable hand-off from authoritative daemon
15// state to composition. Every field is a value or an owned bounded copy; the
16// composer must not retain or consult session, tab, pane, or overlay owners.
17// renderCaptureScratch owns bounded snapshots for one attachment. The complete
18// capture/compose/emit transaction holds that attachment's sendMu, so these
19// buffers cannot be observed or reused before emission completes.
20type renderCaptureScratch struct {
21 state capturedRenderState
22 panes []capturedPaneRenderState
23 placements []layout.Placement
24 dividers []layout.Divider
25 layoutTab *tab
26 layoutRoot *layout.Node
27 layoutGeneration uint64
28 layoutArea domain.Rect
29 layoutFocus layout.PaneID
30 layoutFingerprint string
31 layoutValid bool
32 statusTabs []statusTab
33 mru []recentRouteDisplay
34 ranked []rankedRecent
35 titleIDs []layout.PaneID
36 receipts []damageReceipt
37}
38
39type damageReceipt struct {
40 pane *pane
41 generation uint64
42}
43
44type capturedRenderState struct {
45 attachment *attachedClient // identity only; never dereferenced by composition
46 sessionID domain.SessionID
47 route protocol.CommittedRouteIdentity
48 incarnation domain.IncarnationID
49 lease *attachmentLease
50 uiFence uint64
51 frameCapture uint64 // numbers this capture in the attachment's afterFrame queue
52 view attachmentView
53 focusedPaneID domain.PaneStableID
54 window domain.Size
55 reset bool
56 layout capturedTabLayout
57 panes []capturedPaneRenderState
58 floating capturedFloatingRenderState
59 bars barState
60 theme themeui.Theme
61 styles themeui.Styles
62 styleGeneration uint64
63 overlays capturedOverlayRenderState
64 // suppressedGraphics records a late Kitty scene observed on an attachment
65 // that cannot emit graphics. Capture keeps the scene out of the output but
66 // lets the daemon issue one bounded user warning after pane locks release.
67 suppressedGraphics bool
68 cursor capturedCursorInputs
69 tabGeneration uint64
70 floatingGeneration uint64
71 receipts []damageReceipt // private capture receipts; compose must not inspect these
72}
73
74// viewContext describes only this captured composition, never live session state.
75func (s *capturedRenderState) viewContext() protocol.ViewContext {
76 context := protocol.ViewContext{Route: s.route, TabID: s.view.tabID, FocusedPaneID: s.focusedPaneID}
77 if s.floating.visible && s.floating.focused {
78 context.FocusedPaneID = s.floating.pane.stableID
79 return context
80 }
81 for _, pane := range s.panes {
82 if pane.focused {
83 context.FocusedPaneID = pane.stableID
84 break
85 }
86 }
87 return context
88}
89
90type capturedTabLayout struct {
91 area domain.Rect
92 focus layout.PaneID
93 placements []layout.Placement
94 dividers []layout.Divider
95 fingerprint string
96 valid bool
97}
98
99type capturedPaneRenderState struct {
100 id layout.PaneID
101 stableID domain.PaneStableID
102 frame renderer.Frame
103 rawDamage []renderer.Damage
104 damage []renderer.Damage
105 damageGeneration uint64
106 // graphics is an immutable copy-on-write reference owned by the VT screen.
107 // It is nil for ordinary text panes, preserving the fast text path.
108 graphics *vevgraphics.Snapshot
109 graphicsGeometry domain.Geometry
110 title string
111 titleGeneration uint64
112 placement layout.Placement
113 focused bool
114}
115
116type capturedFloatingRenderState struct {
117 visible bool
118 focused bool
119 pane capturedPaneRenderState
120 geometry floatingGeometry
121 title string
122 generation uint64
123 titleGeneration uint64
124}
125
126type capturedOverlayRenderState struct {
127 copyActive, copySearchActive, paletteActive, promptActive bool
128 noticesOverlayActive, resizeActive bool
129 copyMode *scopy.Mode
130 copyPaneID layout.PaneID
131 statusFeedback string
132 paletteGuidance string
133 copySearch, palette, prompt, noticesOverlay capturedModal
134 notices []domain.Notification
135 noticeOverflow int
136}
137
138type capturedModal struct {
139 active bool
140 title string
141 presentation ui.Presentation
142 inner renderer.Frame
143 focused bool
144}
145
146func (o capturedOverlayRenderState) active() bool {
147 return o.copyActive || o.copySearchActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
148}
149
150type capturedCursorInputs struct {
151 row, col, style int
152 hasStyle, visible, renderable, hiddenByOverlay bool
153 content domain.Rect
154}
155
156// writePaneScreenLocked is the production VT write boundary. The first
157// placement fixes the current scene's coordinate units before a later resize
158// can change Screen.Geometry; deleting the last placement ends that boundary.
159// Caller holds p.mu.
160func writePaneScreenLocked(p *pane, data []byte) {
161 if p == nil || p.screen == nil {
162 return
163 }
164 before := p.screen.GraphicsSnapshot()
165 beforeHasPlacements := before != nil && before.Usage().Placements != 0
166 p.screen.Write(data)
167 after := p.screen.GraphicsSnapshot()
168 afterHasPlacements := after != nil && after.Usage().Placements != 0
169 if !beforeHasPlacements && afterHasPlacements {
170 geometry := p.screen.Geometry()
171 p.graphicsCoordinateGeometry = domain.Geometry{
172 Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
173 PixelWidth: geometry.PixelWidth,
174 PixelHeight: geometry.PixelHeight,
175 }.NormalizePixels()
176 }
177 p.graphicsPlacementScene = afterHasPlacements
178 if !afterHasPlacements {
179 p.graphicsCoordinateGeometry = domain.Geometry{}
180 }
181}
182
183// capturePaneRenderStateLockedInto copies only the visible rectangle required
184// by composition into out. It never copies scrollback/history and never lets
185// the mutable VT frame's Cells or row-offset slices escape pane.mu.
186func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
187 out.id, out.stableID, out.title, out.titleGeneration = p.id, domain.PaneStableID(p.stableID), p.displayTitleLocked(), p.title.generation
188 // GraphicsSnapshot never allocates graphics state for a text-only screen and
189 // returns an immutable scene reference when Kitty graphics were used.
190 out.graphics = p.screen.GraphicsSnapshot()
191 geometry := p.screen.Geometry()
192 currentGraphicsGeometry := domain.Geometry{
193 Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
194 PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
195 }.NormalizePixels()
196 // The scene retains placement coordinates across SetGeometry. Capture the
197 // unit basis when the first placement appears and keep it until the placement
198 // scene becomes empty; otherwise an attachment resize silently reinterprets
199 // old pixel coordinates using the new cell dimensions.
200 hasPlacements := out.graphics != nil && out.graphics.Usage().Placements != 0
201 if hasPlacements && !p.graphicsPlacementScene {
202 p.graphicsCoordinateGeometry = currentGraphicsGeometry
203 }
204 p.graphicsPlacementScene = hasPlacements
205 if hasPlacements {
206 out.graphicsGeometry = p.graphicsCoordinateGeometry
207 } else {
208 p.graphicsCoordinateGeometry = domain.Geometry{}
209 out.graphicsGeometry = currentGraphicsGeometry
210 }
211 damage := p.screen.CaptureDamage()
212 out.damageGeneration = damage.Generation
213 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
214
215 width := min(max(visible.Width, 0), p.screen.Columns())
216 height := min(max(visible.Height, 0), p.screen.Rows())
217 needsFrame := len(damage.Damage) > 0
218 frameChanged := out.frame.Width != width || out.frame.Height != height
219 if frameChanged {
220 out.frame = renderer.NewFrame(width, height)
221 needsFrame = true
222 }
223 // A retained attachment snapshot is already an immutable copy of this
224 // pane. With no VT damage, retain it rather than copying inactive panes.
225 if needsFrame {
226 for y := range height {
227 for x := range width {
228 out.frame.Set(x, y, p.screen.Cell(x, y))
229 }
230 }
231 }
232
233 if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) {
234 // A cache entry with a new frame has no terminal-shadow equivalent.
235 // Redraw it even when the pane has no VT damage, such as an untouched
236 // pane first made visible after a tab or session switch.
237 out.damage = []renderer.Damage{renderer.FullRedraw()}
238 } else {
239 out.damage = append(out.damage[:0], damage.Damage...)
240 }
241 // Consumption is transactional: capture only snapshots damage. publishFrameLocked
242 // acknowledges this generation after preparation and transport success.
243 return out
244}
245
246func uncertainDamage(damage []renderer.Damage, width, height int) bool {
247 for _, d := range damage {
248 if d.Kind == renderer.DamageFullRedraw {
249 if d != renderer.FullRedraw() {
250 return true
251 }
252 continue
253 }
254 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 {
255 return true
256 }
257 if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
258 return true
259 }
260 }
261 return false
262}
263
264type renderCaptureRequest struct {
265 bars barState
266 overlays capturedOverlayRenderState
267 floatingCfg domain.FloatingConfig
268 styles themeui.Styles
269 styleGeneration uint64
270 reset bool
271 lease *attachmentLease
272 uiFence uint64
273}
274
275// captureLocalRenderState is the ownership boundary for a local attachment
276// render transaction. Callers hold attachment sendMu; this function then follows
277// session -> tab -> pane lock order. ACK-blocked capture returns before touching
278// VT damage, and every captured pane records a receipt for successful emission.
279func captureLocalRenderState(
280 sess *session,
281 ac *attachedClient,
282 request renderCaptureRequest,
283) (*capturedRenderState, bool) {
284 bars := request.bars
285 overlays := request.overlays
286 floatingCfg := request.floatingCfg
287 reset := request.reset
288 lease := request.lease
289 if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
290 return nil, false
291 }
292 sess.mu.Lock()
293 _, owned := sess.attachments[ac]
294 sessionID := sess.id
295 incarnation := sess.incarnation
296 route := protocol.CommittedRouteIdentity{
297 Target: protocol.ExactSessionTarget{LifecycleID: incarnation, SessionName: sess.name},
298 Ephemeral: sess.ephemeral,
299 }
300 sess.mu.Unlock()
301 if !owned {
302 return nil, false
303 }
304 tb := sess.tabForAttachment(ac)
305 if tb == nil {
306 return nil, false
307 }
308 // Resolve all pane selection from one view value. Navigation after this
309 // point invalidates the captured revision at prepared-output admission.
310 view := ac.viewSnapshot()
311 if view.tabID != domain.TabStableID(tb.stableID) {
312 return nil, false
313 }
314
315 // A reset is an attachment-local capture boundary. The shared pane damage
316 // receipt may have been acknowledged by another fan-out attachment before
317 // this capture runs, so retaining this attachment's prior pane snapshots
318 // would compose an old frame with a fresh reset epoch.
319 if reset {
320 ac.render.forgetAllPanes()
321 }
322 scratch := &ac.render.capture
323 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
324 bars.status.tabs = scratch.statusTabs
325 scratch.mru = append(scratch.mru[:0], bars.mru...)
326 bars.mru = scratch.mru
327 if bars.rankedRecent != nil {
328 scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
329 bars.rankedRecent = scratch.ranked
330 }
331 tb.mu.Lock()
332 defer tb.mu.Unlock()
333
334 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
335 var focus layout.PaneID
336 var focusedPaneID domain.PaneStableID
337 for _, pane := range tb.panes {
338 if pane != nil && domain.PaneStableID(pane.stableID) == view.paneID {
339 focus = pane.id
340 focusedPaneID = domain.PaneStableID(pane.stableID)
341 break
342 }
343 }
344 if focusedPaneID == "" && (tb.floating.state != floatingVisible || tb.floating.pane == nil) {
345 // A removed input target is not a committed semantic view. Its owning
346 // topology mutation will repair/invalidate the attachment separately.
347 return nil, false
348 }
349 layoutSnap := tabLayoutSnapshot{
350 area: area, focus: focus, placements: scratch.placements,
351 dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
352 }
353 var layoutRoot *layout.Node
354 if tb.tree != nil {
355 layoutRoot = tb.tree.Root
356 }
357 if scratch.layoutTab != tb || scratch.layoutRoot != layoutRoot || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
358 layoutSnap = solveTabLayoutLocked(tb)
359 layoutSnap.focus = focus
360 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
361 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
362 scratch.layoutTab = tb
363 scratch.layoutRoot = layoutRoot
364 scratch.layoutGeneration = tb.layoutGeneration
365 scratch.layoutArea = area
366 scratch.layoutFocus = focus
367 scratch.layoutFingerprint = layoutSnap.fingerprint
368 scratch.layoutValid = layoutSnap.ok
369 layoutSnap.placements = scratch.placements
370 layoutSnap.dividers = scratch.dividers
371 }
372 state := &scratch.state
373 window := domain.Size{}
374 if view.windowSet {
375 window = ac.sizeSnapshot()
376 }
377 *state = capturedRenderState{
378 attachment: ac, sessionID: sessionID, route: route, incarnation: incarnation, lease: lease, view: view, window: window,
379 reset: reset, bars: bars, theme: bars.theme,
380 uiFence: request.uiFence,
381 focusedPaneID: focusedPaneID,
382 styles: request.styles, styleGeneration: request.styleGeneration,
383 overlays: overlays,
384 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
385 floatingGeneration: tb.floating.generation,
386 receipts: scratch.receipts[:0],
387 }
388 state.panes = scratch.panes[:0]
389 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
390 for _, placement := range layoutSnap.placements {
391 p := tb.panes[placement.ID]
392 if p == nil {
393 continue
394 }
395 visible := placement.Content
396 if placement.Collapsed {
397 visible = domain.Rect{}
398 }
399 p.mu.Lock()
400 captured := capturePaneRenderStateLockedInto(p, visible, ac.render.paneSnapshot(p))
401 if !ac.terminalCapabilities.SupportsKittyGraphics() {
402 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
403 state.suppressedGraphics = true
404 }
405 captured.graphics = nil
406 }
407 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
408 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
409 if captured.focused {
410 state.cursor = captureCursorInputsLocked(p, placement.Content, overlays)
411 }
412 p.mu.Unlock()
413 translated := captured.rawDamage[:0]
414 for _, damage := range captured.damage {
415 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
416 }
417 captured.rawDamage, captured.damage = captured.damage, translated
418 ac.render.storePaneSnapshot(p, captured)
419 state.panes = append(state.panes, captured)
420 }
421 scratch.panes = state.panes
422 if tb.floating.state == floatingVisible && tb.floating.pane != nil {
423 p := tb.floating.pane
424 p.mu.Lock()
425 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
426 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.render.paneSnapshot(p))
427 if !ac.terminalCapabilities.SupportsKittyGraphics() {
428 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
429 state.suppressedGraphics = true
430 }
431 captured.graphics = nil
432 }
433 seen := false
434 for _, receipt := range state.receipts {
435 if receipt.pane == p {
436 seen = true
437 break
438 }
439 }
440 if !seen {
441 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
442 }
443 ac.render.storePaneSnapshot(p, captured)
444 // A visible floating pane is the terminal input target, so its structural
445 // border carries the focused semantic role independently of its content.
446 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
447 state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays)
448 p.mu.Unlock()
449 }
450 scratch.receipts = state.receipts
451 return state, true
452}
453
454// captureRenderState preserves the focused local test seam. Production
455// rendering delegates through *session.captureRenderState.
456func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
457 return captureLocalRenderState(sess, ac, request)
458}
459
460// copyRankedRecentInto preserves the non-nil empty slice that selects
461// contextual recent-session mode while retaining scratch capacity for reuse.
462func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
463 if len(src) == 0 {
464 if dst != nil {
465 return dst[:0]
466 }
467 return []rankedRecent{}
468 }
469 return append(dst[:0], src...)
470}
471
472func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs {
473 style, hasStyle := p.screen.CursorStyle()
474 hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive
475 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}
476}
477
478// appendStackPaneIDs returns exactly the panes whose solved placement can own a
479// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
480func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
481 if node == nil {
482 return dst
483 }
484 if node.Kind == layout.Stack {
485 for _, child := range node.Children {
486 if child != nil && child.Kind == layout.Leaf {
487 dst = append(dst, child.Leaf)
488 }
489 }
490 return dst
491 }
492 for _, child := range node.Children {
493 dst = appendStackPaneIDs(dst, child)
494 }
495 return dst
496}