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/domain/layout"
8 "github.com/bnema/vev/internal/protocol"
9 scopy "github.com/bnema/vev/internal/usecase/copy"
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 // caretCol is the input caret column on row 0 of inner, valid when
145 // hasCaret. The topmost modal with a caret owns the terminal cursor.
146 caretCol int
147 hasCaret bool
148}
149
150func (o capturedOverlayRenderState) active() bool {
151 return o.copyActive || o.copySearchActive || o.paletteActive || o.promptActive || o.noticesOverlayActive
152}
153
154type capturedCursorInputs struct {
155 row, col, style int
156 hasStyle, visible, renderable bool
157 content domain.Rect
158}
159
160// writePaneScreenLocked is the production VT write boundary. The first
161// placement fixes the current scene's coordinate units before a later resize
162// can change Screen.Geometry; deleting the last placement ends that boundary.
163// Caller holds p.mu.
164func writePaneScreenLocked(p *pane, data []byte) {
165 if p == nil || p.screen == nil {
166 return
167 }
168 before := p.screen.GraphicsSnapshot()
169 beforeHasPlacements := before != nil && before.Usage().Placements != 0
170 p.screen.Write(data)
171 after := p.screen.GraphicsSnapshot()
172 afterHasPlacements := after != nil && after.Usage().Placements != 0
173 if !beforeHasPlacements && afterHasPlacements {
174 geometry := p.screen.Geometry()
175 p.graphicsCoordinateGeometry = domain.Geometry{
176 Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
177 PixelWidth: geometry.PixelWidth,
178 PixelHeight: geometry.PixelHeight,
179 }.NormalizePixels()
180 }
181 p.graphicsPlacementScene = afterHasPlacements
182 if !afterHasPlacements {
183 p.graphicsCoordinateGeometry = domain.Geometry{}
184 }
185}
186
187// capturePaneRenderStateLockedInto copies only the visible rectangle required
188// by composition into out. It never copies scrollback/history and never lets
189// the mutable VT frame's Cells or row-offset slices escape pane.mu.
190func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState {
191 out.id, out.stableID, out.title, out.titleGeneration = p.id, domain.PaneStableID(p.stableID), p.displayTitleLocked(), p.title.generation
192 // GraphicsSnapshot never allocates graphics state for a text-only screen and
193 // returns an immutable scene reference when Kitty graphics were used.
194 out.graphics = p.screen.GraphicsSnapshot()
195 geometry := p.screen.Geometry()
196 currentGraphicsGeometry := domain.Geometry{
197 Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows},
198 PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight,
199 }.NormalizePixels()
200 // The scene retains placement coordinates across SetGeometry. Capture the
201 // unit basis when the first placement appears and keep it until the placement
202 // scene becomes empty; otherwise an attachment resize silently reinterprets
203 // old pixel coordinates using the new cell dimensions.
204 hasPlacements := out.graphics != nil && out.graphics.Usage().Placements != 0
205 if hasPlacements && !p.graphicsPlacementScene {
206 p.graphicsCoordinateGeometry = currentGraphicsGeometry
207 }
208 p.graphicsPlacementScene = hasPlacements
209 if hasPlacements {
210 out.graphicsGeometry = p.graphicsCoordinateGeometry
211 } else {
212 p.graphicsCoordinateGeometry = domain.Geometry{}
213 out.graphicsGeometry = currentGraphicsGeometry
214 }
215 damage := p.screen.CaptureDamage()
216 out.damageGeneration = damage.Generation
217 out.rawDamage = append(out.rawDamage[:0], damage.Damage...)
218
219 width := min(max(visible.Width, 0), p.screen.Columns())
220 height := min(max(visible.Height, 0), p.screen.Rows())
221 needsFrame := len(damage.Damage) > 0
222 frameChanged := out.frame.Width != width || out.frame.Height != height
223 if frameChanged {
224 out.frame = renderer.NewFrame(width, height)
225 needsFrame = true
226 }
227 // A retained attachment snapshot is already an immutable copy of this
228 // pane. With no VT damage, retain it rather than copying inactive panes.
229 if needsFrame {
230 for y := range height {
231 for x := range width {
232 out.frame.Set(x, y, p.screen.Cell(x, y))
233 }
234 }
235 }
236
237 if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) {
238 // A cache entry with a new frame has no terminal-shadow equivalent.
239 // Redraw it even when the pane has no VT damage, such as an untouched
240 // pane first made visible after a tab or session switch.
241 out.damage = []renderer.Damage{renderer.FullRedraw()}
242 } else {
243 out.damage = append(out.damage[:0], damage.Damage...)
244 }
245 // Consumption is transactional: capture only snapshots damage. publishFrameLocked
246 // acknowledges this generation after preparation and transport success.
247 return out
248}
249
250func uncertainDamage(damage []renderer.Damage, width, height int) bool {
251 for _, d := range damage {
252 if d.Kind == renderer.DamageFullRedraw {
253 if d != renderer.FullRedraw() {
254 return true
255 }
256 continue
257 }
258 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 {
259 return true
260 }
261 if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) {
262 return true
263 }
264 }
265 return false
266}
267
268type renderCaptureRequest struct {
269 bars barState
270 overlays capturedOverlayRenderState
271 floatingCfg domain.FloatingConfig
272 styles themeui.Styles
273 styleGeneration uint64
274 reset bool
275 lease *attachmentLease
276 uiFence uint64
277}
278
279// captureLocalRenderState is the ownership boundary for a local attachment
280// render transaction. Callers hold attachment sendMu; this function then follows
281// session -> tab -> pane lock order. ACK-blocked capture returns before touching
282// VT damage, and every captured pane records a receipt for successful emission.
283func captureLocalRenderState(
284 sess *session,
285 ac *attachedClient,
286 request renderCaptureRequest,
287) (*capturedRenderState, bool) {
288 bars := request.bars
289 overlays := request.overlays
290 floatingCfg := request.floatingCfg
291 reset := request.reset
292 lease := request.lease
293 if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
294 return nil, false
295 }
296 sess.mu.Lock()
297 _, owned := sess.attachments[ac]
298 sessionID := sess.id
299 incarnation := sess.incarnation
300 route := protocol.CommittedRouteIdentity{
301 Target: protocol.ExactSessionTarget{LifecycleID: incarnation, SessionName: sess.name},
302 Ephemeral: sess.ephemeral,
303 }
304 sess.mu.Unlock()
305 if !owned {
306 return nil, false
307 }
308 tb := sess.tabForAttachment(ac)
309 if tb == nil {
310 return nil, false
311 }
312 // Resolve all pane selection from one view value. Navigation after this
313 // point invalidates the captured revision at prepared-output admission.
314 view := ac.viewSnapshot()
315 if view.tabID != domain.TabStableID(tb.stableID) {
316 return nil, false
317 }
318
319 // A reset is an attachment-local capture boundary. The shared pane damage
320 // receipt may have been acknowledged by another fan-out attachment before
321 // this capture runs, so retaining this attachment's prior pane snapshots
322 // would compose an old frame with a fresh reset epoch.
323 if reset {
324 ac.render.forgetAllPanes()
325 }
326 scratch := &ac.render.capture
327 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
328 bars.status.tabs = scratch.statusTabs
329 scratch.mru = append(scratch.mru[:0], bars.mru...)
330 bars.mru = scratch.mru
331 if bars.rankedRecent != nil {
332 scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent)
333 bars.rankedRecent = scratch.ranked
334 }
335 tb.mu.Lock()
336 defer tb.mu.Unlock()
337
338 area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
339 var focus layout.PaneID
340 var focusedPaneID domain.PaneStableID
341 for _, pane := range tb.panes {
342 if pane != nil && domain.PaneStableID(pane.stableID) == view.paneID {
343 focus = pane.id
344 focusedPaneID = domain.PaneStableID(pane.stableID)
345 break
346 }
347 }
348 if focusedPaneID == "" && (tb.floating.state != floatingVisible || tb.floating.pane == nil) {
349 // A removed input target is not a committed semantic view. Its owning
350 // topology mutation will repair/invalidate the attachment separately.
351 return nil, false
352 }
353 layoutSnap := tabLayoutSnapshot{
354 area: area, focus: focus, placements: scratch.placements,
355 dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid,
356 }
357 var layoutRoot *layout.Node
358 if tb.tree != nil {
359 layoutRoot = tb.tree.Root
360 }
361 if scratch.layoutTab != tb || scratch.layoutRoot != layoutRoot || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus {
362 layoutSnap = solveTabLayoutLocked(tb)
363 layoutSnap.focus = focus
364 scratch.placements = append(scratch.placements[:0], layoutSnap.placements...)
365 scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...)
366 scratch.layoutTab = tb
367 scratch.layoutRoot = layoutRoot
368 scratch.layoutGeneration = tb.layoutGeneration
369 scratch.layoutArea = area
370 scratch.layoutFocus = focus
371 scratch.layoutFingerprint = layoutSnap.fingerprint
372 scratch.layoutValid = layoutSnap.ok
373 layoutSnap.placements = scratch.placements
374 layoutSnap.dividers = scratch.dividers
375 }
376 state := &scratch.state
377 window := domain.Size{}
378 if view.windowSet {
379 window = ac.sizeSnapshot()
380 }
381 *state = capturedRenderState{
382 attachment: ac, sessionID: sessionID, route: route, incarnation: incarnation, lease: lease, view: view, window: window,
383 reset: reset, bars: bars, theme: bars.theme,
384 uiFence: request.uiFence,
385 focusedPaneID: focusedPaneID,
386 styles: request.styles, styleGeneration: request.styleGeneration,
387 overlays: overlays,
388 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
389 floatingGeneration: tb.floating.generation,
390 receipts: scratch.receipts[:0],
391 }
392 state.panes = scratch.panes[:0]
393 state.tabGeneration = uint64(len(layoutSnap.fingerprint))
394 for _, placement := range layoutSnap.placements {
395 p := tb.panes[placement.ID]
396 if p == nil {
397 continue
398 }
399 visible := placement.Content
400 if placement.Collapsed {
401 visible = domain.Rect{}
402 }
403 p.mu.Lock()
404 captured := capturePaneRenderStateLockedInto(p, visible, ac.render.paneSnapshot(p))
405 if !ac.terminalCapabilities.SupportsKittyGraphics() {
406 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
407 state.suppressedGraphics = true
408 }
409 captured.graphics = nil
410 }
411 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
412 captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus
413 if captured.focused {
414 state.cursor = captureCursorInputsLocked(p, placement.Content)
415 }
416 p.mu.Unlock()
417 translated := captured.rawDamage[:0]
418 for _, damage := range captured.damage {
419 translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...)
420 }
421 captured.rawDamage, captured.damage = captured.damage, translated
422 ac.render.storePaneSnapshot(p, captured)
423 state.panes = append(state.panes, captured)
424 }
425 scratch.panes = state.panes
426 if tb.floating.state == floatingVisible && tb.floating.pane != nil {
427 p := tb.floating.pane
428 p.mu.Lock()
429 geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg))
430 captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.render.paneSnapshot(p))
431 if !ac.terminalCapabilities.SupportsKittyGraphics() {
432 if captured.graphics != nil && captured.graphics.Usage().Placements != 0 {
433 state.suppressedGraphics = true
434 }
435 captured.graphics = nil
436 }
437 seen := false
438 for _, receipt := range state.receipts {
439 if receipt.pane == p {
440 seen = true
441 break
442 }
443 }
444 if !seen {
445 state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration})
446 }
447 ac.render.storePaneSnapshot(p, captured)
448 // A visible floating pane is the terminal input target, so its structural
449 // border carries the focused semantic role independently of its content.
450 state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration}
451 state.cursor = captureCursorInputsLocked(p, geometry.Inner)
452 p.mu.Unlock()
453 }
454 scratch.receipts = state.receipts
455 return state, true
456}
457
458// captureRenderState preserves the focused local test seam. Production
459// rendering delegates through *session.captureRenderState.
460func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
461 return captureLocalRenderState(sess, ac, request)
462}
463
464// copyRankedRecentInto preserves the non-nil empty slice that selects
465// contextual recent-session mode while retaining scratch capacity for reuse.
466func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent {
467 if len(src) == 0 {
468 if dst != nil {
469 return dst[:0]
470 }
471 return []rankedRecent{}
472 }
473 return append(dst[:0], src...)
474}
475
476func captureCursorInputsLocked(p *pane, content domain.Rect) capturedCursorInputs {
477 style, hasStyle := p.screen.CursorStyle()
478 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, content: content}
479}
480
481// appendStackPaneIDs returns exactly the panes whose solved placement can own a
482// stack title bar. The caller holds tab.mu, and dst is attachment scratch.
483func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID {
484 if node == nil {
485 return dst
486 }
487 if node.Kind == layout.Stack {
488 for _, child := range node.Children {
489 if child != nil && child.Kind == layout.Leaf {
490 dst = append(dst, child.Leaf)
491 }
492 }
493 return dst
494 }
495 for _, child := range node.Children {
496 dst = appendStackPaneIDs(dst, child)
497 }
498 return dst
499}