zot/internal/tui/render.go
patriceckhart 487b097da4 fix(tui): VS Code ghost highlight on selection navigation
Forces full attribute reset before clearing each row when selection highlights are present. VS Code's xterm.js doesn't reliably clear background colors on row overwrite without an explicit reset.
2026-04-25 20:49:41 +02:00

247 lines
6.9 KiB
Go

package tui
import (
"io"
"strings"
"github.com/mattn/go-runewidth"
)
// runewidthRune reports the number of cells a rune occupies, pinned
// here so the renderer does not depend on the editor's helper.
func runewidthRune(r rune) int { return runewidth.RuneWidth(r) }
// Renderer maintains a previous frame and writes only the lines that
// changed on each Draw(). Callers pass a full target frame (slice of
// styled lines, already wrapped to width).
type Renderer struct {
out io.Writer
prev []string
rows int // terminal rows
cols int // terminal cols
// Cursor position after last draw (for placing input cursor).
cursorRow int
cursorCol int
// hideCursor when true prevents ShowCursor from being emitted.
hideCursor bool
// prevHadImage tracks whether the previous frame contained an
// inline-image escape so we can force a full clear+repaint whenever
// the image set changes. Only matters when inline images are
// enabled via ZOT_INLINE_IMAGES; defaults to false.
prevHadImage bool
}
// NewRenderer returns a renderer that writes to out.
func NewRenderer(out io.Writer) *Renderer {
return &Renderer{out: out}
}
// Resize tells the renderer the current terminal size.
//
// On a real size change we also issue a clear-screen so the next Draw
// starts from a blank slate. Without the clear, characters from the
// old (wider) layout linger past the new right edge and rows from
// before the new bottom hang around as garbage.
func (r *Renderer) Resize(cols, rows int) {
if cols != r.cols || rows != r.rows {
r.cols = cols
r.rows = rows
r.prev = nil
if r.out != nil {
_, _ = io.WriteString(r.out, SeqClearScreen)
}
}
}
// Clear forces a full repaint on the next Draw and clears the screen.
func (r *Renderer) Clear() {
r.prev = nil
_, _ = io.WriteString(r.out, SeqClearScreen)
}
// Invalidate forces a full repaint on the next Draw without clearing the
// whole terminal first. Useful when the cached diff is unreliable but a
// visible full-screen flash would be too distracting.
func (r *Renderer) Invalidate() {
r.prev = nil
}
// Draw updates the terminal so that the visible frame ends with the
// given lines (bottom-aligned). cursorRow/cursorCol are offsets within
// the lines slice indicating where to place the terminal cursor; use
// -1 to hide it.
// containsImageEscape reports whether the line carries an inline-image
// escape we must repaint rather than diff against the previous frame.
func containsImageEscape(s string) bool {
return strings.Contains(s, "\x1b]1337;File=") || strings.Contains(s, "\x1b_G")
}
// truncateToWidth clips s so its on-screen width doesn't exceed cols
// cells, preserving ANSI CSI escape sequences (which don't consume
// cells). Lines carrying an inline-image escape are returned as-is
// since we can't measure their painted size.
//
// Fast path: a byte-length <= cols is a conservative upper bound
// guaranteeing the cell width is also <= cols, so we skip all the
// rune-width math. That covers the vast majority of lines in a
// transcript (narrow terminals wrap early; wide ones leave headroom).
func truncateToWidth(s string, cols int) string {
if cols <= 0 || containsImageEscape(s) {
return s
}
if len(s) <= cols {
return s
}
var out strings.Builder
out.Grow(len(s))
seen := 0
runes := []rune(s)
for i := 0; i < len(runes); {
r := runes[i]
// CSI escape sequence (ESC [ ... final): zero-width.
if r == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
out.WriteRune(r)
out.WriteRune(runes[i+1])
i += 2
for i < len(runes) {
c := runes[i]
out.WriteRune(c)
i++
if c >= 0x40 && c <= 0x7e {
break
}
}
continue
}
rw := runewidthRune(r)
if seen+rw > cols {
// Flush any trailing ANSI escapes (resets, erase-to-EOL)
// so background colors and cleanup sequences survive.
for i < len(runes) {
if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' {
out.WriteRune(runes[i])
out.WriteRune(runes[i+1])
i += 2
for i < len(runes) {
c := runes[i]
out.WriteRune(c)
i++
if c >= 0x40 && c <= 0x7e {
break
}
}
} else {
break
}
}
break
}
out.WriteRune(r)
seen += rw
i++
}
return out.String()
}
func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
if r.cols == 0 || r.rows == 0 {
return
}
// Bottom-align: only the last r.rows lines are visible.
visible := lines
if len(visible) > r.rows {
visible = visible[len(visible)-r.rows:]
cursorRow -= len(lines) - len(visible)
}
// Pad to r.rows with empty lines at the top. Every line is also
// hard-truncated to cols so the terminal never soft-wraps our output
// (which would push the status bar out of its row).
frame := make([]string, r.rows)
top := r.rows - len(visible)
for i := 0; i < top; i++ {
frame[i] = ""
}
for i, line := range visible {
frame[top+i] = truncateToWidth(line, r.cols)
}
var w strings.Builder
w.WriteString(SeqSynchronizedOn)
w.WriteString(SeqHideCursor)
// When inline images are in play we always full-repaint (clear
// screen first, then rewrite every row). Terminals manage image
// pixels in a layer we cannot diff against, so the per-line cache
// is unreliable. Inline images are opt-in via ZOT_INLINE_IMAGES;
// the common code path below is the fast cached diff.
curHasImage := false
curHasKittyImage := false
for _, l := range frame {
if containsImageEscape(l) {
curHasImage = true
if strings.Contains(l, "\x1b_G") {
curHasKittyImage = true
}
}
}
forceAll := curHasImage || r.prevHadImage
if forceAll {
w.WriteString(SeqClearScreen)
if curHasKittyImage {
// Delete previously placed kitty images once per frame,
// before rewriting all rows. Doing this inside each image
// escape makes only the last image in the frame survive.
w.WriteString("\x1b_Ga=d\x1b\\")
}
}
// Detect selection highlights: if the current OR previous frame
// has selection-background rows, force full repaint. VS Code's
// terminal doesn't reliably clear background colors on row
// overwrites, leaving ghost highlights behind.
hasSelection := false
for _, l := range frame {
if strings.Contains(l, "\x1b[48;5;") {
hasSelection = true
break
}
}
if !hasSelection && r.prev != nil {
for _, l := range r.prev {
if strings.Contains(l, "\x1b[48;5;") {
hasSelection = true
break
}
}
}
full := r.prev == nil || len(r.prev) != r.rows
for i := 0; i < r.rows; i++ {
if full || forceAll || hasSelection || r.prev[i] != frame[i] {
w.WriteString(MoveTo(i+1, 1))
w.WriteString("\x1b[0m") // reset all attributes first
w.WriteString(SeqClearLine)
w.WriteString(frame[i])
}
}
if cursorRow >= 0 {
absRow := top + cursorRow + 1
absCol := cursorCol + 1
if absRow >= 1 && absRow <= r.rows {
w.WriteString(MoveTo(absRow, absCol))
w.WriteString(SeqShowCursor)
}
}
w.WriteString(SeqSynchronizedOff)
_, _ = io.WriteString(r.out, w.String())
r.prev = frame
r.prevHadImage = curHasImage
r.cursorRow = cursorRow
r.cursorCol = cursorCol
}