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