jesseduffield.lazygit/pkg/gocui/escape.go
Stefan Haller 2fce9c91b6 Materialize cursor-forward escapes as space runs
ConPTY compresses runs of default-colored spaces into ECH + CUF
(\x1b[NX\x1b[NC) instead of emitting them literally. ECH is still a
no-op for us — our buffer is built sequentially and has nothing to
erase — but CUF has to materialize as N visible space cells so the
gap actually appears, otherwise content the child wrote with leading
indentation slides left against the preceding cell.

The view's cursorForward branch reuses the same machinery as tab
expansion: substitute the trigger byte for a space and let the
repeatCount path emit the cells under the parser-tracked SGR. The
existing notifyCellsWritten plumbing then advances screenCol over
the gap, keeping subsequent CUP targets aligned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-03 18:47:15 +02:00

600 lines
18 KiB
Go

// Copyright 2014 The gocui Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package gocui
import (
"strconv"
"strings"
"github.com/go-errors/errors"
)
type escapeInterpreter struct {
state escapeState
curch string
csiParam []string
curFgColor, curBgColor Attribute
mode OutputMode
instruction instruction
hyperlink strings.Builder
// ConPTY emits cursor-positioning escapes (CUP) to skip over blank
// rows rather than emitting LFs for them. To convert those into row
// advances the view can act on, we track where in the pseudo-terminal
// screen the cursor currently is. 1-based to match the escape
// sequences.
//
// We also have to track the column, but only well enough to count
// soft-wraps when written content runs past the right edge: ConPTY's
// CUPs are addressed against its post-wrap screen, so a logical line
// long enough to wrap in ConPTY's screen counts for two rows from the
// next CUP's perspective. Column accuracy past wrap-counting isn't
// modelled — we don't track the col argument of CUPs, and most
// pager-style emitters use col 1 anyway.
screenRow, screenCol int
}
type (
escapeState int
fontEffect int
)
type instruction interface{ isInstruction() }
type eraseInLineFromCursor struct{}
func (self eraseInLineFromCursor) isInstruction() {}
// cursorDown asks the view to advance N rows. Emitted when CUP / CUD /
// CNL / VPA targets a row past the current one; backward moves are
// ignored because the view's buffer is line-based and can't undo.
type cursorDown struct{ n int }
func (self cursorDown) isInstruction() {}
// cursorForward asks the view to materialize N space cells. Emitted
// when CUF advances the cursor right — ConPTY uses CUF (often paired
// with ECH) to encode runs of default-colored spaces compactly, so we
// have to render the gap, not just bump a counter.
type cursorForward struct{ n int }
func (self cursorForward) isInstruction() {}
type noInstruction struct{}
func (self noInstruction) isInstruction() {}
const (
stateNone escapeState = iota
stateEscape
stateCharacterSetDesignation
stateCSI
stateParams
stateCSIDiscard
stateOSC
stateOSCWaitForParams
stateOSCParams
stateOSCHyperlink
stateOSCEndEscape
stateOSCSkipUnknown
bold fontEffect = 1
faint fontEffect = 2
italic fontEffect = 3
underline fontEffect = 4
blink fontEffect = 5
reverse fontEffect = 7
strike fontEffect = 9
setForegroundColor int = 38
defaultForegroundColor int = 39
setBackgroundColor int = 48
defaultBackgroundColor int = 49
)
var (
errNotCSI = errors.New("Not a CSI escape sequence")
errCSIParseError = errors.New("CSI escape sequence parsing error")
)
// characters in case of error will output the non-parsed characters as a string.
func (ei *escapeInterpreter) characters() []string {
switch ei.state {
case stateNone:
return []string{"\x1b"}
case stateEscape:
return []string{"\x1b", ei.curch}
case stateCSI:
return []string{"\x1b", "[", ei.curch}
case stateParams:
ret := []string{"\x1b", "["}
for _, s := range ei.csiParam {
ret = append(ret, s)
ret = append(ret, ";")
}
return append(ret, ei.curch)
default:
}
return nil
}
// newEscapeInterpreter returns an escapeInterpreter that will be able to parse
// terminal escape sequences.
func newEscapeInterpreter(mode OutputMode) *escapeInterpreter {
ei := &escapeInterpreter{
state: stateNone,
curFgColor: ColorDefault,
curBgColor: ColorDefault,
mode: mode,
instruction: noInstruction{},
screenRow: 1,
screenCol: 1,
}
return ei
}
// reset sets the escapeInterpreter in initial state. Note: this only resets
// escape-parsing state. Screen cursor state survives so that mid-stream
// malformed escapes don't desync the row tracking from the view.
func (ei *escapeInterpreter) reset() {
ei.state = stateNone
ei.curFgColor = ColorDefault
ei.curBgColor = ColorDefault
ei.csiParam = nil
}
// resetScreenCursor returns the screen-cursor tracking to the top of the
// pseudo-terminal screen. Called when the view is rewound before a fresh pty
// render, and on cursor-home (which ConPTY emits at the start of each screen)
// for views that aren't rewound in lockstep — see the CUP handling in parseOne.
func (ei *escapeInterpreter) resetScreenCursor() {
ei.screenRow = 1
ei.screenCol = 1
}
// notifyRowAdvance must be called by the view whenever it advances to the
// next row in response to an LF / CRLF outside of an escape sequence
// (i.e. the row transitions the parser doesn't see directly). Keeps the
// parser's notion of the current screen row in sync with the view.
func (ei *escapeInterpreter) notifyRowAdvance() {
ei.screenRow++
ei.screenCol = 1
}
// notifyColumnReset must be called when the view processes a bare CR
// (column reset without row advance). Keeps screenCol in sync so wrap
// counting starts over from col 1.
func (ei *escapeInterpreter) notifyColumnReset() {
ei.screenCol = 1
}
// notifyCellsWritten must be called after the view writes visible cells
// to its buffer. Advances the parser's idea of the cursor by `width`
// columns; if that crosses the right edge of a `screenColMax`-wide pty
// screen, the corresponding number of soft-wraps are added to screenRow
// so subsequent CUPs land on the right line.
func (ei *escapeInterpreter) notifyCellsWritten(width, screenColMax int) {
if screenColMax <= 0 {
return
}
// One column at a time: matches ConPTY's "pending wrap" semantics
// where the cursor stays at col max+1 after writing the rightmost
// cell and only wraps on the next cell. Loops over individual
// columns rather than doing the math in one shot so wide cells on a
// row boundary still wrap cleanly.
for range width {
if ei.screenCol > screenColMax {
ei.screenRow++
ei.screenCol = 1
}
ei.screenCol++
}
}
// emitCursorAdvance schedules a cursorDown instruction for the next time
// the view checks ei.instruction, advancing the parser's screen row by
// the same amount. n <= 0 is a no-op (backward / same-row CUPs are
// ignored — the view's buffer is line-based and can't undo).
func (ei *escapeInterpreter) emitCursorAdvance(n int) {
if n <= 0 {
return
}
ei.instruction = cursorDown{n: n}
ei.screenRow += n
ei.screenCol = 1
}
// firstParamOrDefault returns the first CSI parameter parsed as an int,
// or dflt if it's absent / empty / unparseable.
func (ei *escapeInterpreter) firstParamOrDefault(dflt int) int {
if len(ei.csiParam) == 0 || ei.csiParam[0] == "" {
return dflt
}
n, err := strconv.Atoi(ei.csiParam[0])
if err != nil {
return dflt
}
return n
}
func (ei *escapeInterpreter) instructionRead() {
ei.instruction = noInstruction{}
}
// parseOne parses a character (grapheme cluster). If isEscape is true, it means that the character
// is part of an escape sequence, and as such should not be printed verbatim. Otherwise, it's not an
// escape sequence.
func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
// Sanity checks: if a sequence has grown absurdly long, stop
// accumulating state and just swallow bytes until its final byte —
// much better than leaking the accumulated garbage into the view.
if len(ei.csiParam) > 20 || (len(ei.csiParam) > 0 && len(ei.csiParam[len(ei.csiParam)-1]) > 255) {
ei.state = stateCSIDiscard
ei.csiParam = nil
return true, nil
}
ei.curch = string(ch)
switch ei.state {
case stateNone:
if characterEquals(ch, 0x1b) {
ei.state = stateEscape
return true, nil
}
return false, nil
case stateEscape:
switch {
case characterEquals(ch, '['):
ei.state = stateCSI
return true, nil
case characterEquals(ch, ']'):
ei.state = stateOSC
return true, nil
case characterEquals(ch, '('),
characterEquals(ch, ')'),
characterEquals(ch, '*'),
characterEquals(ch, '+'):
ei.state = stateCharacterSetDesignation
return true, nil
case len(ch) == 1 && ch[0] >= 0x30 && ch[0] <= 0x7E:
// Single-byte ESC sequence (e.g. ESC c = RIS). We don't
// interpret these, but we must consume them so they don't
// leak into the view as literal text.
ei.state = stateNone
return true, nil
default:
return false, errNotCSI
}
case stateCharacterSetDesignation:
// Not supported, so just skip it
ei.state = stateNone
return true, nil
case stateCSI:
switch {
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
ei.csiParam = append(ei.csiParam, "")
case characterEquals(ch, 'm'):
ei.csiParam = append(ei.csiParam, "0")
case characterEquals(ch, 'K'),
characterEquals(ch, 'H'), characterEquals(ch, 'f'), characterEquals(ch, 'd'),
characterEquals(ch, 'B'), characterEquals(ch, 'E'),
characterEquals(ch, 'C'):
// fall through — let stateParams handle these with default
// params (CUP/VPA default to row 1, CUD/CNL/CUF default to
// advance by 1).
case characterEquals(ch, ';'):
// Empty first param ([;Xm ≡ [0;Xm). Seed a slot for the
// empty param; stateParams will append the next one when it
// re-reads this ';' via the fallthrough.
ei.csiParam = append(ei.csiParam, "")
case len(ch) == 1 && ch[0] >= 0x3C && ch[0] <= 0x3F:
// Private-mode prefix byte (<, =, >, ?). We don't interpret
// DEC private-mode sequences, but must consume them so they
// don't leak into the view as literal text. Seed an empty
// param so the subsequent digits land on a valid slot.
ei.csiParam = append(ei.csiParam, "")
ei.state = stateParams
return true, nil
case len(ch) == 1 && ch[0] >= 0x20 && ch[0] <= 0x2F:
// CSI intermediate byte. A sequence with intermediates is
// one we don't implement; consume the rest until the final
// byte.
ei.state = stateCSIDiscard
ei.csiParam = nil
return true, nil
case len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E:
// Valid CSI final byte we don't implement — swallow.
ei.state = stateNone
ei.csiParam = nil
return true, nil
default:
return false, errCSIParseError
}
ei.state = stateParams
fallthrough
case stateParams:
switch {
case len(ch) == 1 && ch[0] >= '0' && ch[0] <= '9':
ei.csiParam[len(ei.csiParam)-1] += string(ch)
return true, nil
case characterEquals(ch, ';'):
ei.csiParam = append(ei.csiParam, "")
return true, nil
case characterEquals(ch, 'm'):
// outputCSI applies params left-to-right and mutates as it
// goes, so on failure some leading params may already have
// taken effect (e.g. `[1;;m` would leave AttrBold set before
// hitting the empty param). Snapshot the colors beforehand
// and restore them on error so a malformed SGR is truly a
// no-op rather than a partial apply.
savedFg, savedBg := ei.curFgColor, ei.curBgColor
if err := ei.outputCSI(); err != nil {
ei.curFgColor, ei.curBgColor = savedFg, savedBg
}
ei.state = stateNone
ei.csiParam = nil
return true, nil
case characterEquals(ch, 'K'):
p := 0
if len(ei.csiParam) != 0 && ei.csiParam[0] != "" {
p, err = strconv.Atoi(ei.csiParam[0])
if err != nil {
return false, errCSIParseError
}
}
if p == 0 {
ei.instruction = eraseInLineFromCursor{}
} else {
// non-zero values of P not supported
ei.instruction = noInstruction{}
}
ei.state = stateNone
ei.csiParam = nil
return true, nil
case characterEquals(ch, 'H'), characterEquals(ch, 'f'),
characterEquals(ch, 'd'):
// CUP / HVP (absolute (row, col), col ignored) or VPA (absolute row).
targetRow := ei.firstParamOrDefault(1)
if targetRow <= 1 {
// Cursor home. ConPTY emits this (after [2J) at the start of
// every screen, so it marks where ConPTY's coordinate origin
// now sits. Re-anchor our row tracking to the current write
// position rather than treating it as a backward move: a view
// that isn't rewound in lockstep with ConPTY's screen (the
// command log) would otherwise carry stale drift, making every
// later absolute CUP compute a negative, dropped advance and
// collapsing the blank rows ConPTY positioned with.
ei.resetScreenCursor()
} else {
// Skip forward to the target row; ignore backward moves.
ei.emitCursorAdvance(targetRow - ei.screenRow)
}
ei.state = stateNone
ei.csiParam = nil
return true, nil
case characterEquals(ch, 'B'), characterEquals(ch, 'E'):
// CUD / CNL — relative row advance by N. CNL also resets
// the column, which we don't track, so the two are
// equivalent for our purposes.
ei.emitCursorAdvance(ei.firstParamOrDefault(1))
ei.state = stateNone
ei.csiParam = nil
return true, nil
case characterEquals(ch, 'C'):
// CUF — cursor forward N. Emit space cells so the gap
// renders. (screenCol is updated by the view via
// notifyCellsWritten as those spaces are emitted.)
ei.instruction = cursorForward{n: ei.firstParamOrDefault(1)}
ei.state = stateNone
ei.csiParam = nil
return true, nil
case len(ch) == 1 && ch[0] >= 0x20 && ch[0] <= 0x2F:
// CSI intermediate byte after params. The final byte will
// have a semantic we don't implement (e.g. `[0 q` =
// DECSCUSR); consume everything until it arrives.
ei.state = stateCSIDiscard
ei.csiParam = nil
return true, nil
case len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E:
// Valid CSI final byte we don't implement — swallow the
// whole sequence rather than printing it as text.
ei.state = stateNone
ei.csiParam = nil
return true, nil
default:
return false, errCSIParseError
}
case stateCSIDiscard:
// Consume the rest of a CSI sequence whose semantic we don't
// interpret (one with intermediate bytes, or one the sanity
// checks at the top of parseOne bailed out of). Any byte in the
// final-byte range ends it.
if len(ch) == 1 && ch[0] >= 0x40 && ch[0] <= 0x7E {
ei.state = stateNone
}
return true, nil
case stateOSC:
if characterEquals(ch, '8') {
ei.state = stateOSCWaitForParams
ei.hyperlink.Reset()
return true, nil
}
ei.state = stateOSCSkipUnknown
return true, nil
case stateOSCWaitForParams:
if !characterEquals(ch, ';') {
// Malformed OSC 8 (expected ';' after '8'). Rather than
// erroring — which would reset state mid-OSC and cause the
// rest of the sequence to leak as literal text — treat the
// whole OSC as one we don't understand and skip to its
// terminator.
ei.state = stateOSCSkipUnknown
return true, nil
}
ei.state = stateOSCParams
return true, nil
case stateOSCParams:
if characterEquals(ch, ';') {
ei.state = stateOSCHyperlink
}
return true, nil
case stateOSCHyperlink:
switch {
case characterEquals(ch, 0x07):
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.state = stateOSCEndEscape
default:
ei.hyperlink.Write(ch)
}
return true, nil
case stateOSCEndEscape:
ei.state = stateNone
return true, nil
case stateOSCSkipUnknown:
switch {
case characterEquals(ch, 0x07):
ei.state = stateNone
case characterEquals(ch, 0x1b):
ei.state = stateOSCEndEscape
}
return true, nil
}
return false, nil
}
func (ei *escapeInterpreter) outputCSI() error {
n := len(ei.csiParam)
for i := 0; i < n; {
p, err := strconv.Atoi(ei.csiParam[i])
if err != nil {
return errCSIParseError
}
skip := 1
switch {
case p == 0: // reset style and color
ei.curFgColor = ColorDefault
ei.curBgColor = ColorDefault
case p >= 1 && p <= 9: // set style
ei.curFgColor |= getFontEffect(p)
case p >= 21 && p <= 29: // reset style
ei.curFgColor &= ^getFontEffect(p - 20)
case p >= 30 && p <= 37: // set foreground color
ei.curFgColor &= AttrStyleBits
ei.curFgColor |= Get256Color(int32(p) - 30)
case p == setForegroundColor: // set foreground color (256-color or true color)
var color Attribute
var err error
color, skip, err = ei.csiColor(ei.csiParam[i:])
if err != nil {
return err
}
ei.curFgColor &= AttrStyleBits
ei.curFgColor |= color
case p == defaultForegroundColor: // reset foreground color
ei.curFgColor &= AttrStyleBits
ei.curFgColor |= ColorDefault
case p >= 40 && p <= 47: // set background color
ei.curBgColor &= AttrStyleBits
ei.curBgColor |= Get256Color(int32(p) - 40)
case p == setBackgroundColor: // set background color (256-color or true color)
var color Attribute
var err error
color, skip, err = ei.csiColor(ei.csiParam[i:])
if err != nil {
return err
}
ei.curBgColor &= AttrStyleBits
ei.curBgColor |= color
case p == defaultBackgroundColor: // reset background color
ei.curBgColor &= AttrStyleBits
ei.curBgColor |= ColorDefault
case p >= 90 && p <= 97: // set bright foreground color
ei.curFgColor &= AttrStyleBits
ei.curFgColor |= Get256Color(int32(p) - 90 + 8)
case p >= 100 && p <= 107: // set bright background color
ei.curBgColor &= AttrStyleBits
ei.curBgColor |= Get256Color(int32(p) - 100 + 8)
default:
}
i += skip
}
return nil
}
func (ei *escapeInterpreter) csiColor(param []string) (color Attribute, skip int, err error) {
if len(param) < 2 {
return 0, 0, errCSIParseError
}
switch param[1] {
case "2":
// 24-bit color
if ei.mode < OutputTrue {
return 0, 0, errCSIParseError
}
if len(param) < 5 {
return 0, 0, errCSIParseError
}
var red, green, blue int
red, err = strconv.Atoi(param[2])
if err != nil {
return 0, 0, errCSIParseError
}
green, err = strconv.Atoi(param[3])
if err != nil {
return 0, 0, errCSIParseError
}
blue, err = strconv.Atoi(param[4])
if err != nil {
return 0, 0, errCSIParseError
}
return NewRGBColor(int32(red), int32(green), int32(blue)), 5, nil
case "5":
// 8-bit color
if ei.mode < Output256 {
return 0, 0, errCSIParseError
}
if len(param) < 3 {
return 0, 0, errCSIParseError
}
var hex int
hex, err = strconv.Atoi(param[2])
if err != nil {
return 0, 0, errCSIParseError
}
return Get256Color(int32(hex)), 3, nil
default:
return 0, 0, errCSIParseError
}
}
func getFontEffect(f int) Attribute {
switch fontEffect(f) {
case bold:
return AttrBold
case faint:
return AttrDim
case italic:
return AttrItalic
case underline:
return AttrUnderline
case blink:
return AttrBlink
case reverse:
return AttrReverse
case strike:
return AttrStrikeThrough
}
return AttrNone
}