1package daemon
2
3import (
4 "errors"
5 "slices"
6
7 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/domain/layout"
10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/protocol"
12 "github.com/bnema/vev/internal/usecase/notices"
13 "github.com/bnema/vev/internal/usecase/palette"
14 "github.com/bnema/vev/internal/usecase/prompt"
15 themeui "github.com/bnema/vev/internal/usecase/theme"
16 "github.com/bnema/vev/internal/usecase/ui"
17 "github.com/bnema/vev/internal/usecase/visualsearch"
18)
19
20// composeCacheInput is an attachment-local value snapshot. composeFrame never
21// retains or accesses the attachment that owns it.
22// renderStageHooks are optional observability hooks. capture and compose run
23// after their result is available, and emit after prepare plus transport success
24// (including a successful no-byte emission); those hooks run while sendMu is
25// held. Failed attempts count for capture/compose but never for emit.
26// handoffRebase marks the boundary immediately before a handoff acquires sendMu
27// to rebase attachment-owned output state, so it intentionally runs unlocked.
28type renderStageHooks struct {
29 capture func()
30 compose func()
31 emit func()
32 handoffRebase func()
33}
34
35type composeCacheInput struct {
36 valid bool
37 frame renderer.Frame
38 layoutFingerprint string
39 titleGenerations map[layout.PaneID]uint64
40 damage []renderer.Damage
41 floatingVisible bool
42 floatingFocused bool
43 floatingGeneration uint64
44 floatingGeometry floatingGeometry
45 floatingTitleGeneration uint64
46 bars barCache
47 toastFootprints []domain.Rect
48 theme themeui.Theme
49 styleGeneration uint64
50 copyViewport copyViewportState
51}
52
53type composedRenderFrame struct {
54 frame renderer.Frame
55 damage []renderer.Damage
56 cursor cursorOut
57 cache composeCacheInput
58 reset bool
59}
60
61// composeFrame is pure with respect to daemon ownership: it consumes only the
62// capture, the last committed cache, and an independent attachment-owned
63// scratch cache. It returns a new cache without mutating committed.
64const inactivePaneForegroundDimming = 55
65
66// clearOutsideLayout blanks the content cells that the shared layout does not
67// cover. A window larger than the shared session geometry reuses its previous
68// frame, so without this the cells of a wider or taller earlier layout stay on
69// screen.
70func clearOutsideLayout(frame renderer.Frame, content, area domain.Rect) {
71 blank := renderer.BlankCell()
72 for y := content.Y; y < content.Y+content.Height; y++ {
73 start := area.Width
74 if y-content.Y >= area.Height {
75 start = 0
76 }
77 frame.FillRow(y, content.X+start, content.X+content.Width, blank)
78 }
79}
80
81func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
82 scratch := composeCacheInput{}
83 if len(scratchIn) > 0 {
84 scratch = scratchIn[0]
85 }
86 width, rows := state.layout.area.Width, state.layout.area.Height
87 if state.window.Valid() {
88 window := contentSize(state.window)
89 width, rows = window.Cols, window.Rows
90 }
91 if width <= 0 || rows < 0 {
92 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
93 }
94 // Compose into the alternate attachment-owned buffer. The committed frame is
95 // copied before incremental drawing so prepare/send failures cannot change it.
96 contentY := 1
97 frameHeight := rows + tabChromeRows
98 canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight
99 frame := scratch.frame
100 if !canReuseFrame {
101 frame = renderer.NewFrame(width, frameHeight)
102 }
103 if in.frame.Width == width && in.frame.Height == frameHeight {
104 if state.overlays.copyMode != nil {
105 // A structural copy preserves page-local style IDs. Re-interning every
106 // cell costs more while scrolling. Copying into the spare page keeps
107 // committed state isolated from failed publications without a new page.
108 frame.CopyFrom(in.frame)
109 } else {
110 for y := 0; y < frame.Height; y++ {
111 for x := range frame.Width {
112 frame.Set(x, y, in.frame.Cell(x, y))
113 }
114 }
115 }
116 }
117 styles := state.styles
118 if styles == (themeui.Styles{}) {
119 // Direct/test-only composition has no applied snapshot; production
120 // capture always supplies immutable styles.
121 styles = fallbackChromeStyles
122 }
123 state.styles = styles
124 defaultDimmer := themeui.NewDimmer(state.theme)
125 neutralBorder := styles.NeutralBorder
126 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
127 // The alternate cache owns these rows; the committed cache remains intact
128 // until prepare/send succeeds. Row extraction would allocate on each paint.
129 topBar := slices.Grow(scratch.bars.top[:0], width)[:width]
130 drawTopBarSnapshot(topBar, state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
131 frame.WriteRow(0, 0, topBar)
132 bottomBar := slices.Grow(scratch.bars.bottom[:0], width)[:width]
133 drawStatusBarState(bottomBar, state.bars, styles)
134 frame.WriteRow(rows+1, 0, bottomBar)
135 content := domain.Rect{Y: contentY, Width: width, Height: rows}
136 if state.layout.valid {
137 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
138 }
139
140 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != frameHeight || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.styleGeneration != state.styleGeneration || in.floatingVisible != state.floating.visible
141 if full {
142 clearOutsideLayout(frame, content, state.layout.area)
143 }
144 titles := scratch.titleGenerations
145 if titles == nil {
146 titles = make(map[layout.PaneID]uint64, len(state.panes))
147 }
148 clear(titles)
149 damage := scratch.damage[:0]
150 for _, pane := range state.panes {
151 pl := offsetPlacement(pane.placement, 0, contentY)
152 if pl.TitleBar.Height > 0 {
153 drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, neutralBorder, defaultDimmer)
154 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
155 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
156 }
157 titles[pane.id] = pane.titleGeneration
158 }
159 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
160 // Reduced-color chrome never recolors or fades pane content; focus is
161 // shown by the title bar and dividers.
162 blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused && !state.theme.DimByAttribute, inactivePaneDimmer)
163 }
164 for _, d := range pane.damage {
165 if d.Kind != renderer.DamageFullRedraw {
166 d.Y += contentY
167 }
168 damage = append(damage, d)
169 }
170 }
171 // Keep the committed cache unadorned. Toasts are composed first so floating
172 // terminals and scoped modals dim them with the rest of the complete frame.
173 baseFrame := frame
174 overlaysActive := state.overlays.active()
175 toastsVisible := len(state.overlays.notices) > 0 || state.overlays.noticeOverflow > 0
176 var toastFootprints []domain.Rect
177 if toastsVisible {
178 frame = baseFrame.Clone()
179 toastFootprints = composeCapturedNotices(state.overlays, frame, state.styles)
180 }
181 if state.floating.visible {
182 var floatingDamage []renderer.Damage
183 frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
184 baseFrame: frame,
185 baseDamage: damage,
186 floating: state.floating,
187 content: content,
188 theme: state.theme,
189 borderMuted: styles.BorderMuted,
190 borderActive: styles.BorderActive,
191 cache: in,
192 full: full || overlaysActive,
193 })
194 damage = floatingDamage
195 }
196 copyOnly := state.overlays.copyMode != nil && state.overlays.copyActive &&
197 !state.overlays.copySearchActive &&
198 !state.overlays.paletteActive && !state.overlays.promptActive &&
199 !state.overlays.noticesOverlayActive && !state.overlays.resizeActive && !state.floating.visible
200 var copyViewport copyViewportState
201 var scrollDamage []renderer.Damage
202 if copyOnly && !toastsVisible {
203 copyViewport = captureCopyViewport(state, content)
204 if !full {
205 scrollDamage = copyViewportDamage(in.copyViewport, copyViewport, frame)
206 }
207 }
208 if overlaysActive {
209 if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight {
210 frame = composeScrolledCopyViewport(state, baseFrame, scratch.copyViewport.frame, in.copyViewport, copyViewport, scrollDamage)
211 } else {
212 switch {
213 case copyOnly && !toastsVisible:
214 // A plain copy viewport becomes the next viewport cache, so build
215 // it in the spare viewport page, which never shares baseFrame's.
216 frame = scratch.copyViewport.frame
217 frame.CopyFrom(baseFrame)
218 case !toastsVisible && !state.floating.visible:
219 frame = baseFrame.Clone()
220 }
221 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
222 }
223 frame, damage = composeCapturedOverlays(state, frame, damage)
224 }
225 for x := range width {
226 topBar[x] = baseFrame.Cell(x, 0)
227 bottomBar[x] = baseFrame.Cell(x, rows+1)
228 }
229 if !full {
230 if !sameCells(in.bars.top, topBar) {
231 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
232 }
233 if !sameCells(in.bars.bottom, bottomBar) {
234 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: rows + 1, Width: width, Height: 1})
235 }
236 }
237 // The cache stays toast-free. The terminal shadow, in contrast, includes
238 // toasts, so every render damages both the last and current toast coverage.
239 // This restores cells exposed by dismissal and redraws a stable toast over
240 // any underlying pane update without promoting either case to a full frame.
241 if full || overlaysActive {
242 damage = []renderer.Damage{renderer.FullRedraw()}
243 } else {
244 damage = appendToastDamage(damage, in.toastFootprints)
245 damage = appendToastDamage(damage, toastFootprints)
246 }
247 cursor := desiredCapturedCursor(state.cursor, contentY)
248 if overlaysActive {
249 cursor = overlayInputCursor(state.overlays, state.cursor, width, frameHeight)
250 }
251 // A plain copy viewport is painted on a clone or the spare viewport page,
252 // never into baseFrame. Keep
253 // that unadorned base reusable between wheel events. Modal/floating paths
254 // retain their conservative invalidation, and copy exit requests a reset.
255 outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
256 outCache.bars = barCache{top: topBar, bottom: bottomBar}
257 if copyViewport.document != nil {
258 copyViewport.frame = frame
259 outCache.copyViewport = copyViewport
260 }
261 if scrollDamage != nil {
262 damage = scrollDamage
263 }
264 // A copy viewport is ordinary terminal content, not a new output epoch.
265 // Keep conservative resets for other overlays and explicit resynchronization.
266 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || (overlaysActive && !copyOnly)}
267}
268
269func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
270 if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height {
271 return
272 }
273 style := styles.StatusBar
274 if !focused {
275 style = dimmer.Dim(neutralBorder)
276 }
277 for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ {
278 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
279 }
280 ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style)
281}
282
283func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
284 if !c.renderable || !c.visible {
285 return cursorOut{hidden: true}
286 }
287 style := c.style
288 if !c.hasStyle {
289 style = 1
290 }
291 return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true}
292}
293
294// overlayInputCursor places the terminal cursor on the input caret of the
295// topmost modal, in the same priority order composeCapturedOverlays paints.
296// A real cursor (rather than a drawn caret) lets client predictive echo type
297// ahead on the input line. Any other overlay hides the cursor. The caret keeps
298// the focused pane's cursor shape, so opening a modal never changes it.
299func overlayInputCursor(o capturedOverlayRenderState, pane capturedCursorInputs, width, height int) cursorOut {
300 for _, modal := range []capturedModal{o.prompt, o.palette, o.noticesOverlay, o.copySearch} {
301 if !modal.active {
302 continue
303 }
304 row, col := modal.presentation.Inner.Y, modal.presentation.Inner.X+modal.caretCol
305 if !modal.hasCaret || row < 0 || row >= height || col < 0 || col >= width {
306 break
307 }
308 style := 1
309 if pane.hasStyle {
310 style = pane.style
311 }
312 return cursorOut{valid: true, row: row, col: col, style: style, hasStyle: true}
313 }
314 return cursorOut{hidden: true}
315}
316
317func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
318 if state == nil || snap == nil {
319 return
320 }
321 o := &state.overlays
322 o.copyActive, o.copySearchActive, o.paletteActive, o.promptActive = snap.copyActive, snap.copySearchModel != nil, snap.paletteActive, snap.promptActive
323 o.noticesOverlayActive = snap.noticesOverlayActive
324 o.copyMode = snap.copyMode
325 o.notices, o.noticeOverflow = snap.notices, snap.noticeOverflow
326 if snap.copyPane != nil {
327 o.copyPaneID = snap.copyPane.id
328 }
329 styles := state.styles
330 if styles == (themeui.Styles{}) {
331 styles = fallbackChromeStyles
332 }
333 rows := state.layout.area.Height
334 rows += tabChromeRows
335 size := domain.Size{Cols: state.layout.area.Width, Rows: rows}
336 if snap.copySearchModel != nil {
337 presentation := copySearchModal.Resolve(size)
338 o.copySearch = capturedModal{active: true, title: copySearchModal.Title, presentation: presentation, focused: true}
339 o.copySearch.inner = snap.copySearchModel.RenderStyled(rectSize(presentation.Inner), visualsearch.RenderStyles{Base: styles.PromptBase, Selection: styles.SearchSelection})
340 o.copySearch.caretCol, o.copySearch.hasCaret = snap.copySearchModel.Caret(rectSize(presentation.Inner))
341 }
342 if snap.noticesOverlayActive && snap.noticesOverlayModel != nil {
343 presentation := noticesModal.Resolve(size)
344 o.noticesOverlay = capturedModal{active: true, title: noticesModal.Title, presentation: presentation, focused: true}
345 renderStyles := notices.RenderStyles{Background: styles.PickerBase, Base: styles.PickerBase, Selection: styles.PickerSelection, Text: styles.PickerBase, SelectionText: styles.PickerSelectionName, Muted: styles.PickerDescription, SelectionMuted: styles.PickerSelectionMuted}
346 o.noticesOverlay.inner = snap.noticesOverlayModel.Render(rectSize(presentation.Inner), renderStyles)
347 }
348 if snap.paletteActive && snap.paletteModel != nil {
349 modal := paletteModalFor(paletteCfg)
350 presentation := modal.Resolve(size)
351 o.palette = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
352 guidance := ""
353 if snap.paletteHints != nil {
354 guidance = snap.paletteHints.Feedback
355 }
356 o.paletteGuidance = snap.paletteFeedback
357 o.palette.inner = snap.paletteModel.Render(rectSize(presentation.Inner), palette.RenderOptions{Styles: palette.RenderStyles{Base: styles.PickerBase, Row: styles.PickerBase, Selection: styles.PickerSelection, Description: styles.PickerDescription, SelectionDescription: styles.PickerSelectionMuted}, Guidance: guidance, Preview: snap.palettePreview, Feedback: snap.paletteFeedback})
358 o.palette.caretCol, o.palette.hasCaret = snap.paletteModel.Caret(rectSize(presentation.Inner))
359 }
360 if snap.promptActive && snap.promptModel != nil {
361 modal := promptModalFor(snap.promptModel.Title())
362 presentation := modal.Resolve(size)
363 o.prompt = capturedModal{active: true, title: modal.Title, presentation: presentation, focused: true}
364 selection := styles.Selection
365 // Reduced copy selection is plain reverse; prompts retain bold emphasis.
366 if styles.Reduced {
367 selection = styles.SurfaceActive
368 }
369 o.prompt.inner = snap.promptModel.RenderStyled(rectSize(presentation.Inner), prompt.RenderStyles{Base: styles.PromptBase, Selection: selection})
370 o.prompt.caretCol, o.prompt.hasCaret = snap.promptModel.Caret(rectSize(presentation.Inner))
371 }
372}
373
374func composeCapturedCopyMode(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage, content domain.Rect) (renderer.Frame, []renderer.Damage) {
375 o := state.overlays
376 if !o.copyActive {
377 return frame, damage
378 }
379 target := capturedCopyTarget(state, content)
380 return composeCopyClientFrame(o.copyMode, target, frame, state.styles)
381}
382
383func capturedCopyTarget(state capturedRenderState, content domain.Rect) domain.Rect {
384 o := state.overlays
385 target := domain.Rect{}
386 if state.floating.visible && (o.copyPaneID == "" || o.copyPaneID == state.floating.pane.id) {
387 target = state.floating.geometry.translate(content.X, content.Y).Inner
388 }
389 if target.Width == 0 {
390 id := o.copyPaneID
391 if id == "" {
392 id = state.layout.focus
393 }
394 for _, p := range state.panes {
395 if p.id == id && !p.placement.Collapsed {
396 target = p.placement.Content
397 target.Y += content.Y
398 break
399 }
400 }
401 }
402 return target
403}
404
405func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, damage []renderer.Damage) (renderer.Frame, []renderer.Damage) {
406 o := state.overlays
407 // Paint in reverse keyboard priority so the same layer that owns input is
408 // visually topmost: prompt > palette > notices > copy search.
409 for _, modal := range []capturedModal{o.copySearch, o.noticesOverlay, o.palette, o.prompt} {
410 if !modal.active {
411 continue
412 }
413 applyOverlayBackdrop(frame, state.theme)
414 border := state.styles.BorderMuted
415 if modal.focused {
416 border = state.styles.BorderActive
417 }
418 (ui.Modal{Title: modal.title}).CompositePresentation(frame, modal.presentation, border, state.styles.PickerBase)
419 copyModalInner(frame, modal.presentation.Inner, modal.inner)
420 damage = []renderer.Damage{renderer.FullRedraw()}
421 }
422 return frame, damage
423}
424
425// copyModalInner copies a captured model frame into its resolved destination.
426// Both source and destination are clipped so degenerate presentations remain
427// safe immutable composition inputs.
428func copyModalInner(dst renderer.Frame, target domain.Rect, src renderer.Frame) {
429 left := max(target.X, 0)
430 top := max(target.Y, 0)
431 right := min(target.X+target.Width, dst.Width)
432 bottom := min(target.Y+target.Height, dst.Height)
433 if left >= right || top >= bottom {
434 return
435 }
436
437 sourceX := left - target.X
438 sourceY := top - target.Y
439 width := min(right-left, src.Width-sourceX)
440 height := min(bottom-top, src.Height-sourceY)
441 if width <= 0 || height <= 0 {
442 return
443 }
444 for y := range height {
445 for x := range width {
446 dst.Set(left+x, top+y, src.Cell(sourceX+x, sourceY+y))
447 }
448 }
449}
450
451func composeCapturedNotices(overlays capturedOverlayRenderState, frame renderer.Frame, styles themeui.Styles) []domain.Rect {
452 if len(overlays.notices) == 0 && overlays.noticeOverflow == 0 {
453 return nil
454 }
455 views := make([]ui.NoticeView, len(overlays.notices))
456 for i, n := range overlays.notices {
457 views[i] = ui.NoticeView{Severity: n.Severity, Title: n.Code.String(), Message: n.Message, Count: n.Count}
458 }
459 return ui.ComposeNotices(frame, views, overlays.noticeOverflow, noticeStylesFrom(styles))
460}
461
462func appendToastDamage(damage []renderer.Damage, footprints []domain.Rect) []renderer.Damage {
463 for _, footprint := range footprints {
464 if footprint.Width > 0 && footprint.Height > 0 {
465 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: footprint.X, Y: footprint.Y, Width: footprint.Width, Height: footprint.Height})
466 }
467 }
468 return damage
469}
470
471// noticeStylesFrom picks the toast box color per severity from the theme's
472// chrome roles. Warn uses the dedicated BorderWarn role so it reads as
473// distinct from Info instead of sharing BorderMuted with it.
474func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
475 return ui.NoticeStyles{
476 Text: styles.PickerBase,
477 BoxError: styles.BorderActive,
478 BoxWarn: styles.BorderWarn,
479 BoxInfo: styles.BorderMuted,
480 }
481}
482
483// commitDamageReceipts acknowledges only snapshots that reached the client.
484// sendMu serializes attachment transactions; panes are locked individually so
485// no pane lock is held while another is acquired. A stale generation is
486// intentionally acknowledged too: Screen conservatively leaves FullRedraw for
487// the next transaction.
488func commitDamageReceipts(receipts []damageReceipt) {
489 for _, receipt := range receipts {
490 if receipt.pane != nil {
491 receipt.pane.mu.Lock()
492 receipt.pane.screen.AcknowledgeDamage(receipt.generation)
493 receipt.pane.mu.Unlock()
494 continue
495 }
496 }
497}
498
499// frameFollowUp is the work a frame publication leaves for after the
500// attachment's sendMu is released: notices, repaints, and send-error cleanup
501// may re-enter rendering or take session locks, so they never run under it.
502type frameFollowUp struct {
503 prepareErr error
504 sendErr error
505 sendTr ports.ServerConnection
506 warnGraphics bool
507}
508
509// publishFrameLocked is the sole side-effecting half of the pipeline: it
510// revalidates ownership, prepares, sends, and commits or aborts one captured
511// frame. The caller holds ac.sendMu for the complete capture/compose/publish
512// transaction; publishFrameLocked never releases it. It reports false when the
513// frame was rejected before preparation. The returned follow-up must be passed
514// to settleFrame after sendMu is released.
515func (d *Daemon) publishFrameLocked(entry *session, ac *attachedClient, state *capturedRenderState, composed composedRenderFrame, marks *runtimeMarkBatch) (frameFollowUp, bool) {
516 if entry == nil || entry.core() == nil || ac == nil || state == nil {
517 return frameFollowUp{}, false
518 }
519 entry.core().mu.Lock()
520 _, owned := entry.core().attachments[ac]
521 position := protocol.RoutePosition{
522 Target: state.route.Target,
523 ActiveTabID: state.view.tabID,
524 }
525 entry.core().mu.Unlock()
526 if marks.attachmentEffect != nil {
527 if !marks.attachmentEffect.current() || state.attachment != ac {
528 return frameFollowUp{}, false
529 }
530 } else {
531 if !owned || state.attachment != ac || ac.currentAttachmentSession() != entry {
532 return frameFollowUp{}, false
533 }
534 if state.lease != nil {
535 rc := attachmentRenderCoordinator(entry)
536 if rc == nil || state.lease.attachment != ac || !rc.leaseCurrent(state.lease, true) {
537 return frameFollowUp{}, false
538 }
539 }
540 }
541 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
542 prepared, err := ac.output.prepareFrame(d, state, composed.frame, composed.damage, composed.reset, composed.cursor)
543 var data []byte
544 if prepared != nil {
545 data = prepared.data
546 }
547 endDiff(0, err == nil)
548 if err != nil {
549 return frameFollowUp{prepareErr: err}, true
550 }
551 var sendTr ports.ServerConnection
552 var sendErr error
553 rc := attachmentRenderCoordinator(entry)
554 confirmUIFence := rc != nil && rc.needsUIFence(state.lease, state.uiFence)
555 suppressedGraphics := state.suppressedGraphics && !ac.terminalCapabilities.SupportsKittyGraphics()
556 if len(data) > 0 || prepared.ansi.context != nil {
557 sendTransport := ac.transportSnapshot()
558 sendTr = sendTransport.transport
559 endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
560 if sendTr == nil {
561 sendErr = errors.New("client transport is nil")
562 } else {
563 send := sendTr.SendOutput
564 if sendTr.Capabilities().AsyncSend {
565 send = sendTr.SendOutputAsync
566 }
567 interruptible := false
568 if marks.attachmentEffect != nil {
569 interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
570 if !interruptible {
571 sendErr = errAttachmentTransition
572 }
573 }
574 if sendErr == nil {
575 if len(data) > 0 {
576 sendErr = prepared.send(ac.echoAck.Load(), send)
577 } else {
578 sendErr = prepared.publishNoBytes(confirmUIFence, func(update protocol.UIViewUpdate) error {
579 if sendTr.Capabilities().AsyncSend {
580 return sendTr.SendServerAsync(update)
581 }
582 return sendTr.SendServer(update)
583 })
584 }
585 }
586 if marks.attachmentEffect != nil && interruptible {
587 if sendErr != nil {
588 marks.attachmentEffect.reportTransportFailure(sendTransport)
589 }
590 marks.attachmentEffect.endTransportSend()
591 }
592 }
593 emitted := sendErr == nil && prepared.sent()
594 endEmit(uint64(len(data)), emitted)
595 if sendErr == nil && !emitted {
596 return frameFollowUp{}, true
597 }
598 if sendErr != nil {
599 prepared.abort()
600 }
601 }
602 if sendErr == nil {
603 if len(data) == 0 {
604 prepared.commitNoSend()
605 if !prepared.sent() {
606 return frameFollowUp{}, true
607 }
608 }
609 ac.afterFrame.emitted(state.frameCapture)
610 // Publish only after output preparation and transport emission both
611 // succeed. A cross-session transition may publish concurrently, but its
612 // mandatory first-paint rebase waits for sendMu and therefore follows this
613 // completed output transaction.
614 ac.render.commitComposition(composed.cache)
615
616 // A successful no-byte emission also commits: its renderer shadow still
617 // represents the captured frame. Lock panes only under sendMu and with no
618 // session guard held.
619 commitDamageReceipts(state.receipts)
620 if ac.renderStages.emit != nil {
621 ac.renderStages.emit()
622 }
623
624 if position.ActiveTabID != "" && position != ac.output.lastRoutePosition {
625 if positionValidateErr := position.Validate(); positionValidateErr != nil {
626 d.log.Error("validate route position", "err", positionValidateErr)
627 } else {
628 sendTransport := ac.transportSnapshot()
629 sendTr = sendTransport.transport
630 interruptible := false
631 if marks.attachmentEffect != nil {
632 interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
633 if !interruptible {
634 sendErr = errAttachmentTransition
635 }
636 }
637 if sendErr == nil {
638 if sendTr == nil {
639 sendErr = errors.New("client transport is nil")
640 } else if sendTr.Capabilities().AsyncSend {
641 sendErr = sendTr.SendServerAsync(position)
642 } else {
643 sendErr = sendTr.SendServer(position)
644 }
645 }
646 if marks.attachmentEffect != nil && interruptible {
647 if sendErr != nil {
648 marks.attachmentEffect.reportTransportFailure(sendTransport)
649 }
650 marks.attachmentEffect.endTransportSend()
651 }
652 if sendErr == nil {
653 ac.output.lastRoutePosition = position
654 }
655 }
656 }
657 if sendErr == nil && rc != nil && prepared.ansi.boundary.ViewPublication != 0 &&
658 (marks.attachmentEffect == nil || marks.attachmentEffect.current()) {
659 if pending := rc.retireUIFence(state.lease, state.uiFence); pending != nil {
660 receipt := prepared.ansi.boundary
661 receipt.ActionID = pending.actionID
662 sendTransport := ac.transportSnapshot()
663 sendTr = sendTransport.transport
664 sendErr = sendUIReceiptLocked(marks.attachmentEffect, sendTransport, receipt)
665 if sendErr == nil {
666 rc.finishUIFence(state.lease, pending)
667 } else {
668 rc.failUIFence(state.lease, pending)
669 }
670 }
671 }
672 }
673 return frameFollowUp{sendErr: sendErr, sendTr: sendTr, warnGraphics: suppressedGraphics}, true
674}
675
676// settleFrame runs a publication's follow-up with sendMu released and no
677// attachment, session, tab, or pane lock held.
678func (d *Daemon) settleFrame(entry *session, ac *attachedClient, marks *runtimeMarkBatch, f frameFollowUp) {
679 if f.prepareErr != nil {
680 core := entry.core()
681 core.mu.Lock()
682 sessionName := core.name
683 core.mu.Unlock()
684 d.log.Error("render draw failed", "err", f.prepareErr, "session", sessionName)
685 // Without a coordinator reportError repaints synchronously. Suppress only
686 // that nested notice repaint; leave the guard before returning so a later,
687 // independent failed transaction can still notify the user.
688 if entry.renderCoordinator() == nil {
689 if !ac.prepareFailureFallback.CompareAndSwap(false, true) {
690 return
691 }
692 d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", f.prepareErr))
693 ac.prepareFailureFallback.Store(false)
694 return
695 }
696 d.reportError(entry, domain.UserErr(domain.NoticeInternal, "display update failed", f.prepareErr))
697 // Notices are session-scoped, while the attachment repaint still needs
698 // to redraw its chrome after a failed transaction.
699 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
700 return
701 }
702 if f.warnGraphics && d.warnUnsupportedGraphics(ac) {
703 d.repaintForNotice(ac)
704 }
705 if f.sendErr != nil {
706 // A transport failure may invalidate the role gate. Release this render's
707 // admission first. Detachment freezes the gate and therefore cannot mutate
708 // ownership until any enclosing admitted operation has also ended.
709 if marks.attachmentEffect == nil {
710 d.detachOnSendError(entry, ac, f.sendTr)
711 } else {
712 // Capture the exact admitted capability, including its coordinator
713 // lease, before End permits a new attachment publication. Reserve
714 // cleanup accounting before End so terminal Wait cannot race a later Add.
715 capability := marks.attachmentEffect.capability()
716 launchCleanup := d.reserveAttachmentSendErrorCleanup(capability, f.sendTr)
717 marks.attachmentEffect.End()
718 launchCleanup()
719 }
720 }
721}