Files
Stefan HallerandClaude Opus 4.8 f6eaed8cd4 Snapshot the view width for command-task rendering on the UI thread
A command task streams its output into a view from its own goroutine. To
track soft-wraps (so cursor-positioning escapes from a pager land on the
right line) the write path read the view's live InnerWidth, and the pty
setup read its InnerSize -- both off the UI thread, racing the UI thread
mutating the view's dimensions during layout.

Capture the width on the UI thread instead and hand it to the task: the
escape interpreter keeps a screenColMax it reads from, seeded in NewView
and refreshed per render via View.SetContentWidth (called from
newCmdTask/newPtyTask before the task's goroutine starts), and the pty
size is computed in the after-layout callback rather than in the task's
start func. The view's dimensions stay UI-thread-only; the task uses the
snapshot rather than reading them live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 12:35:54 +02:00

608 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
// The screen width that soft-wraps are counted against (see
// notifyCellsWritten). It's a snapshot of the view's InnerWidth taken on
// the UI thread (in NewView, and refreshed per render via
// View.SetContentWidth), rather than read live from the view's dimensions:
// a view's output is written from a task goroutine, and reading the live
// dimensions there would race the UI thread updating them during layout.
screenColMax 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 int) {
if ei.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 > ei.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
}