1package tui
2
3import (
4 "io"
5 "os"
6 "strings"
7
8 "github.com/mattn/go-runewidth"
9)
10
11// runewidthRune reports the number of cells a rune occupies, pinned
12// here so the renderer does not depend on the editor's helper.
13func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
14
15// Renderer maintains a previous frame and writes only the lines that
16// changed on each Draw(). Callers pass a full target frame (slice of
17// styled lines, already wrapped to width).
18type Renderer struct {
19 out io.Writer
20 prev []string
21 rows int // terminal rows
22 cols int // terminal cols
23
24 // Cursor position after last draw (for placing input cursor).
25 cursorRow int
26 cursorCol int
27
28 // hideCursor when true prevents ShowCursor from being emitted.
29 hideCursor bool
30
31 // prevHadImage tracks whether the previous frame contained an
32 // inline-image escape so we can force a full clear+repaint whenever
33 // the image set changes. Only matters when inline images are
34 // enabled via ZOT_INLINE_IMAGES; defaults to false.
35 prevHadImage bool
36
37 // Main-screen flow renderer state. logLines is the full logical
38 // buffer (chat + live bottom band) from the previous DrawLog call.
39 // logViewportTop/logHardwareRow track where that logical buffer sits
40 // in the terminal's visible viewport so we can diff safely, and bail
41 // out to clear+replay when the diff would touch rows that are no
42 // longer addressable.
43 logChat []string
44 logBottom []string
45 logLines []string
46 logViewportTop int
47 logHardwareRow int
48 logInit bool
49
50 // keepScrollback is true when we must NOT emit \x1b[3J
51 // (erase-in-display 3, "clear scrollback").
52 //
53 // VS Code's integrated terminal (xterm.js) interprets \x1b[3J
54 // as "also snap the viewport to the top of the remaining
55 // buffer." Once the user has reopened a terminal with VS
56 // Code's persistent-sessions feature on, there is real
57 // replayed scrollback above the live cursor, and the snap is
58 // visible: the host scrollbar jumps to the top every time we
59 // do a full repaint (first paint, Ctrl+L via Renderer.Clear,
60 // any writeFull(true) shrink). On every other terminal we
61 // tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal)
62 // \x1b[3J just drops scrollback rows without moving the
63 // viewport, which is what we actually want.
64 //
65 // The trade-off when keepScrollback is true: stale zot frames
66 // remain in scrollback above the live view, so scrolling up
67 // in VS Code's terminal shows old (already-superseded) zot
68 // output. That is strictly less disruptive than the
69 // scrollbar yanking to top on every Ctrl+L, and it is a
70 // limitation specific to VS Code's terminal that we have no
71 // way to work around without breaking other terminals.
72 keepScrollback bool
73
74 // theme is optional renderer-level styling applied at the final
75 // row-writing boundary. In particular, Theme.Background is painted
76 // as a full-width row background without making every View renderer
77 // know about terminal padding and reset semantics.
78 theme Theme
79}
80
81// NewRenderer returns a renderer that writes to out.
82//
83// Detects VS Code's integrated terminal via $TERM_PROGRAM and, when
84// detected, disables emission of \x1b[3J for the reasons documented
85// on Renderer.keepScrollback. The env var is set by VS Code itself
86// (and by Cursor, which forks VS Code's terminal — same xterm.js,
87// same bug), so no user configuration is required.
88func NewRenderer(out io.Writer) *Renderer {
89 return &Renderer{
90 out: out,
91 keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode",
92 }
93}
94
95// SetTheme updates renderer-level terminal styling. Changing the
96// background affects every row, so cached frame state is invalidated.
97func (r *Renderer) SetTheme(th Theme) {
98 r.theme = th
99 r.Invalidate()
100}
101
102// Resize tells the renderer the current terminal size.
103//
104// On a real size change we also issue a clear-screen so the next Draw
105// starts from a blank slate. Without the clear, characters from the
106// old (wider) layout linger past the new right edge and rows from
107// before the new bottom hang around as garbage.
108func (r *Renderer) ResetScrollRegion() {
109 if r.out != nil {
110 _, _ = io.WriteString(r.out, SeqResetScrollRegion)
111 }
112}
113
114func (r *Renderer) Resize(cols, rows int) {
115 if cols != r.cols || rows != r.rows {
116 r.cols = cols
117 r.rows = rows
118 r.prev = nil
119 r.logChat = nil
120 r.logBottom = nil
121 r.logLines = nil
122 r.logViewportTop = 0
123 r.logHardwareRow = 0
124 r.logInit = false
125 if r.out != nil {
126 // Clear both screen and (where safe) scrollback so stale
127 // content from the old width doesn't bleed through. Move
128 // to (1,1) so the next DrawLog/writeFull starts from a
129 // clean slate. Use the no-home variant: the explicit
130 // MoveTo below sets the cursor without triggering VS
131 // Code's viewport-snap. See Renderer.keepScrollback for
132 // why we skip \x1b[3J on VS Code's terminal.
133 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
134 }
135 }
136}
137
138// Clear forces a full repaint on the next Draw and clears the screen
139// plus scrollback. In main-screen flow mode this is required whenever
140// already-emitted transcript layout changes (for example ctrl+o
141// expand/collapse), because terminal scrollback cannot be edited
142// reliably once printed.
143func (r *Renderer) Clear() {
144 r.prev = nil
145 r.logChat = nil
146 r.logBottom = nil
147 r.logLines = nil
148 r.logViewportTop = 0
149 r.logHardwareRow = 0
150 r.logInit = false
151 _, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
152}
153
154// clearScrollbackSeq returns the scrollback-clear escape, or the
155// empty string when we are running under a terminal where emitting
156// it has user-visible side effects (see Renderer.keepScrollback).
157// Callers concatenate this into a larger control sequence; an empty
158// return value is a no-op there.
159func (r *Renderer) clearScrollbackSeq() string {
160 if r.keepScrollback {
161 return ""
162 }
163 return SeqClearScrollback
164}
165
166// Invalidate forces a full repaint on the next Draw without clearing the
167// whole terminal first. Useful when the cached diff is unreliable but a
168// visible full-screen flash would be too distracting.
169func (r *Renderer) Invalidate() {
170 r.prev = nil
171 r.logLines = nil
172}
173
174// Draw updates the terminal so that the visible frame ends with the
175// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
176// the lines slice indicating where to place the terminal cursor; use
177// -1 to hide it.
178// containsImageEscape reports whether the line carries an inline-image
179// escape we must repaint rather than diff against the previous frame.
180func containsImageEscape(s string) bool {
181 return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
182}
183
184// paintBackgroundRow applies the optional theme background to a single
185// already-truncated terminal row. It pads with spaces to cols so the
186// background reaches the right edge, and re-applies the background
187// after full SGR resets inside the row so local styling does not punch
188// transparent holes through the global tint.
189func paintBackgroundRow(line string, cols int, th Theme) string {
190 bg := th.BackgroundStyle()
191 if bg == "" || cols <= 0 || containsImageEscape(line) {
192 return line
193 }
194 line = strings.ReplaceAll(line, reset, reset+bg)
195 if w := visibleWidth(line); w < cols {
196 line += strings.Repeat(" ", cols-w)
197 }
198 return bg + line + reset
199}
200
201// truncateToWidth clips s so its on-screen width doesn't exceed cols
202// cells, preserving ANSI CSI escape sequences (which don't consume
203// cells). Lines carrying an inline-image escape are returned as-is
204// since we can't measure their painted size.
205//
206// Fast path: a byte-length <= cols is a conservative upper bound
207// guaranteeing the cell width is also <= cols, so we skip all the
208// rune-width math. That covers the vast majority of lines in a
209// transcript (narrow terminals wrap early; wide ones leave headroom).
210func truncateToWidth(s string, cols int) string {
211 if cols <= 0 || containsImageEscape(s) {
212 return s
213 }
214 if len(s) <= cols {
215 return s
216 }
217 var out strings.Builder
218 out.Grow(len(s))
219 seen := 0
220 runes := []rune(s)
221 for i := 0; i < len(runes); {
222 r := runes[i]
223 // CSI escape sequence (ESC [ ... final): zero-width.
224 if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
225 out.WriteRune(r)
226 out.WriteRune(runes[i+1])
227 i += 2
228 for i < len(runes) {
229 c := runes[i]
230 out.WriteRune(c)
231 i++
232 if c >= 0x40 && c <= 0x7e {
233 break
234 }
235 }
236 continue
237 }
238 rw := runewidthRune(r)
239 if seen+rw > cols {
240 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
241 // so background colors and cleanup sequences survive.
242 for i < len(runes) {
243 if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
244 out.WriteRune(runes[i])
245 out.WriteRune(runes[i+1])
246 i += 2
247 for i < len(runes) {
248 c := runes[i]
249 out.WriteRune(c)
250 i++
251 if c >= 0x40 && c <= 0x7e {
252 break
253 }
254 }
255 } else {
256 break
257 }
258 }
259 break
260 }
261 out.WriteRune(r)
262 seen += rw
263 i++
264 }
265 return out.String()
266}
267
268func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
269 if r.cols == 0 || r.rows == 0 {
270 return
271 }
272 // Bottom-align: only the last r.rows lines are visible.
273 visible := lines
274 if len(visible) > r.rows {
275 visible = visible[len(visible)-r.rows:]
276 cursorRow -= len(lines) - len(visible)
277 }
278 // Pad to r.rows with empty lines at the top. Every line is also
279 // hard-truncated to cols so the terminal never soft-wraps our output
280 // (which would push the status bar out of its row).
281 frame := make([]string, r.rows)
282 top := r.rows - len(visible)
283 for i := 0; i < top; i++ {
284 frame[i] = ""
285 }
286 for i, line := range visible {
287 frame[top+i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
288 }
289 if r.theme.Background != nil {
290 for i := 0; i < top; i++ {
291 frame[i] = paintBackgroundRow("", r.cols, r.theme)
292 }
293 }
294
295 var w strings.Builder
296 w.WriteString(SeqSynchronizedOn)
297 w.WriteString(SeqHideCursor)
298
299 // When inline images are in play we always full-repaint (clear
300 // screen first, then rewrite every row). Terminals manage image
301 // pixels in a layer we cannot diff against, so the per-line cache
302 // is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
303 // the common code path below is the fast cached diff.
304 curHasImage := false
305 curHasKittyImage := false
306 for _, l := range frame {
307 if containsImageEscape(l) {
308 curHasImage = true
309 if strings.Contains(l, "\x1b_G") {
310 curHasKittyImage = true
311 }
312 }
313 }
314 forceAll := curHasImage || r.prevHadImage
315 if forceAll {
316 // No-home variant: the per-row MoveTo(i+1, 1) writes in the
317 // loop below position the cursor for every painted row, so
318 // the embedded \x1b[H would only serve to make VS Code snap
319 // its scrollbar to the top of the viewport on every image or
320 // selection-highlight frame.
321 w.WriteString(SeqClearScreenNoHome)
322 if curHasKittyImage {
323 // Delete previously placed kitty images once per frame,
324 // before rewriting all rows. Doing this inside each image
325 // escape makes only the last image in the frame survive.
326 w.WriteString("\x1b_Ga=d\x1b\\")
327 }
328 }
329
330 // Detect selection highlights: if the current OR previous frame
331 // has selection-background rows, force full repaint. VS Code's
332 // terminal doesn't reliably clear background colors on row
333 // overwrites, leaving ghost highlights behind.
334 hasSelection := false
335 if r.theme.Background == nil {
336 selectionBG := sgrBG(r.theme.SelectionBG)
337 for _, l := range frame {
338 if selectionBG != "" && strings.Contains(l, selectionBG) {
339 hasSelection = true
340 break
341 }
342 }
343 if !hasSelection && r.prev != nil {
344 for _, l := range r.prev {
345 if selectionBG != "" && strings.Contains(l, selectionBG) {
346 hasSelection = true
347 break
348 }
349 }
350 }
351 }
352
353 full := r.prev == nil || len(r.prev) != r.rows
354 for i := 0; i < r.rows; i++ {
355 if full || forceAll || hasSelection || r.prev[i] != frame[i] {
356 w.WriteString(MoveTo(i+1, 1))
357 w.WriteString("\x1b[0m") // reset all attributes first
358 w.WriteString(SeqClearLine)
359 w.WriteString(frame[i])
360 }
361 }
362
363 if cursorRow >= 0 {
364 absRow := top + cursorRow + 1
365 absCol := cursorCol + 1
366 if absRow >= 1 && absRow <= r.rows {
367 w.WriteString(MoveTo(absRow, absCol))
368 w.WriteString(SeqShowCursor)
369 }
370 }
371 w.WriteString(SeqSynchronizedOff)
372
373 _, _ = io.WriteString(r.out, w.String())
374
375 r.prev = frame
376 r.prevHadImage = curHasImage
377 r.cursorRow = cursorRow
378 r.cursorCol = cursorCol
379}
380
381// DrawLog renders zot in the terminal's main screen as normal terminal
382// flow rather than a fixed full-screen frame. Chat lines are emitted once
383// into the host terminal scrollback; the current bottom block (dialogs,
384// slash popup, status, editor) is erased and redrawn in place at the end.
385//
386// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
387func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
388 if r.cols == 0 || r.rows == 0 {
389 return
390 }
391 if len(bottom) == 0 {
392 bottom = []string{""}
393 }
394 chatFrame := make([]string, len(chat))
395 for i, line := range chat {
396 chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
397 }
398 bottomFrame := make([]string, len(bottom))
399 for i, line := range bottom {
400 bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
401 }
402
403 // Always reserve one real row below the editor/status band. This is
404 // renderer-owned (not a best-effort trailing blank in the caller's
405 // bottom block), so the logical-buffer diff keeps it visible and cursor
406 // placement remains relative to the editor itself.
407 const bottomMarginRows = 1
408 lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
409 lines = append(lines, chatFrame...)
410 lines = append(lines, bottomFrame...)
411 for range bottomMarginRows {
412 lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
413 }
414 // In main-screen flow mode zot normally emits only its logical
415 // content rows and leaves the rest of the terminal viewport alone.
416 // When a theme background is configured, fill that otherwise-idle
417 // space with painted blank rows so the full window is tinted while
418 // keeping the scrollback-oriented renderer model unchanged for the
419 // default transparent case.
420 if r.theme.Background != nil {
421 for len(lines) < r.rows {
422 lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
423 }
424 }
425 if len(lines) == 0 {
426 lines = []string{""}
427 }
428
429 cursorTargetRow := -1
430 if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
431 cursorTargetRow = len(chatFrame) + cursorBottomRow
432 }
433
434 // Idle no-op fast path. When the buffer AND the cursor position
435 // haven't changed since the last DrawLog, emit nothing. The
436 // alternative — always writing SeqHideCursor + cursor-position +
437 // SeqShowCursor — resets the terminal's cursor blink timer on
438 // every tick. At our 120ms animation cadence that means the
439 // caret in an idle dialog editor (e.g. an open swarm transcript
440 // for an agent that's currently idle) appears as a solid block
441 // that never blinks, because we keep "showing" it before the
442 // terminal can blink it off. Bailing out here lets the OS run
443 // its blink cycle.
444 if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
445 return
446 }
447
448 var w strings.Builder
449 w.WriteString(SeqSynchronizedOn)
450 w.WriteString(SeqHideCursor)
451
452 writeFull := func(clear bool) {
453 if clear {
454 w.WriteString(SeqDeleteKittyImages)
455 w.WriteString(SeqClearScreenNoHome)
456 w.WriteString(r.clearScrollbackSeq())
457 w.WriteString(MoveTo(1, 1))
458 }
459 for idx, line := range lines {
460 if idx > 0 {
461 w.WriteString("\r\n")
462 }
463 w.WriteString("\x1b[0m")
464 w.WriteString(SeqClearLine)
465 w.WriteString(line)
466 }
467 r.logHardwareRow = len(lines) - 1
468 r.logViewportTop = len(lines) - r.rows
469 if r.logViewportTop < 0 {
470 r.logViewportTop = 0
471 }
472 }
473
474 moveToLogicalRow := func(targetRow int) {
475 if targetRow < 0 {
476 targetRow = 0
477 }
478 if targetRow >= len(lines) {
479 targetRow = len(lines) - 1
480 }
481 viewportBottom := r.logViewportTop + r.rows - 1
482 if targetRow > viewportBottom {
483 currentScreenRow := r.logHardwareRow - r.logViewportTop
484 if currentScreenRow < 0 {
485 currentScreenRow = 0
486 }
487 if currentScreenRow >= r.rows {
488 currentScreenRow = r.rows - 1
489 }
490 moveToBottom := r.rows - 1 - currentScreenRow
491 if moveToBottom > 0 {
492 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
493 }
494 scroll := targetRow - viewportBottom
495 for s := 0; s < scroll; s++ {
496 w.WriteString("\r\n")
497 }
498 r.logViewportTop += scroll
499 r.logHardwareRow = targetRow
500 return
501 }
502 currentScreenRow := r.logHardwareRow - r.logViewportTop
503 targetScreenRow := targetRow - r.logViewportTop
504 lineDiff := targetScreenRow - currentScreenRow
505 if lineDiff > 0 {
506 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
507 } else if lineDiff < 0 {
508 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
509 }
510 r.logHardwareRow = targetRow
511 }
512
513 positionCursor := func() {
514 if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
515 return
516 }
517 moveToLogicalRow(cursorTargetRow)
518 w.WriteString("\r")
519 if cursorCol > 0 {
520 w.WriteString("\x1b[" + itoa(cursorCol) + "C")
521 }
522 w.WriteString(SeqShowCursor)
523 }
524
525 // Selection-highlight workaround removed: it could mis-invalidate
526 // user-bubble padding rows whose colored bg made botHasHL trip,
527 // causing the next diff pass to leave those rows visually thinned
528 // because the cached entry was the \x00 sentinel rather than the
529 // real previous bg-colored row.
530
531 full := !r.logInit || len(r.logLines) == 0
532 if full {
533 writeFull(true)
534 r.logInit = true
535 } else {
536 firstChanged := -1
537 lastChanged := -1
538 maxLines := len(lines)
539 if len(r.logLines) > maxLines {
540 maxLines = len(r.logLines)
541 }
542 for idx := 0; idx < maxLines; idx++ {
543 oldLine := ""
544 if idx < len(r.logLines) {
545 oldLine = r.logLines[idx]
546 }
547 newLine := ""
548 if idx < len(lines) {
549 newLine = lines[idx]
550 }
551 if oldLine != newLine {
552 if firstChanged == -1 {
553 firstChanged = idx
554 }
555 lastChanged = idx
556 }
557 }
558 // Buffer grew but the appended rows were empty (or otherwise
559 // equal to the implicit "" past the old end). The diff above
560 // won't flag those rows, yet the renderer still needs to
561 // advance its hardware cursor / viewport tracking past them so
562 // the next render starts from the correct position. Treat the
563 // extension as changed.
564 if len(lines) > len(r.logLines) {
565 if firstChanged == -1 {
566 firstChanged = len(r.logLines)
567 }
568 if lastChanged < len(lines)-1 {
569 lastChanged = len(lines) - 1
570 }
571 }
572
573 if firstChanged == -1 {
574 // No content changes; the final cursor positioning below may still
575 // move the hardware cursor if the editor cursor changed.
576 } else if firstChanged < r.logViewportTop {
577 // Changes above the visible viewport cannot be patched safely.
578 writeFull(true)
579 } else {
580 prevViewportTop := r.logViewportTop
581 viewportTop := prevViewportTop
582 hardwareRow := r.logHardwareRow
583 prevViewportBottom := prevViewportTop + r.rows - 1
584 appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
585 moveTarget := firstChanged
586 if appendStart {
587 moveTarget = firstChanged - 1
588 }
589
590 if moveTarget > prevViewportBottom {
591 currentScreenRow := hardwareRow - prevViewportTop
592 if currentScreenRow < 0 {
593 currentScreenRow = 0
594 }
595 if currentScreenRow >= r.rows {
596 currentScreenRow = r.rows - 1
597 }
598 moveToBottom := r.rows - 1 - currentScreenRow
599 if moveToBottom > 0 {
600 w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
601 }
602 scroll := moveTarget - prevViewportBottom
603 for s := 0; s < scroll; s++ {
604 w.WriteString("\r\n")
605 }
606 prevViewportTop += scroll
607 viewportTop += scroll
608 hardwareRow = moveTarget
609 }
610
611 currentScreenRow := hardwareRow - prevViewportTop
612 targetScreenRow := moveTarget - viewportTop
613 lineDiff := targetScreenRow - currentScreenRow
614 if lineDiff > 0 {
615 w.WriteString("\x1b[" + itoa(lineDiff) + "B")
616 } else if lineDiff < 0 {
617 w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
618 }
619 if appendStart {
620 w.WriteString("\r\n")
621 } else {
622 w.WriteString("\r")
623 }
624
625 renderEnd := lastChanged
626 if renderEnd >= len(lines) {
627 renderEnd = len(lines) - 1
628 }
629 for idx := firstChanged; idx <= renderEnd; idx++ {
630 if idx > firstChanged {
631 w.WriteString("\r\n")
632 }
633 w.WriteString("\x1b[0m")
634 w.WriteString(SeqClearLine)
635 w.WriteString(lines[idx])
636 }
637 finalRow := renderEnd
638 if len(r.logLines) > len(lines) {
639 extra := len(r.logLines) - len(lines)
640 if extra > r.rows {
641 writeFull(true)
642 } else {
643 for e := 0; e < extra; e++ {
644 w.WriteString("\x1b[1B")
645 w.WriteString("\r")
646 w.WriteString("\x1b[0m")
647 w.WriteString(SeqClearLine)
648 finalRow++
649 }
650 if extra > 0 {
651 w.WriteString("\x1b[" + itoa(extra) + "A")
652 finalRow -= extra
653 }
654 }
655 }
656 r.logHardwareRow = finalRow
657 r.logViewportTop = viewportTop
658 if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
659 r.logViewportTop = minTop
660 }
661 if r.logViewportTop < 0 {
662 r.logViewportTop = 0
663 }
664 }
665 }
666
667 positionCursor()
668 w.WriteString(SeqSynchronizedOff)
669 _, _ = io.WriteString(r.out, w.String())
670
671 r.logChat = append(r.logChat[:0], chatFrame...)
672 r.logBottom = append(r.logBottom[:0], bottomFrame...)
673 r.logLines = append(r.logLines[:0], lines...)
674 r.cursorRow = cursorBottomRow
675 r.cursorCol = cursorCol
676}
677
678// sameLines reports whether two []string have the exact same
679// length and per-row contents. Used by DrawLog's idle no-op fast
680// path; cheap enough at our frame rates and far simpler than
681// hashing every byte.
682func sameLines(a, b []string) bool {
683 if len(a) != len(b) {
684 return false
685 }
686 for i := range a {
687 if a[i] != b[i] {
688 return false
689 }
690 }
691 return true
692}
693
694func writeBlock(w *strings.Builder, lines []string) {
695 for i, line := range lines {
696 w.WriteString("\x1b[0m")
697 w.WriteString(SeqClearLine)
698 w.WriteString(line)
699 if i < len(lines)-1 {
700 w.WriteString("\r\n")
701 }
702 }
703}
704
705func tailTruncated(lines []string, maxRows, cols int) []string {
706 if maxRows <= 0 {
707 return nil
708 }
709 if len(lines) > maxRows {
710 lines = lines[len(lines)-maxRows:]
711 }
712 out := make([]string, len(lines))
713 for i, line := range lines {
714 out[i] = truncateToWidth(line, cols)
715 }
716 return out
717}