1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "bytes"
6
7 renderer "github.com/bnema/vev-vt/ansi"
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/domain/terminalcap"
10 "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/layout"
12 themeui "github.com/bnema/vev/internal/usecase/theme"
13)
14
15// completedSynchronizedUpdate reports a full DEC 2026 batch contained in one
16// PTY read. The VT's final state is inactive in this case, so it cannot be
17// inferred from before/after SyncUpdateActive alone.
18func completedSynchronizedUpdate(data []byte) bool {
19 _, tail, found := bytes.Cut(data, []byte(renderer.SyncStartCSI))
20 return found && bytes.Contains(tail, []byte(renderer.SyncEndCSI))
21}
22
23// paneRenderable reports dynamic composition visibility. Callers must not
24// hold coordinator locks: this acquires daemon/session/tab/pane-adjacent locks.
25func (d *Daemon) paneRenderable(sess *session, tb *tab, p *pane) bool {
26 if sess == nil || tb == nil || p == nil {
27 return false
28 }
29 sess.mu.Lock()
30 active := sess.attachmentViewsTabLocked(tb)
31 attached := len(sess.attachments) != 0
32 sess.mu.Unlock()
33
34 if !active || !attached {
35 // A headless tab is still renderable while a preview subscriber
36 // watches it: the preview service composes its frames from this tab.
37 rc := sess.renderCoordinator()
38 if rc == nil || !rc.hasPreviewSubscribers() {
39 return false
40 }
41 }
42 tb.mu.Lock()
43 defer tb.mu.Unlock()
44 if tb.floating.pane == p {
45 return active && attached && tb.floating.state == floatingVisible
46 }
47 placements, ok := solvedPlacementsLocked(tb)
48 if !ok {
49 return false
50 }
51 for _, placement := range placements {
52 if placement.ID == p.id && !placement.Collapsed && placement.Content.Width > 0 && placement.Content.Height > 0 {
53 return true
54 }
55 }
56 return false
57}
58
59// ptyReader is retained as a compatibility entry point for focused tests. The
60// production goroutine calls readPanePTY, whose closure owns only the pane.
61func (d *Daemon) ptyReader(sess *session, tb *tab, p *pane) {
62 if p != nil && p.ownerSnapshot() == nil && sess != nil && tb != nil {
63 publishPaneOwner(p, sess, tb, 0)
64 }
65 d.readPanePTY(p)
66}
67
68func (d *Daemon) readPanePTY(p *pane) {
69 defer d.sessWg.Done()
70 if p == nil {
71 return
72 }
73 buf := make([]byte, ptyReadBufSize)
74 p.mu.Lock()
75 p.screen.OnResponse = func(b []byte) { p.ptyResponses = append(p.ptyResponses, b...) }
76 p.screen.OnBell = func() { p.ptyAttention = true }
77 p.screen.OnNotify = func(string, string) { p.ptyAttention = true }
78 p.screen.OnProgress = func(bool) { p.ptyAttention = true }
79 p.screen.OnClipboard = func(b64 string) { p.ptyClipboards = append(p.ptyClipboards, b64) }
80 p.mu.Unlock()
81 for {
82 n, err := p.pty.Read(buf)
83 if n > 0 {
84 d.processPanePTYData(p, buf[:n], true)
85 }
86 if err != nil {
87 lease := p.effectLease()
88 if sessionName, current := paneEffectSessionName(lease); current {
89 d.log.Info("pane pty closed", "err", err, "session", sessionName)
90 } else {
91 d.log.Info("pane pty closed", "err", err)
92 }
93 if p.onExit != nil {
94 p.onExit()
95 } else {
96 d.reapPaneOwner(p)
97 }
98 return
99 }
100 }
101}
102
103type panePTYEffects struct {
104 lease paneEffectLease
105 renderCoordinator *renderCoordinator
106 wasSyncing bool
107 isSyncing bool
108 completeSyncRead bool
109 syncEnded bool
110 attention bool
111 responses []byte
112 clipboards []string
113 syncCleanup syncTimerCleanup
114}
115
116// processPTYData retains the old call shape for resize replay and focused tests,
117// but routing is always derived from the pane owner captured under pane.mu.
118func (d *Daemon) processPTYData(_ *session, _ *tab, p *pane, data []byte, bufferDuringApply bool) {
119 d.processPanePTYData(p, data, bufferDuringApply)
120}
121
122// processPanePTYData is the sole VT parsing path. While a resize apply owns the
123// pane, reader calls append exact bytes and returns immediately; it never waits
124// for PTY.Resize or loses a short read.
125func (d *Daemon) processPanePTYData(p *pane, data []byte, bufferDuringApply bool) {
126 if p == nil {
127 return
128 }
129 p.mu.Lock()
130 if bufferDuringApply && p.resizeApplying {
131 p.resizePending = append(p.resizePending, data...)
132 p.mu.Unlock()
133 return
134 }
135 effects := panePTYEffects{lease: p.effectLeaseLocked()}
136 owner := effects.lease.owner
137 if owner != nil && owner.session != nil {
138 effects.renderCoordinator = owner.session.renderCoordinator()
139 }
140 effects.wasSyncing = p.screen.SyncUpdateActive()
141 writePaneScreenLocked(p, data)
142 p.refreshTerminalTitleLocked()
143 effects.isSyncing = p.screen.SyncUpdateActive()
144 effects.completeSyncRead = !effects.wasSyncing && !effects.isSyncing && completedSynchronizedUpdate(data)
145 if effects.wasSyncing != effects.isSyncing && owner != nil && owner.session != nil {
146 if effects.isSyncing {
147 syncGen := owner.session.syncGen.Add(1)
148 p.syncGen = syncGen
149 if effects.renderCoordinator != nil {
150 lease := effects.lease
151 effects.syncCleanup = effects.renderCoordinator.beginSyncBatchWithRenderability(p, syncGen, func() bool {
152 return lease.Current() && d.paneRenderable(owner.session, owner.tab, p)
153 }, func() {
154 p.mu.Lock()
155 if p.owner.Load() == lease.owner && p.syncGen == syncGen && p.screen.SyncUpdateActive() {
156 p.screen.ForceSyncEnd()
157 }
158 p.mu.Unlock()
159 })
160 }
161 } else {
162 effects.syncEnded = effects.renderCoordinator != nil && effects.renderCoordinator.removeSyncEnd(p, p.syncGen, true)
163 }
164 }
165 effects.attention = p.ptyAttention
166 p.ptyAttention = false
167 effects.responses = append([]byte(nil), p.ptyResponses...)
168 effects.responses = filterUnsupportedKittyAnimationResponses(effects.responses)
169 p.ptyResponses = p.ptyResponses[:0]
170 effects.clipboards = append([]string(nil), p.ptyClipboards...)
171 p.ptyClipboards = p.ptyClipboards[:0]
172 p.mu.Unlock()
173 effects.syncCleanup.finish()
174 d.applyPanePTYEffects(p, effects)
175}
176
177// filterUnsupportedKittyAnimationResponses keeps older icat releases from
178// leaving vev's static-animation fallback replies in the shared PTY input
179// queue. Current Kitty sends animation controls with q=2, but older releases
180// requested error replies and then exited without consuming them. Transfer
181// capability probes use ordinary image IDs and retain their ENOTSUP replies.
182func filterUnsupportedKittyAnimationResponses(data []byte) []byte {
183 if !bytes.Contains(data, []byte("i=0,I=")) || !bytes.Contains(data, []byte(";ENOTSUP")) {
184 return data
185 }
186 out := make([]byte, 0, len(data))
187 for len(data) != 0 {
188 start := bytes.Index(data, []byte("\x1b_G"))
189 if start < 0 {
190 out = append(out, data...)
191 break
192 }
193 out = append(out, data[:start]...)
194 endOffset := bytes.Index(data[start+3:], []byte("\x1b\\"))
195 if endOffset < 0 {
196 out = append(out, data[start:]...)
197 break
198 }
199 end := start + 3 + endOffset + 2
200 record := data[start:end]
201 body := record[3 : len(record)-2]
202 if !bytes.Contains(body, []byte("i=0,I=")) || !bytes.HasSuffix(body, []byte(";ENOTSUP")) {
203 out = append(out, record...)
204 }
205 data = data[end:]
206 }
207 return out
208}
209
210// applyPanePTYEffects emits only effects still bound to the owner generation
211// that parsed them. Each owner-sensitive boundary revalidates independently so
212// ownership publication can retire the remaining effects without rerouting
213// bytes that belonged to the old owner.
214func (d *Daemon) applyPanePTYEffects(p *pane, effects panePTYEffects) {
215 owner := effects.lease.owner
216 if owner == nil || owner.session == nil || owner.tab == nil {
217 return
218 }
219 if effects.lease.Current() {
220 markSnapshotDirty(owner.session)
221 }
222 if effects.attention && effects.lease.Current() {
223 d.noteAttention(owner.session, owner.tab)
224 }
225 for _, b64 := range effects.clipboards {
226 if effects.lease.Current() {
227 d.forwardClipboardAsync(effects.lease, b64)
228 }
229 }
230 if len(effects.responses) > 0 && effects.lease.Current() {
231 if _, err := p.pty.Write(effects.responses); err != nil {
232 if sessionName, current := paneEffectSessionName(effects.lease); current {
233 d.log.Warn("pty response write failed", "err", err, "session", sessionName)
234 }
235 }
236 }
237 if effects.renderCoordinator == nil || !effects.lease.Current() {
238 return
239 }
240 renderable := d.paneRenderable(owner.session, owner.tab, p)
241 if !effects.lease.Current() {
242 return
243 }
244 if effects.wasSyncing != effects.isSyncing {
245 if effects.isSyncing {
246 if renderable {
247 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
248 }
249 } else if effects.syncEnded && renderable {
250 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
251 }
252 }
253 if effects.completeSyncRead && renderable {
254 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
255 } else if renderable && !effects.isSyncing && effects.wasSyncing == effects.isSyncing {
256 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
257 }
258}
259
260// paint draws the composed client frame (active tab plus status bar) and
261// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
262// such as switch/create/close/rename/resize so the repaint is complete.
263func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
264 if d != nil && d.runtimeObserver != nil {
265 d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
266 }
267}
268
269// runtimeMarkBatch captures immutable runtime marks during a render transaction.
270// paint owns attachment sendMu while it captures, composes, and emits; JSONL
271// observer I/O must therefore happen only after that ownership is released.
272type runtimeMarkBatch struct {
273 observer ports.RuntimeObserver
274 marks []ports.RuntimeMark
275 attachmentEffect *attachmentEffect
276}
277
278func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
279 if d == nil {
280 return runtimeMarkBatch{}
281 }
282 return runtimeMarkBatch{observer: d.runtimeObserver}
283}
284
285func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
286 if b == nil || b.observer == nil {
287 return func(uint64, bool) {}
288 }
289 correlation := ports.NewRuntimeCorrelation()
290 b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
291 return func(endBytes uint64, valid bool) {
292 b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
293 }
294}
295
296func (b *runtimeMarkBatch) flush() {
297 if b == nil || b.observer == nil {
298 return
299 }
300 for _, mark := range b.marks {
301 b.observer.ObserveRuntime(mark)
302 }
303}
304
305type paintResult uint8
306
307const (
308 paintRejected paintResult = iota
309 paintEmitted
310 paintBlockedCapacity
311)
312
313func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
314 return d.paintWithActivationEffect(entry, ac, reset, lease, nil)
315}
316
317// activation owns the drained transition gate; only its explicit ticket may
318// publish while ordinary input and render admission remain disabled.
319func (d *Daemon) paintWithActivationEffect(entry *session, ac *attachedClient, reset bool, lease *attachmentLease, activation *attachmentEffect) paintResult {
320 // Session-owned PTY preparation remains local; attachment rendering is
321 // captured through session.captureRenderState.
322 sess := entry
323 local := sess != nil
324 marks := d.newRuntimeMarkBatch()
325 var paintEffect *attachmentEffect
326 if activation != nil {
327 paintEffect = activation
328 marks.attachmentEffect = activation
329 } else if ac.attachmentActivity() != attachmentActive {
330 return paintRejected
331 } else if lease != nil {
332 token := captureAttachmentCapability(entry, ac, ac.transport())
333 token.lease = lease
334 ticket, admitted := ac.beginAttachmentEffect(token)
335 if !admitted {
336 return paintRejected
337 }
338 paintEffect = ticket
339 marks.attachmentEffect = ticket
340 defer ticket.End()
341 }
342 var tb *tab
343 if local {
344 tb = sess.tabForAttachment(ac)
345 if tb == nil {
346 return paintRejected
347 }
348 }
349
350 ac.sendMu.Lock()
351 result, afterUnlock := d.paintLocked(entry, ac, tb, reset, lease, paintEffect, &marks)
352 ac.sendMu.Unlock()
353 afterUnlock()
354 return result
355}
356
357func noAfterUnlock() {}
358
359// paintLocked is the complete capture/compose/publish transaction. It runs with
360// ac.sendMu held and never releases it; paint is the sole owner of that lock.
361// The returned function runs after sendMu is released, with no attachment,
362// session, tab, or pane lock held.
363func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset bool, lease *attachmentLease, paintEffect *attachmentEffect, marks *runtimeMarkBatch) (paintResult, func()) {
364 sess := entry
365 local := sess != nil
366 if paintEffect != nil {
367 if !paintEffect.current() {
368 return paintRejected, noAfterUnlock
369 }
370 } else {
371 // Direct/headless rendering has no Attachment effect and retains its
372 // membership-only fixture path.
373 entry.core().mu.Lock()
374 _, owned := entry.core().attachments[ac]
375 entry.core().mu.Unlock()
376 if !owned || ac.currentAttachmentSession() != entry {
377 return paintRejected, noAfterUnlock
378 }
379 }
380 // Capacity is checked before any destructive capture. Refresh the atomic
381 // readiness snapshot while sendMu is held so direct test/setup mutations of
382 // the stream fields remain covered without making coordinator probes wait on
383 // a blocked transport send.
384 if ac.output != nil {
385 ac.output.syncCapacityLocked()
386 }
387 // The coordinator is the normal gate, but this ownership check also protects
388 // direct resize and test paint paths from consuming damage that cannot be
389 // emitted.
390 if ac.output != nil && ac.output.atCapacity() {
391 // The coordinator owns the blocked interval; this guard only protects
392 // direct test/resize paint calls from destructive capture.
393 return paintBlockedCapacity, noAfterUnlock
394 }
395 var uiFence uint64
396 if lease != nil {
397 if rc := attachmentRenderCoordinator(entry); rc != nil {
398 uiFence = rc.captureUIFence(lease)
399 }
400 }
401 // Composition owns attachment sendMu; initialize its lazy overlay state
402 // under that same ownership so concurrent fallback paints cannot observe a
403 // partially published runtime.
404 ac.initOverlays()
405 frameCapture := ac.afterFrame.beginCapture()
406 overlays := ac.overlays.SnapshotForRender()
407 repaintAttachedClients := false
408 var published bool
409 var followUp frameFollowUp
410 afterUnlock := func() {
411 // Frame follow-up (notices, repaints, send-error cleanup) runs first,
412 // then the captured sequence is emitted before any follow-up repaint can
413 // introduce another boundary.
414 if published {
415 d.settleFrame(entry, ac, marks, followUp)
416 }
417 overlays.Unlock()
418 marks.flush()
419 ac.afterFrame.runReady()
420 if repaintAttachedClients {
421 d.repaintAllAttachedClients()
422 }
423 }
424 if local {
425 d.refreshSessionFocusedTitles(sess)
426 }
427 // Contextual ranks are completely captured under paletteMu. Compose them
428 // without reading the live MRU, whose order may have changed mid-interaction.
429 statusFeedback := overlays.statusFeedback
430 if statusFeedback == "" && overlays.resizeActive {
431 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
432 }
433 bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
434 applied := ac.getAppliedTheme()
435 bars.theme = applied.Raw
436 if local {
437 attentionVisible := pulseVisible(bars.attentionFrame)
438 repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
439 }
440
441 paletteCfg := d.currentPaletteConfig()
442 floatingCfg := d.currentFloatingConfig()
443 // Title refresh may inspect process state, so it remains before the capture
444 // boundary and outside tab/pane ownership locks.
445 if local {
446 tb.mu.Lock()
447 titleIDs := ac.render.capture.titleIDs[:0]
448 if tb.tree != nil {
449 titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
450 }
451 ac.render.capture.titleIDs = titleIDs
452 floating := tb.floating.pane
453 hasFloating := tb.floating.state == floatingVisible && floating != nil
454 tb.mu.Unlock()
455 for _, id := range titleIDs {
456 d.refreshPaneTitle(sess, id, tb)
457 }
458 if hasFloating {
459 d.refreshPaneDisplayTitle(sess, floating, false)
460 }
461 }
462
463 capturedOverlays := capturedOverlayRenderState{
464 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
465 paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
466 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
467 }
468 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
469 state, ok := entry.captureRenderState(ac, renderCaptureRequest{
470 bars: bars,
471 overlays: capturedOverlays,
472 floatingCfg: floatingCfg,
473 styles: applied.Resolved.Styles,
474 styleGeneration: applied.Generation,
475 reset: reset,
476 lease: lease,
477 uiFence: uiFence,
478 })
479 endCapture(0, ok)
480 if !ok {
481 return paintRejected, afterUnlock
482 }
483 if ac.renderStages.capture != nil {
484 ac.renderStages.capture()
485 }
486 state.frameCapture = frameCapture
487 captureOverlayLayers(state, overlays, paletteCfg)
488 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
489 composed := composeFrame(*state, ac.render.cache, ac.render.spare)
490 endCompose(0, true)
491 if ac.renderStages.compose != nil {
492 ac.renderStages.compose()
493 }
494 followUp, published = d.publishFrameLocked(entry, ac, state, composed, marks)
495 return paintEmitted, afterUnlock
496}
497
498var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
499
500var (
501 fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles
502 fallbackMonoStyles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles
503)
504
505// fallbackStylesFor is the static pre-theme chrome for one attachment's mode.
506func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles {
507 switch color.Mode {
508 case terminalcap.ANSI16:
509 return fallbackANSI16Styles
510 case terminalcap.Monochrome:
511 return fallbackMonoStyles
512 default:
513 return fallbackChromeStyles
514 }
515}
516
517func resolveStyles(styles []themeui.Styles) themeui.Styles {
518 if len(styles) > 0 {
519 return styles[0]
520 }
521 return fallbackChromeStyles
522}
523
524type barCache struct {
525 top []renderer.Cell
526 bottom []renderer.Cell
527}
528
529func (c *barCache) Reset() {
530 c.top = nil
531 c.bottom = nil
532}
533
534func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
535 pl.Content.X += dx
536 pl.Content.Y += dy
537 pl.TitleBar.X += dx
538 pl.TitleBar.Y += dy
539 return pl
540}
541
542func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
543 startX, startY := max(r.X, 0), max(r.Y, 0)
544 endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
545 for y := startY; y < endY; y++ {
546 sy := y - r.Y
547 if sy < 0 || sy >= src.Height {
548 continue
549 }
550 for x := startX; x < endX; x++ {
551 sx := x - r.X
552 if sx < 0 || sx >= src.Width {
553 continue
554 }
555 cell := src.At(sx, sy)
556 if dim {
557 cell.Style = dimmer.Dim(cell.Style)
558 }
559 dst.Set(x, y, cell)
560 }
561 }
562}
563
564// drawDividers paints each precomputed divider gap onto frame, offsetting by
565// dy (composeFrame's content area starts one row below the top status bar).
566// The dividers themselves come from layout.SolveWithDividers, computed once
567// per capture alongside pane placements, rather than recomputed here.
568func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style renderer.Style) {
569 for _, d := range dividers {
570 glyph := rune('│')
571 if d.Dir == layout.Vertical {
572 glyph = '─'
573 }
574 y0 := max(d.Rect.Y+dy, 0)
575 y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
576 x0 := max(d.Rect.X, 0)
577 x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
578 for y := y0; y < y1; y++ {
579 for x := x0; x < x1; x++ {
580 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
581 }
582 }
583 }
584}
585
586func (c *barCache) capture(top, bottom []renderer.Cell) {
587 c.top = cloneCells(c.top, top)
588 c.bottom = cloneCells(c.bottom, bottom)
589}
590
591func cloneCells(dst, src []renderer.Cell) []renderer.Cell {
592 if cap(dst) < len(src) {
593 dst = make([]renderer.Cell, len(src))
594 } else {
595 dst = dst[:len(src)]
596 }
597 copy(dst, src)
598 return dst
599}
600
601func sameCells(a, b []renderer.Cell) bool {
602 if len(a) != len(b) {
603 return false
604 }
605 for i := range a {
606 if !a[i].Equal(b[i]) {
607 return false
608 }
609 }
610 return true
611}
612
613func offsetDamage(in []renderer.Damage) []renderer.Damage {
614 return translateDamage(in, 0, 1)
615}
616
617func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
618 out := make([]renderer.Damage, len(in))
619 for i, d := range in {
620 out[i] = d
621 if d.Kind != renderer.DamageFullRedraw {
622 out[i].X += dx
623 out[i].Y += dy
624 }
625 }
626 return out
627}
628
629func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
630 if d.Kind == renderer.DamageFullRedraw {
631 return []renderer.Damage{d}
632 }
633 if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
634 return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
635 }
636 d.X += content.X
637 d.Y += content.Y
638 return []renderer.Damage{d}
639}