package daemon import ( renderer "github.com/bnema/vev-vt" vevgraphics "github.com/bnema/vev-vt/graphics" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/protocol" scopy "github.com/bnema/vev/internal/usecase/copy" "github.com/bnema/vev/internal/usecase/layout" themeui "github.com/bnema/vev/internal/usecase/theme" "github.com/bnema/vev/internal/usecase/ui" ) // capturedRenderState is the immutable hand-off from authoritative daemon // state to composition. Every field is a value or an owned bounded copy; the // composer must not retain or consult session, tab, pane, or overlay owners. // renderCaptureScratch owns bounded snapshots for one attachment. The complete // capture/compose/emit transaction holds that attachment's sendMu, so these // buffers cannot be observed or reused before emission completes. type renderCaptureScratch struct { state capturedRenderState panes []capturedPaneRenderState placements []layout.Placement dividers []layout.Divider layoutTab *tab layoutRoot *layout.Node layoutGeneration uint64 layoutArea domain.Rect layoutFocus layout.PaneID layoutFingerprint string layoutValid bool statusTabs []statusTab mru []recentRouteDisplay ranked []rankedRecent titleIDs []layout.PaneID receipts []damageReceipt } type damageReceipt struct { pane *pane generation uint64 } type capturedRenderState struct { attachment *attachedClient // identity only; never dereferenced by composition sessionID domain.SessionID route protocol.CommittedRouteIdentity incarnation domain.IncarnationID lease *attachmentLease uiFence uint64 frameCapture uint64 // numbers this capture in the attachment's afterFrame queue view attachmentView focusedPaneID domain.PaneStableID window domain.Size reset bool layout capturedTabLayout panes []capturedPaneRenderState floating capturedFloatingRenderState bars barState theme themeui.Theme styles themeui.Styles styleGeneration uint64 overlays capturedOverlayRenderState // suppressedGraphics records a late Kitty scene observed on an attachment // that cannot emit graphics. Capture keeps the scene out of the output but // lets the daemon issue one bounded user warning after pane locks release. suppressedGraphics bool cursor capturedCursorInputs tabGeneration uint64 floatingGeneration uint64 receipts []damageReceipt // private capture receipts; compose must not inspect these } // viewContext describes only this captured composition, never live session state. func (s *capturedRenderState) viewContext() protocol.ViewContext { context := protocol.ViewContext{Route: s.route, TabID: s.view.tabID, FocusedPaneID: s.focusedPaneID} if s.floating.visible && s.floating.focused { context.FocusedPaneID = s.floating.pane.stableID return context } for _, pane := range s.panes { if pane.focused { context.FocusedPaneID = pane.stableID break } } return context } type capturedTabLayout struct { area domain.Rect focus layout.PaneID placements []layout.Placement dividers []layout.Divider fingerprint string valid bool } type capturedPaneRenderState struct { id layout.PaneID stableID domain.PaneStableID frame renderer.Frame rawDamage []renderer.Damage damage []renderer.Damage damageGeneration uint64 // graphics is an immutable copy-on-write reference owned by the VT screen. // It is nil for ordinary text panes, preserving the fast text path. graphics *vevgraphics.Snapshot graphicsGeometry domain.Geometry title string titleGeneration uint64 placement layout.Placement focused bool } type capturedFloatingRenderState struct { visible bool focused bool pane capturedPaneRenderState geometry floatingGeometry title string generation uint64 titleGeneration uint64 } type capturedOverlayRenderState struct { copyActive, copySearchActive, paletteActive, promptActive bool noticesOverlayActive, resizeActive bool copyMode *scopy.Mode copyPaneID layout.PaneID statusFeedback string paletteGuidance string copySearch, palette, prompt, noticesOverlay capturedModal notices []domain.Notification noticeOverflow int } type capturedModal struct { active bool title string presentation ui.Presentation inner renderer.Frame focused bool } func (o capturedOverlayRenderState) active() bool { return o.copyActive || o.copySearchActive || o.paletteActive || o.promptActive || o.noticesOverlayActive } type capturedCursorInputs struct { row, col, style int hasStyle, visible, renderable, hiddenByOverlay bool content domain.Rect } // writePaneScreenLocked is the production VT write boundary. The first // placement fixes the current scene's coordinate units before a later resize // can change Screen.Geometry; deleting the last placement ends that boundary. // Caller holds p.mu. func writePaneScreenLocked(p *pane, data []byte) { if p == nil || p.screen == nil { return } before := p.screen.GraphicsSnapshot() beforeHasPlacements := before != nil && before.Usage().Placements != 0 p.screen.Write(data) after := p.screen.GraphicsSnapshot() afterHasPlacements := after != nil && after.Usage().Placements != 0 if !beforeHasPlacements && afterHasPlacements { geometry := p.screen.Geometry() p.graphicsCoordinateGeometry = domain.Geometry{ Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows}, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight, }.NormalizePixels() } p.graphicsPlacementScene = afterHasPlacements if !afterHasPlacements { p.graphicsCoordinateGeometry = domain.Geometry{} } } // capturePaneRenderStateLockedInto copies only the visible rectangle required // by composition into out. It never copies scrollback/history and never lets // the mutable VT frame's Cells or row-offset slices escape pane.mu. func capturePaneRenderStateLockedInto(p *pane, visible domain.Rect, out capturedPaneRenderState) capturedPaneRenderState { out.id, out.stableID, out.title, out.titleGeneration = p.id, domain.PaneStableID(p.stableID), p.displayTitleLocked(), p.title.generation // GraphicsSnapshot never allocates graphics state for a text-only screen and // returns an immutable scene reference when Kitty graphics were used. out.graphics = p.screen.GraphicsSnapshot() geometry := p.screen.Geometry() currentGraphicsGeometry := domain.Geometry{ Size: domain.Size{Cols: geometry.Cols, Rows: geometry.Rows}, PixelWidth: geometry.PixelWidth, PixelHeight: geometry.PixelHeight, }.NormalizePixels() // The scene retains placement coordinates across SetGeometry. Capture the // unit basis when the first placement appears and keep it until the placement // scene becomes empty; otherwise an attachment resize silently reinterprets // old pixel coordinates using the new cell dimensions. hasPlacements := out.graphics != nil && out.graphics.Usage().Placements != 0 if hasPlacements && !p.graphicsPlacementScene { p.graphicsCoordinateGeometry = currentGraphicsGeometry } p.graphicsPlacementScene = hasPlacements if hasPlacements { out.graphicsGeometry = p.graphicsCoordinateGeometry } else { p.graphicsCoordinateGeometry = domain.Geometry{} out.graphicsGeometry = currentGraphicsGeometry } damage := p.screen.CaptureDamage() out.damageGeneration = damage.Generation out.rawDamage = append(out.rawDamage[:0], damage.Damage...) width := min(max(visible.Width, 0), p.screen.Columns()) height := min(max(visible.Height, 0), p.screen.Rows()) needsFrame := len(damage.Damage) > 0 frameChanged := out.frame.Width != width || out.frame.Height != height if frameChanged { out.frame = renderer.NewFrame(width, height) needsFrame = true } // A retained attachment snapshot is already an immutable copy of this // pane. With no VT damage, retain it rather than copying inactive panes. if needsFrame { for y := range height { for x := range width { out.frame.Set(x, y, p.screen.Cell(x, y)) } } } if frameChanged || uncertainDamage(damage.Damage, p.screen.Columns(), p.screen.Rows()) { // A cache entry with a new frame has no terminal-shadow equivalent. // Redraw it even when the pane has no VT damage, such as an untouched // pane first made visible after a tab or session switch. out.damage = []renderer.Damage{renderer.FullRedraw()} } else { out.damage = append(out.damage[:0], damage.Damage...) } // Consumption is transactional: capture only snapshots damage. publishFrameLocked // acknowledges this generation after preparation and transport success. return out } func uncertainDamage(damage []renderer.Damage, width, height int) bool { for _, d := range damage { if d.Kind == renderer.DamageFullRedraw { if d != renderer.FullRedraw() { return true } continue } 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 { return true } if d.Kind == renderer.DamageScrollUp && (d.Count <= 0 || d.Count > d.Height) { return true } } return false } type renderCaptureRequest struct { bars barState overlays capturedOverlayRenderState floatingCfg domain.FloatingConfig styles themeui.Styles styleGeneration uint64 reset bool lease *attachmentLease uiFence uint64 } // captureLocalRenderState is the ownership boundary for a local attachment // render transaction. Callers hold attachment sendMu; this function then follows // session -> tab -> pane lock order. ACK-blocked capture returns before touching // VT damage, and every captured pane records a receipt for successful emission. func captureLocalRenderState( sess *session, ac *attachedClient, request renderCaptureRequest, ) (*capturedRenderState, bool) { bars := request.bars overlays := request.overlays floatingCfg := request.floatingCfg reset := request.reset lease := request.lease if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) { return nil, false } sess.mu.Lock() _, owned := sess.attachments[ac] sessionID := sess.id incarnation := sess.incarnation route := protocol.CommittedRouteIdentity{ Target: protocol.ExactSessionTarget{LifecycleID: incarnation, SessionName: sess.name}, Ephemeral: sess.ephemeral, } sess.mu.Unlock() if !owned { return nil, false } tb := sess.tabForAttachment(ac) if tb == nil { return nil, false } // Resolve all pane selection from one view value. Navigation after this // point invalidates the captured revision at prepared-output admission. view := ac.viewSnapshot() if view.tabID != domain.TabStableID(tb.stableID) { return nil, false } // A reset is an attachment-local capture boundary. The shared pane damage // receipt may have been acknowledged by another fan-out attachment before // this capture runs, so retaining this attachment's prior pane snapshots // would compose an old frame with a fresh reset epoch. if reset { ac.render.forgetAllPanes() } scratch := &ac.render.capture scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...) bars.status.tabs = scratch.statusTabs scratch.mru = append(scratch.mru[:0], bars.mru...) bars.mru = scratch.mru if bars.rankedRecent != nil { scratch.ranked = copyRankedRecentInto(scratch.ranked, bars.rankedRecent) bars.rankedRecent = scratch.ranked } tb.mu.Lock() defer tb.mu.Unlock() area := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows} var focus layout.PaneID var focusedPaneID domain.PaneStableID for _, pane := range tb.panes { if pane != nil && domain.PaneStableID(pane.stableID) == view.paneID { focus = pane.id focusedPaneID = domain.PaneStableID(pane.stableID) break } } if focusedPaneID == "" && (tb.floating.state != floatingVisible || tb.floating.pane == nil) { // A removed input target is not a committed semantic view. Its owning // topology mutation will repair/invalidate the attachment separately. return nil, false } layoutSnap := tabLayoutSnapshot{ area: area, focus: focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: scratch.layoutFingerprint, ok: scratch.layoutValid, } var layoutRoot *layout.Node if tb.tree != nil { layoutRoot = tb.tree.Root } if scratch.layoutTab != tb || scratch.layoutRoot != layoutRoot || scratch.layoutGeneration != tb.layoutGeneration || scratch.layoutArea != area || scratch.layoutFocus != focus { layoutSnap = solveTabLayoutLocked(tb) layoutSnap.focus = focus scratch.placements = append(scratch.placements[:0], layoutSnap.placements...) scratch.dividers = append(scratch.dividers[:0], layoutSnap.dividers...) scratch.layoutTab = tb scratch.layoutRoot = layoutRoot scratch.layoutGeneration = tb.layoutGeneration scratch.layoutArea = area scratch.layoutFocus = focus scratch.layoutFingerprint = layoutSnap.fingerprint scratch.layoutValid = layoutSnap.ok layoutSnap.placements = scratch.placements layoutSnap.dividers = scratch.dividers } state := &scratch.state window := domain.Size{} if view.windowSet { window = ac.sizeSnapshot() } *state = capturedRenderState{ attachment: ac, sessionID: sessionID, route: route, incarnation: incarnation, lease: lease, view: view, window: window, reset: reset, bars: bars, theme: bars.theme, uiFence: request.uiFence, focusedPaneID: focusedPaneID, styles: request.styles, styleGeneration: request.styleGeneration, overlays: overlays, layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, floatingGeneration: tb.floating.generation, receipts: scratch.receipts[:0], } state.panes = scratch.panes[:0] state.tabGeneration = uint64(len(layoutSnap.fingerprint)) for _, placement := range layoutSnap.placements { p := tb.panes[placement.ID] if p == nil { continue } visible := placement.Content if placement.Collapsed { visible = domain.Rect{} } p.mu.Lock() captured := capturePaneRenderStateLockedInto(p, visible, ac.render.paneSnapshot(p)) if !ac.terminalCapabilities.SupportsKittyGraphics() { if captured.graphics != nil && captured.graphics.Usage().Placements != 0 { state.suppressedGraphics = true } captured.graphics = nil } state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) captured.placement, captured.focused = placement, placement.ID == layoutSnap.focus if captured.focused { state.cursor = captureCursorInputsLocked(p, placement.Content, overlays) } p.mu.Unlock() translated := captured.rawDamage[:0] for _, damage := range captured.damage { translated = append(translated, translatePaneDamage(damage, placement.Content, layoutSnap.area)...) } captured.rawDamage, captured.damage = captured.damage, translated ac.render.storePaneSnapshot(p, captured) state.panes = append(state.panes, captured) } scratch.panes = state.panes if tb.floating.state == floatingVisible && tb.floating.pane != nil { p := tb.floating.pane p.mu.Lock() geometry := p.committedFloatingGeometryLocked(calculateContentFloatingGeometry(domain.Size{Cols: layoutSnap.area.Width, Rows: layoutSnap.area.Height}, floatingCfg)) captured := capturePaneRenderStateLockedInto(p, geometry.Inner, ac.render.paneSnapshot(p)) if !ac.terminalCapabilities.SupportsKittyGraphics() { if captured.graphics != nil && captured.graphics.Usage().Placements != 0 { state.suppressedGraphics = true } captured.graphics = nil } seen := false for _, receipt := range state.receipts { if receipt.pane == p { seen = true break } } if !seen { state.receipts = append(state.receipts, damageReceipt{pane: p, generation: captured.damageGeneration}) } ac.render.storePaneSnapshot(p, captured) // A visible floating pane is the terminal input target, so its structural // border carries the focused semantic role independently of its content. state.floating = capturedFloatingRenderState{visible: true, focused: true, pane: captured, geometry: geometry, title: captured.title, generation: tb.floating.generation, titleGeneration: captured.titleGeneration} state.cursor = captureCursorInputsLocked(p, geometry.Inner, overlays) p.mu.Unlock() } scratch.receipts = state.receipts return state, true } // captureRenderState preserves the focused local test seam. Production // rendering delegates through *session.captureRenderState. func captureRenderState(sess *session, ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) { return captureLocalRenderState(sess, ac, request) } // copyRankedRecentInto preserves the non-nil empty slice that selects // contextual recent-session mode while retaining scratch capacity for reuse. func copyRankedRecentInto(dst, src []rankedRecent) []rankedRecent { if len(src) == 0 { if dst != nil { return dst[:0] } return []rankedRecent{} } return append(dst[:0], src...) } func captureCursorInputsLocked(p *pane, content domain.Rect, overlays capturedOverlayRenderState) capturedCursorInputs { style, hasStyle := p.screen.CursorStyle() hidden := overlays.copyActive || overlays.copySearchActive || overlays.paletteActive || overlays.promptActive || overlays.noticesOverlayActive 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} } // appendStackPaneIDs returns exactly the panes whose solved placement can own a // stack title bar. The caller holds tab.mu, and dst is attachment scratch. func appendStackPaneIDs(dst []layout.PaneID, node *layout.Node) []layout.PaneID { if node == nil { return dst } if node.Kind == layout.Stack { for _, child := range node.Children { if child != nil && child.Kind == layout.Leaf { dst = append(dst, child.Leaf) } } return dst } for _, child := range node.Children { dst = appendStackPaneIDs(dst, child) } return dst }