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