mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
On VS Code's xterm.js the transcript is taller than the viewport, so an in-place clear (home + erase-to-end) only wipes the visible rows and the scrolled-away part lingers in retained scrollback, stacking a duplicate on the next full repaint. - Clear() (Ctrl+L) now emits \x1b[3J under keepScrollback to actually drop that scrollback, then homes and repaints. Accepts VS Code's viewport-snap since the user explicitly asked for a clean screen. - Overlay close (esc on a dialog, slash/file popup dismissal) now runs the same Clear() so closing a picker purges the stale overlay rows instead of leaving them in scrollback. - Resize() does the same purge under keepScrollback; previously it skipped the wipe and left a half-repainted old-width frame until the user pressed Ctrl+L. Other terminals keep their no-snap clear path.
765 lines
24 KiB
Go
765 lines
24 KiB
Go
package tui
|
|
|
|
import (
|
|
"io"
|
|
"os"
|
|
"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
|
|
|
|
// Main-screen flow renderer state. logLines is the full logical
|
|
// buffer (chat + live bottom band) from the previous DrawLog call.
|
|
// logViewportTop/logHardwareRow track where that logical buffer sits
|
|
// in the terminal's visible viewport so we can diff safely, and bail
|
|
// out to clear+replay when the diff would touch rows that are no
|
|
// longer addressable.
|
|
logChat []string
|
|
logBottom []string
|
|
logLines []string
|
|
logViewportTop int
|
|
logHardwareRow int
|
|
logInit bool
|
|
|
|
// keepScrollback is true when we must NOT emit \x1b[3J
|
|
// (erase-in-display 3, "clear scrollback").
|
|
//
|
|
// VS Code's integrated terminal (xterm.js) interprets \x1b[3J
|
|
// as "also snap the viewport to the top of the remaining
|
|
// buffer." Once the user has reopened a terminal with VS
|
|
// Code's persistent-sessions feature on, there is real
|
|
// replayed scrollback above the live cursor, and the snap is
|
|
// visible: the host scrollbar jumps to the top every time we
|
|
// do a full repaint (first paint, Ctrl+L via Renderer.Clear,
|
|
// any writeFull(true) shrink). On every other terminal we
|
|
// tested (iTerm, Ghostty, Kitty, Alacritty, Apple Terminal)
|
|
// \x1b[3J just drops scrollback rows without moving the
|
|
// viewport, which is what we actually want.
|
|
//
|
|
// The trade-off when keepScrollback is true: stale zot frames
|
|
// remain in scrollback above the live view, so scrolling up
|
|
// in VS Code's terminal shows old (already-superseded) zot
|
|
// output. That is strictly less disruptive than the
|
|
// scrollbar yanking to top on every Ctrl+L, and it is a
|
|
// limitation specific to VS Code's terminal that we have no
|
|
// way to work around without breaking other terminals.
|
|
keepScrollback bool
|
|
|
|
// theme is optional renderer-level styling applied at the final
|
|
// row-writing boundary. In particular, Theme.Background is painted
|
|
// as a full-width row background without making every View renderer
|
|
// know about terminal padding and reset semantics.
|
|
theme Theme
|
|
}
|
|
|
|
// NewRenderer returns a renderer that writes to out.
|
|
//
|
|
// Detects VS Code's integrated terminal via $TERM_PROGRAM and, when
|
|
// detected, disables emission of \x1b[3J for the reasons documented
|
|
// on Renderer.keepScrollback. The env var is set by VS Code itself
|
|
// (and by Cursor, which forks VS Code's terminal — same xterm.js,
|
|
// same bug), so no user configuration is required.
|
|
func NewRenderer(out io.Writer) *Renderer {
|
|
return &Renderer{
|
|
out: out,
|
|
keepScrollback: os.Getenv("TERM_PROGRAM") == "vscode",
|
|
}
|
|
}
|
|
|
|
// SetTheme updates renderer-level terminal styling. Changing the
|
|
// background affects every row, so cached frame state is invalidated.
|
|
func (r *Renderer) SetTheme(th Theme) {
|
|
r.theme = th
|
|
r.Invalidate()
|
|
}
|
|
|
|
// 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) ResetScrollRegion() {
|
|
if r.out != nil {
|
|
_, _ = io.WriteString(r.out, SeqResetScrollRegion)
|
|
}
|
|
}
|
|
|
|
func (r *Renderer) Resize(cols, rows int) {
|
|
if cols != r.cols || rows != r.rows {
|
|
r.cols = cols
|
|
r.rows = rows
|
|
r.prev = nil
|
|
r.logChat = nil
|
|
r.logBottom = nil
|
|
r.logLines = nil
|
|
r.logViewportTop = 0
|
|
r.logHardwareRow = 0
|
|
r.logInit = false
|
|
if r.out != nil {
|
|
if r.keepScrollback {
|
|
// A resize is a discrete user action, like Ctrl+L: the
|
|
// old-width frame must be fully purged or the in-place
|
|
// viewport clear leaves the scrolled-away rows behind
|
|
// (the user otherwise has to press Ctrl+L to fix it).
|
|
// Emit \x1b[3J to drop the retained scrollback and
|
|
// repaint clean, accepting VS Code's viewport-snap the
|
|
// same way Clear does.
|
|
_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
|
|
} else {
|
|
// Clear both screen and (where safe) scrollback so stale
|
|
// content from the old width doesn't bleed through. Move
|
|
// to (1,1) so the next DrawLog/writeFull starts from a
|
|
// clean slate. Use the no-home variant: the explicit
|
|
// MoveTo below sets the cursor without triggering VS
|
|
// Code's viewport-snap.
|
|
_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Clear forces a full repaint on the next Draw and clears the screen
|
|
// plus scrollback. In main-screen flow mode this is required whenever
|
|
// already-emitted transcript layout changes (for example ctrl+o
|
|
// expand/collapse), because terminal scrollback cannot be edited
|
|
// reliably once printed.
|
|
func (r *Renderer) Clear() {
|
|
r.prev = nil
|
|
r.logChat = nil
|
|
r.logBottom = nil
|
|
r.logLines = nil
|
|
r.logViewportTop = 0
|
|
r.logHardwareRow = 0
|
|
r.logInit = false
|
|
if r.keepScrollback {
|
|
// On VS Code's xterm.js the transcript is taller than the
|
|
// viewport, so an in-place clear (home + erase-to-end) only
|
|
// wipes the visible rows: the part that scrolled above the
|
|
// viewport stays in the retained buffer and the next full
|
|
// repaint stacks a duplicate above the live frame.
|
|
//
|
|
// Clear() is an explicit user refresh (Ctrl+L), so here we do
|
|
// emit \x1b[3J to actually drop that scrollback, then home and
|
|
// repaint. This is the one place we accept VS Code's
|
|
// viewport-snap, because the user asked for a clean screen.
|
|
// (Implicit repaints, e.g. closing a dialog, deliberately
|
|
// avoid this path so they never snap the viewport.)
|
|
if r.out != nil {
|
|
_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreen+SeqClearScrollback+MoveTo(1, 1))
|
|
}
|
|
return
|
|
}
|
|
_, _ = io.WriteString(r.out, SeqDeleteKittyImages+SeqClearScreenNoHome+r.clearScrollbackSeq()+MoveTo(1, 1))
|
|
}
|
|
|
|
// clearScrollbackSeq returns the scrollback-clear escape, or the
|
|
// empty string when we are running under a terminal where emitting
|
|
// it has user-visible side effects (see Renderer.keepScrollback).
|
|
// Callers concatenate this into a larger control sequence; an empty
|
|
// return value is a no-op there.
|
|
func (r *Renderer) clearScrollbackSeq() string {
|
|
if r.keepScrollback {
|
|
return ""
|
|
}
|
|
return SeqClearScrollback
|
|
}
|
|
|
|
// KeepsScrollback reports whether this renderer suppresses the
|
|
// scrollback-clear escape (true under VS Code's terminal). Callers
|
|
// use it to pick a viewport-safe full repaint (Invalidate) over a
|
|
// scrollback-clearing one (Clear) when redrawing overlays.
|
|
func (r *Renderer) KeepsScrollback() bool { return r.keepScrollback }
|
|
|
|
// 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
|
|
r.logLines = 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")
|
|
}
|
|
|
|
// paintBackgroundRow applies the optional theme background to a single
|
|
// already-truncated terminal row. It pads with spaces to cols so the
|
|
// background reaches the right edge, and re-applies the background
|
|
// after full SGR resets inside the row so local styling does not punch
|
|
// transparent holes through the global tint.
|
|
func paintBackgroundRow(line string, cols int, th Theme) string {
|
|
bg := th.BackgroundStyle()
|
|
if bg == "" || cols <= 0 || containsImageEscape(line) {
|
|
return line
|
|
}
|
|
line = strings.ReplaceAll(line, reset, reset+bg)
|
|
if w := visibleWidth(line); w < cols {
|
|
line += strings.Repeat(" ", cols-w)
|
|
}
|
|
return bg + line + reset
|
|
}
|
|
|
|
// 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] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
|
|
}
|
|
if r.theme.Background != nil {
|
|
for i := 0; i < top; i++ {
|
|
frame[i] = paintBackgroundRow("", r.cols, r.theme)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
// No-home variant: the per-row MoveTo(i+1, 1) writes in the
|
|
// loop below position the cursor for every painted row, so
|
|
// the embedded \x1b[H would only serve to make VS Code snap
|
|
// its scrollbar to the top of the viewport on every image or
|
|
// selection-highlight frame.
|
|
w.WriteString(SeqClearScreenNoHome)
|
|
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
|
|
if r.theme.Background == nil {
|
|
selectionBG := sgrBG(r.theme.SelectionBG)
|
|
for _, l := range frame {
|
|
if selectionBG != "" && strings.Contains(l, selectionBG) {
|
|
hasSelection = true
|
|
break
|
|
}
|
|
}
|
|
if !hasSelection && r.prev != nil {
|
|
for _, l := range r.prev {
|
|
if selectionBG != "" && strings.Contains(l, selectionBG) {
|
|
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
|
|
}
|
|
|
|
// DrawLog renders zot in the terminal's main screen as normal terminal
|
|
// flow rather than a fixed full-screen frame. Chat lines are emitted once
|
|
// into the host terminal scrollback; the current bottom block (dialogs,
|
|
// slash popup, status, editor) is erased and redrawn in place at the end.
|
|
//
|
|
// cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
|
|
func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
|
|
if r.cols == 0 || r.rows == 0 {
|
|
return
|
|
}
|
|
if len(bottom) == 0 {
|
|
bottom = []string{""}
|
|
}
|
|
chatFrame := make([]string, len(chat))
|
|
for i, line := range chat {
|
|
chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
|
|
}
|
|
bottomFrame := make([]string, len(bottom))
|
|
for i, line := range bottom {
|
|
bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.theme)
|
|
}
|
|
|
|
// Always reserve one real row below the editor/status band. This is
|
|
// renderer-owned (not a best-effort trailing blank in the caller's
|
|
// bottom block), so the logical-buffer diff keeps it visible and cursor
|
|
// placement remains relative to the editor itself.
|
|
const bottomMarginRows = 1
|
|
lines := make([]string, 0, len(chatFrame)+len(bottomFrame)+bottomMarginRows)
|
|
lines = append(lines, chatFrame...)
|
|
lines = append(lines, bottomFrame...)
|
|
for range bottomMarginRows {
|
|
lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
|
|
}
|
|
// In main-screen flow mode zot normally emits only its logical
|
|
// content rows and leaves the rest of the terminal viewport alone.
|
|
// When a theme background is configured, fill that otherwise-idle
|
|
// space with painted blank rows so the full window is tinted while
|
|
// keeping the scrollback-oriented renderer model unchanged for the
|
|
// default transparent case.
|
|
if r.theme.Background != nil {
|
|
for len(lines) < r.rows {
|
|
lines = append(lines, paintBackgroundRow("", r.cols, r.theme))
|
|
}
|
|
}
|
|
if len(lines) == 0 {
|
|
lines = []string{""}
|
|
}
|
|
|
|
cursorTargetRow := -1
|
|
if cursorBottomRow >= 0 && cursorBottomRow < len(bottomFrame) {
|
|
cursorTargetRow = len(chatFrame) + cursorBottomRow
|
|
}
|
|
|
|
// Idle no-op fast path. When the buffer AND the cursor position
|
|
// haven't changed since the last DrawLog, emit nothing. The
|
|
// alternative — always writing SeqHideCursor + cursor-position +
|
|
// SeqShowCursor — resets the terminal's cursor blink timer on
|
|
// every tick. At our 120ms animation cadence that means the
|
|
// caret in an idle dialog editor (e.g. an open swarm transcript
|
|
// for an agent that's currently idle) appears as a solid block
|
|
// that never blinks, because we keep "showing" it before the
|
|
// terminal can blink it off. Bailing out here lets the OS run
|
|
// its blink cycle.
|
|
if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
|
|
return
|
|
}
|
|
|
|
var w strings.Builder
|
|
w.WriteString(SeqSynchronizedOn)
|
|
w.WriteString(SeqHideCursor)
|
|
|
|
writeFull := func(clear bool) {
|
|
if clear {
|
|
w.WriteString(SeqDeleteKittyImages)
|
|
if r.keepScrollback {
|
|
// VS Code's xterm.js scrolls the visible content up into
|
|
// scrollback on \x1b[2J, which duplicates the frame (the
|
|
// old paint stays above the new one). Home to the
|
|
// viewport top and erase-to-end (\x1b[0J) instead: that
|
|
// clears the visible screen in place without pushing the
|
|
// previous frame into scrollback. We still cannot drop
|
|
// existing scrollback (\x1b[3J snaps the viewport there),
|
|
// but a full repaint no longer stacks a fresh copy below
|
|
// the old one.
|
|
w.WriteString(SeqCursorHome)
|
|
w.WriteString(SeqClearToEnd)
|
|
} else {
|
|
w.WriteString(SeqClearScreenNoHome)
|
|
w.WriteString(r.clearScrollbackSeq())
|
|
w.WriteString(MoveTo(1, 1))
|
|
}
|
|
}
|
|
for idx, line := range lines {
|
|
if idx > 0 {
|
|
w.WriteString("\r\n")
|
|
}
|
|
w.WriteString("\x1b[0m")
|
|
w.WriteString(SeqClearLine)
|
|
w.WriteString(line)
|
|
}
|
|
r.logHardwareRow = len(lines) - 1
|
|
r.logViewportTop = len(lines) - r.rows
|
|
if r.logViewportTop < 0 {
|
|
r.logViewportTop = 0
|
|
}
|
|
}
|
|
|
|
moveToLogicalRow := func(targetRow int) {
|
|
if targetRow < 0 {
|
|
targetRow = 0
|
|
}
|
|
if targetRow >= len(lines) {
|
|
targetRow = len(lines) - 1
|
|
}
|
|
viewportBottom := r.logViewportTop + r.rows - 1
|
|
if targetRow > viewportBottom {
|
|
currentScreenRow := r.logHardwareRow - r.logViewportTop
|
|
if currentScreenRow < 0 {
|
|
currentScreenRow = 0
|
|
}
|
|
if currentScreenRow >= r.rows {
|
|
currentScreenRow = r.rows - 1
|
|
}
|
|
moveToBottom := r.rows - 1 - currentScreenRow
|
|
if moveToBottom > 0 {
|
|
w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
|
|
}
|
|
scroll := targetRow - viewportBottom
|
|
for s := 0; s < scroll; s++ {
|
|
w.WriteString("\r\n")
|
|
}
|
|
r.logViewportTop += scroll
|
|
r.logHardwareRow = targetRow
|
|
return
|
|
}
|
|
currentScreenRow := r.logHardwareRow - r.logViewportTop
|
|
targetScreenRow := targetRow - r.logViewportTop
|
|
lineDiff := targetScreenRow - currentScreenRow
|
|
if lineDiff > 0 {
|
|
w.WriteString("\x1b[" + itoa(lineDiff) + "B")
|
|
} else if lineDiff < 0 {
|
|
w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
|
|
}
|
|
r.logHardwareRow = targetRow
|
|
}
|
|
|
|
positionCursor := func() {
|
|
if cursorTargetRow < 0 || cursorTargetRow >= len(lines) {
|
|
return
|
|
}
|
|
moveToLogicalRow(cursorTargetRow)
|
|
w.WriteString("\r")
|
|
if cursorCol > 0 {
|
|
w.WriteString("\x1b[" + itoa(cursorCol) + "C")
|
|
}
|
|
w.WriteString(SeqShowCursor)
|
|
}
|
|
|
|
// Selection-highlight workaround removed: it could mis-invalidate
|
|
// user-bubble padding rows whose colored bg made botHasHL trip,
|
|
// causing the next diff pass to leave those rows visually thinned
|
|
// because the cached entry was the \x00 sentinel rather than the
|
|
// real previous bg-colored row.
|
|
|
|
full := !r.logInit || len(r.logLines) == 0
|
|
if full {
|
|
writeFull(true)
|
|
r.logInit = true
|
|
} else {
|
|
firstChanged := -1
|
|
lastChanged := -1
|
|
maxLines := len(lines)
|
|
if len(r.logLines) > maxLines {
|
|
maxLines = len(r.logLines)
|
|
}
|
|
for idx := 0; idx < maxLines; idx++ {
|
|
oldLine := ""
|
|
if idx < len(r.logLines) {
|
|
oldLine = r.logLines[idx]
|
|
}
|
|
newLine := ""
|
|
if idx < len(lines) {
|
|
newLine = lines[idx]
|
|
}
|
|
if oldLine != newLine {
|
|
if firstChanged == -1 {
|
|
firstChanged = idx
|
|
}
|
|
lastChanged = idx
|
|
}
|
|
}
|
|
// Buffer grew but the appended rows were empty (or otherwise
|
|
// equal to the implicit "" past the old end). The diff above
|
|
// won't flag those rows, yet the renderer still needs to
|
|
// advance its hardware cursor / viewport tracking past them so
|
|
// the next render starts from the correct position. Treat the
|
|
// extension as changed.
|
|
if len(lines) > len(r.logLines) {
|
|
if firstChanged == -1 {
|
|
firstChanged = len(r.logLines)
|
|
}
|
|
if lastChanged < len(lines)-1 {
|
|
lastChanged = len(lines) - 1
|
|
}
|
|
}
|
|
|
|
if firstChanged == -1 {
|
|
// No content changes; the final cursor positioning below may still
|
|
// move the hardware cursor if the editor cursor changed.
|
|
} else if firstChanged < r.logViewportTop {
|
|
// Changes above the visible viewport cannot be patched safely.
|
|
writeFull(true)
|
|
} else {
|
|
prevViewportTop := r.logViewportTop
|
|
viewportTop := prevViewportTop
|
|
hardwareRow := r.logHardwareRow
|
|
prevViewportBottom := prevViewportTop + r.rows - 1
|
|
appendStart := len(lines) > len(r.logLines) && firstChanged == len(r.logLines) && firstChanged > 0
|
|
moveTarget := firstChanged
|
|
if appendStart {
|
|
moveTarget = firstChanged - 1
|
|
}
|
|
|
|
if moveTarget > prevViewportBottom {
|
|
currentScreenRow := hardwareRow - prevViewportTop
|
|
if currentScreenRow < 0 {
|
|
currentScreenRow = 0
|
|
}
|
|
if currentScreenRow >= r.rows {
|
|
currentScreenRow = r.rows - 1
|
|
}
|
|
moveToBottom := r.rows - 1 - currentScreenRow
|
|
if moveToBottom > 0 {
|
|
w.WriteString("\x1b[" + itoa(moveToBottom) + "B")
|
|
}
|
|
scroll := moveTarget - prevViewportBottom
|
|
for s := 0; s < scroll; s++ {
|
|
w.WriteString("\r\n")
|
|
}
|
|
prevViewportTop += scroll
|
|
viewportTop += scroll
|
|
hardwareRow = moveTarget
|
|
}
|
|
|
|
currentScreenRow := hardwareRow - prevViewportTop
|
|
targetScreenRow := moveTarget - viewportTop
|
|
lineDiff := targetScreenRow - currentScreenRow
|
|
if lineDiff > 0 {
|
|
w.WriteString("\x1b[" + itoa(lineDiff) + "B")
|
|
} else if lineDiff < 0 {
|
|
w.WriteString("\x1b[" + itoa(-lineDiff) + "A")
|
|
}
|
|
if appendStart {
|
|
w.WriteString("\r\n")
|
|
} else {
|
|
w.WriteString("\r")
|
|
}
|
|
|
|
renderEnd := lastChanged
|
|
if renderEnd >= len(lines) {
|
|
renderEnd = len(lines) - 1
|
|
}
|
|
for idx := firstChanged; idx <= renderEnd; idx++ {
|
|
if idx > firstChanged {
|
|
w.WriteString("\r\n")
|
|
}
|
|
w.WriteString("\x1b[0m")
|
|
w.WriteString(SeqClearLine)
|
|
w.WriteString(lines[idx])
|
|
}
|
|
finalRow := renderEnd
|
|
if len(r.logLines) > len(lines) {
|
|
extra := len(r.logLines) - len(lines)
|
|
if extra > r.rows {
|
|
writeFull(true)
|
|
} else {
|
|
for e := 0; e < extra; e++ {
|
|
w.WriteString("\x1b[1B")
|
|
w.WriteString("\r")
|
|
w.WriteString("\x1b[0m")
|
|
w.WriteString(SeqClearLine)
|
|
finalRow++
|
|
}
|
|
if extra > 0 {
|
|
w.WriteString("\x1b[" + itoa(extra) + "A")
|
|
finalRow -= extra
|
|
}
|
|
}
|
|
}
|
|
r.logHardwareRow = finalRow
|
|
r.logViewportTop = viewportTop
|
|
if minTop := r.logHardwareRow - r.rows + 1; minTop > r.logViewportTop {
|
|
r.logViewportTop = minTop
|
|
}
|
|
if r.logViewportTop < 0 {
|
|
r.logViewportTop = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
positionCursor()
|
|
w.WriteString(SeqSynchronizedOff)
|
|
_, _ = io.WriteString(r.out, w.String())
|
|
|
|
r.logChat = append(r.logChat[:0], chatFrame...)
|
|
r.logBottom = append(r.logBottom[:0], bottomFrame...)
|
|
r.logLines = append(r.logLines[:0], lines...)
|
|
r.cursorRow = cursorBottomRow
|
|
r.cursorCol = cursorCol
|
|
}
|
|
|
|
// sameLines reports whether two []string have the exact same
|
|
// length and per-row contents. Used by DrawLog's idle no-op fast
|
|
// path; cheap enough at our frame rates and far simpler than
|
|
// hashing every byte.
|
|
func sameLines(a, b []string) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for i := range a {
|
|
if a[i] != b[i] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeBlock(w *strings.Builder, lines []string) {
|
|
for i, line := range lines {
|
|
w.WriteString("\x1b[0m")
|
|
w.WriteString(SeqClearLine)
|
|
w.WriteString(line)
|
|
if i < len(lines)-1 {
|
|
w.WriteString("\r\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
func tailTruncated(lines []string, maxRows, cols int) []string {
|
|
if maxRows <= 0 {
|
|
return nil
|
|
}
|
|
if len(lines) > maxRows {
|
|
lines = lines[len(lines)-maxRows:]
|
|
}
|
|
out := make([]string, len(lines))
|
|
for i, line := range lines {
|
|
out[i] = truncateToWidth(line, cols)
|
|
}
|
|
return out
|
|
}
|