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. 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 && p.prestarted && len(effects.responses) > 0 {
217 // An unclaimed spare still answers its shell's startup queries; a
218 // closed spare only fails the write.
219 _, _ = p.pty.Write(effects.responses)
220 }
221 if owner == nil || owner.session == nil || owner.tab == nil {
222 return
223 }
224 if effects.lease.Current() {
225 markSnapshotDirty(owner.session)
226 }
227 if effects.attention && effects.lease.Current() {
228 d.noteAttention(owner.session, owner.tab)
229 }
230 for _, b64 := range effects.clipboards {
231 if effects.lease.Current() {
232 d.forwardClipboardAsync(effects.lease, b64)
233 }
234 }
235 if len(effects.responses) > 0 && effects.lease.Current() {
236 if _, err := p.pty.Write(effects.responses); err != nil {
237 if sessionName, current := paneEffectSessionName(effects.lease); current {
238 d.log.Warn("pty response write failed", "err", err, "session", sessionName)
239 }
240 }
241 }
242 if effects.renderCoordinator == nil || !effects.lease.Current() {
243 return
244 }
245 renderable := d.paneRenderable(owner.session, owner.tab, p)
246 if !effects.lease.Current() {
247 return
248 }
249 if effects.wasSyncing != effects.isSyncing {
250 if effects.isSyncing {
251 if renderable {
252 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
253 }
254 } else if effects.syncEnded && renderable {
255 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
256 }
257 }
258 if effects.completeSyncRead && renderable {
259 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateUrgent, producer: "render.go"})
260 } else if renderable && !effects.isSyncing && effects.wasSyncing == effects.isSyncing {
261 effects.renderCoordinator.invalidate(renderInvalidation{class: invalidateOutput, producer: "render.go"})
262 }
263}
264
265// paint draws the composed client frame (active tab plus status bar) and
266// sends the resulting bytes. The renderer shadow is reset on explicit invalidations
267// such as switch/create/close/rename/resize so the repaint is complete.
268func (d *Daemon) observeRuntime(kind ports.RuntimeMarkKind, bytes uint64, valid bool) {
269 if d != nil && d.runtimeObserver != nil {
270 d.runtimeObserver.ObserveRuntime(ports.NewRuntimeMark("daemon", kind, bytes, valid))
271 }
272}
273
274// runtimeMarkBatch captures immutable runtime marks during a render transaction.
275// paint owns attachment sendMu while it captures, composes, and emits; JSONL
276// observer I/O must therefore happen only after that ownership is released.
277type runtimeMarkBatch struct {
278 observer ports.RuntimeObserver
279 marks []ports.RuntimeMark
280 attachmentEffect *attachmentEffect
281}
282
283func (d *Daemon) newRuntimeMarkBatch() runtimeMarkBatch {
284 if d == nil {
285 return runtimeMarkBatch{}
286 }
287 return runtimeMarkBatch{observer: d.runtimeObserver}
288}
289
290func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) func(uint64, bool) {
291 if b == nil || b.observer == nil {
292 return func(uint64, bool) {}
293 }
294 correlation := ports.NewRuntimeCorrelation()
295 b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, start, bytes, true))
296 return func(endBytes uint64, valid bool) {
297 b.marks = append(b.marks, ports.NewRuntimeMarkWithCorrelation("daemon", correlation, end, endBytes, valid))
298 }
299}
300
301func (b *runtimeMarkBatch) flush() {
302 if b == nil || b.observer == nil {
303 return
304 }
305 for _, mark := range b.marks {
306 b.observer.ObserveRuntime(mark)
307 }
308}
309
310type paintResult uint8
311
312const (
313 paintRejected paintResult = iota
314 paintEmitted
315 paintBlockedCapacity
316)
317
318func (d *Daemon) paint(entry *session, ac *attachedClient, reset bool, lease *attachmentLease) paintResult {
319 return d.paintWithActivationEffect(entry, ac, reset, lease, nil)
320}
321
322// activation owns the drained transition gate; only its explicit ticket may
323// publish while ordinary input and render admission remain disabled.
324func (d *Daemon) paintWithActivationEffect(entry *session, ac *attachedClient, reset bool, lease *attachmentLease, activation *attachmentEffect) paintResult {
325 // Session-owned PTY preparation remains local; attachment rendering is
326 // captured through session.captureRenderState.
327 sess := entry
328 local := sess != nil
329 marks := d.newRuntimeMarkBatch()
330 var paintEffect *attachmentEffect
331 if activation != nil {
332 paintEffect = activation
333 marks.attachmentEffect = activation
334 } else if ac.attachmentActivity() != attachmentActive {
335 return paintRejected
336 } else if lease != nil {
337 token := captureAttachmentCapability(entry, ac, ac.transport())
338 token.lease = lease
339 ticket, admitted := ac.beginAttachmentEffect(token)
340 if !admitted {
341 return paintRejected
342 }
343 paintEffect = ticket
344 marks.attachmentEffect = ticket
345 defer ticket.End()
346 }
347 var tb *tab
348 if local {
349 tb = sess.tabForAttachment(ac)
350 if tb == nil {
351 return paintRejected
352 }
353 }
354
355 ac.sendMu.Lock()
356 result, afterUnlock := d.paintLocked(entry, ac, tb, reset, lease, paintEffect, &marks)
357 ac.sendMu.Unlock()
358 afterUnlock()
359 return result
360}
361
362func noAfterUnlock() {}
363
364// paintLocked is the complete capture/compose/publish transaction. It runs with
365// ac.sendMu held and never releases it; paint is the sole owner of that lock.
366// The returned function runs after sendMu is released, with no attachment,
367// session, tab, or pane lock held.
368func (d *Daemon) paintLocked(entry *session, ac *attachedClient, tb *tab, reset bool, lease *attachmentLease, paintEffect *attachmentEffect, marks *runtimeMarkBatch) (paintResult, func()) {
369 sess := entry
370 local := sess != nil
371 if paintEffect != nil {
372 if !paintEffect.current() {
373 return paintRejected, noAfterUnlock
374 }
375 } else {
376 // Direct/headless rendering has no Attachment effect and retains its
377 // membership-only fixture path.
378 entry.core().mu.Lock()
379 _, owned := entry.core().attachments[ac]
380 entry.core().mu.Unlock()
381 if !owned || ac.currentAttachmentSession() != entry {
382 return paintRejected, noAfterUnlock
383 }
384 }
385 // Capacity is checked before any destructive capture. Refresh the atomic
386 // readiness snapshot while sendMu is held so direct test/setup mutations of
387 // the stream fields remain covered without making coordinator probes wait on
388 // a blocked transport send.
389 if ac.output != nil {
390 ac.output.syncCapacityLocked()
391 }
392 // The coordinator is the normal gate, but this ownership check also protects
393 // direct resize and test paint paths from consuming damage that cannot be
394 // emitted.
395 if ac.output != nil && ac.output.atCapacity() {
396 // The coordinator owns the blocked interval; this guard only protects
397 // direct test/resize paint calls from destructive capture.
398 return paintBlockedCapacity, noAfterUnlock
399 }
400 var uiFence uint64
401 if lease != nil {
402 if rc := attachmentRenderCoordinator(entry); rc != nil {
403 uiFence = rc.captureUIFence(lease)
404 }
405 }
406 // Composition owns attachment sendMu; initialize its lazy overlay state
407 // under that same ownership so concurrent fallback paints cannot observe a
408 // partially published runtime.
409 ac.initOverlays()
410 frameCapture := ac.afterFrame.beginCapture()
411 overlays := ac.overlays.SnapshotForRender()
412 repaintAttachedClients := false
413 var published bool
414 var followUp frameFollowUp
415 afterUnlock := func() {
416 // Frame follow-up (notices, repaints, send-error cleanup) runs first,
417 // then the captured sequence is emitted before any follow-up repaint can
418 // introduce another boundary.
419 if published {
420 d.settleFrame(entry, ac, marks, followUp)
421 }
422 overlays.Unlock()
423 marks.flush()
424 ac.afterFrame.runReady()
425 if repaintAttachedClients {
426 d.repaintAllAttachedClients()
427 }
428 }
429 if local {
430 d.refreshSessionFocusedTitles(sess)
431 }
432 // Contextual ranks are completely captured under paletteMu. Compose them
433 // without reading the live MRU, whose order may have changed mid-interaction.
434 statusFeedback := overlays.statusFeedback
435 if statusFeedback == "" && overlays.resizeActive {
436 statusFeedback = "resize: h/j/k/l or arrows · = equalize · q/esc/enter exit"
437 }
438 bars := d.barStateForAttachmentPaletteHintsFor(entry, ac, statusFeedback, overlays.paletteHints, overlays.paletteRouteSnapshot)
439 applied := ac.getAppliedTheme()
440 bars.theme = applied.Raw
441 if local {
442 attentionVisible := pulseVisible(bars.attentionFrame)
443 repaintAttachedClients = sess.ackAttention(tb, ac, attentionVisible)
444 }
445
446 paletteCfg := d.currentPaletteConfig()
447 floatingCfg := d.currentFloatingConfig()
448 // Title refresh may inspect process state, so it remains before the capture
449 // boundary and outside tab/pane ownership locks.
450 if local {
451 tb.mu.Lock()
452 titleIDs := ac.render.capture.titleIDs[:0]
453 if tb.tree != nil {
454 titleIDs = appendStackPaneIDs(titleIDs, tb.tree.Root)
455 }
456 ac.render.capture.titleIDs = titleIDs
457 floating := tb.floating.pane
458 hasFloating := tb.floating.state == floatingVisible && floating != nil
459 tb.mu.Unlock()
460 for _, id := range titleIDs {
461 d.refreshPaneTitle(sess, id, tb)
462 }
463 if hasFloating {
464 d.refreshPaneDisplayTitle(sess, floating, false)
465 }
466 }
467
468 capturedOverlays := capturedOverlayRenderState{
469 copyActive: overlays.copyActive, copySearchActive: overlays.copySearchModel != nil,
470 paletteActive: overlays.paletteActive, promptActive: overlays.promptActive,
471 resizeActive: overlays.resizeActive, statusFeedback: statusFeedback,
472 }
473 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
474 state, ok := entry.captureRenderState(ac, renderCaptureRequest{
475 bars: bars,
476 overlays: capturedOverlays,
477 floatingCfg: floatingCfg,
478 styles: applied.Resolved.Styles,
479 styleGeneration: applied.Generation,
480 reset: reset,
481 lease: lease,
482 uiFence: uiFence,
483 })
484 endCapture(0, ok)
485 if !ok {
486 return paintRejected, afterUnlock
487 }
488 if ac.renderStages.capture != nil {
489 ac.renderStages.capture()
490 }
491 state.frameCapture = frameCapture
492 captureOverlayLayers(state, overlays, paletteCfg)
493 endCompose := marks.span(ports.RuntimeComposeStart, ports.RuntimeComposeEnd, 0)
494 composed := composeFrame(*state, ac.render.cache, ac.render.spare)
495 endCompose(0, true)
496 if ac.renderStages.compose != nil {
497 ac.renderStages.compose()
498 }
499 followUp, published = d.publishFrameLocked(entry, ac, state, composed, marks)
500 return paintEmitted, afterUnlock
501}
502
503var fallbackChromeStyles = themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles
504
505var (
506 fallbackANSI16Styles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16}).Styles
507 fallbackMonoStyles = themeui.ResolveForColor(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}, terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome}).Styles
508)
509
510// fallbackStylesFor is the static pre-theme chrome for one attachment's mode.
511func fallbackStylesFor(color terminalcap.ColorCapabilities) themeui.Styles {
512 switch color.Mode {
513 case terminalcap.ANSI16:
514 return fallbackANSI16Styles
515 case terminalcap.Monochrome:
516 return fallbackMonoStyles
517 default:
518 return fallbackChromeStyles
519 }
520}
521
522func resolveStyles(styles []themeui.Styles) themeui.Styles {
523 if len(styles) > 0 {
524 return styles[0]
525 }
526 return fallbackChromeStyles
527}
528
529type barCache struct {
530 top []renderer.Cell
531 bottom []renderer.Cell
532}
533
534func (c *barCache) Reset() {
535 c.top = nil
536 c.bottom = nil
537}
538
539func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
540 pl.Content.X += dx
541 pl.Content.Y += dy
542 pl.TitleBar.X += dx
543 pl.TitleBar.Y += dy
544 return pl
545}
546
547func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
548 startX, startY := max(r.X, 0), max(r.Y, 0)
549 endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
550 for y := startY; y < endY; y++ {
551 sy := y - r.Y
552 if sy < 0 || sy >= src.Height {
553 continue
554 }
555 for x := startX; x < endX; x++ {
556 sx := x - r.X
557 if sx < 0 || sx >= src.Width {
558 continue
559 }
560 cell := src.At(sx, sy)
561 if dim {
562 cell.Style = dimmer.Dim(cell.Style)
563 }
564 dst.Set(x, y, cell)
565 }
566 }
567}
568
569// drawDividers paints each precomputed divider gap onto frame, offsetting by
570// dy (composeFrame's content area starts one row below the top status bar).
571// The dividers themselves come from layout.SolveWithDividers, computed once
572// per capture alongside pane placements, rather than recomputed here.
573func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style renderer.Style) {
574 for _, d := range dividers {
575 glyph := rune('│')
576 if d.Dir == layout.Vertical {
577 glyph = '─'
578 }
579 y0 := max(d.Rect.Y+dy, 0)
580 y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
581 x0 := max(d.Rect.X, 0)
582 x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
583 for y := y0; y < y1; y++ {
584 for x := x0; x < x1; x++ {
585 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
586 }
587 }
588 }
589}
590
591func (c *barCache) capture(top, bottom []renderer.Cell) {
592 c.top = cloneCells(c.top, top)
593 c.bottom = cloneCells(c.bottom, bottom)
594}
595
596func cloneCells(dst, src []renderer.Cell) []renderer.Cell {
597 if cap(dst) < len(src) {
598 dst = make([]renderer.Cell, len(src))
599 } else {
600 dst = dst[:len(src)]
601 }
602 copy(dst, src)
603 return dst
604}
605
606func sameCells(a, b []renderer.Cell) bool {
607 if len(a) != len(b) {
608 return false
609 }
610 for i := range a {
611 if !a[i].Equal(b[i]) {
612 return false
613 }
614 }
615 return true
616}
617
618func offsetDamage(in []renderer.Damage) []renderer.Damage {
619 return translateDamage(in, 0, 1)
620}
621
622func translateDamage(in []renderer.Damage, dx, dy int) []renderer.Damage {
623 out := make([]renderer.Damage, len(in))
624 for i, d := range in {
625 out[i] = d
626 if d.Kind != renderer.DamageFullRedraw {
627 out[i].X += dx
628 out[i].Y += dy
629 }
630 }
631 return out
632}
633
634func translatePaneDamage(d renderer.Damage, content domain.Rect, area domain.Rect) []renderer.Damage {
635 if d.Kind == renderer.DamageFullRedraw {
636 return []renderer.Damage{d}
637 }
638 if d.Kind == renderer.DamageScrollUp && (content.X != 0 || content.Width != area.Width) {
639 return []renderer.Damage{{Kind: renderer.DamageText, X: content.X, Y: content.Y, Width: content.Width, Height: content.Height}}
640 }
641 d.X += content.X
642 d.Y += content.Y
643 return []renderer.Damage{d}
644}