mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 07:36:27 -04:00
Convert forward cursor-positioning escapes into row advances
ConPTY presents its child's output as a screen buffer and uses CUP / CUD / CNL / VPA to skip over blank rows rather than emitting LFs. The previous behaviour swallowed all of those and the visible content collapsed together. Now the escape parser tracks the screen-relative cursor row, and any CSI that moves the cursor past the current row emits a cursorDown instruction that the view turns into the matching number of empty lines. Column tracking is deliberately omitted: doing it correctly would mean duplicating the view's grapheme-cluster width math in the parser, and ConPTY in practice positions to column 1 after a CR-equivalent, which the existing wx-reset path already handles. ConPTY-internal scrolling needs no special handling either: it only emits cursor-positioning escapes within the first, un-scrolled screenful — once its screen scrolls it switches to plain linefeeds, which the view advances on directly regardless of the tracked cursor. Backward cursor moves are silently dropped — the view's buffer is append-style and can't undo earlier writes. The exception is cursor-home (CUP to row 1): ConPTY emits it at the start of every screen, so rather than drop it we re-anchor the row tracking to the current write position. Without that, a view not rewound in lockstep with ConPTY's screen (the command log, which streams pty output without a rewind) accumulates drift, and every later absolute CUP becomes a dropped backward move that collapses the rows ConPTY positioned with. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
79bc8e0bc6
commit
180fe0cd26
|
|
@ -19,6 +19,21 @@ type escapeInterpreter struct {
|
|||
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 (
|
||||
|
|
@ -32,6 +47,13 @@ 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() {}
|
||||
|
||||
type noInstruction struct{}
|
||||
|
||||
func (self noInstruction) isInstruction() {}
|
||||
|
|
@ -99,11 +121,15 @@ func newEscapeInterpreter(mode OutputMode) *escapeInterpreter {
|
|||
curBgColor: ColorDefault,
|
||||
mode: mode,
|
||||
instruction: noInstruction{},
|
||||
screenRow: 1,
|
||||
screenCol: 1,
|
||||
}
|
||||
return ei
|
||||
}
|
||||
|
||||
// reset sets the escapeInterpreter in initial state.
|
||||
// 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
|
||||
|
|
@ -111,6 +137,80 @@ func (ei *escapeInterpreter) reset() {
|
|||
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{}
|
||||
}
|
||||
|
|
@ -170,8 +270,12 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
|||
ei.csiParam = append(ei.csiParam, "")
|
||||
case characterEquals(ch, 'm'):
|
||||
ei.csiParam = append(ei.csiParam, "0")
|
||||
case characterEquals(ch, 'K'):
|
||||
// fall through
|
||||
case characterEquals(ch, 'K'),
|
||||
characterEquals(ch, 'H'), characterEquals(ch, 'f'), characterEquals(ch, 'd'),
|
||||
characterEquals(ch, 'B'), characterEquals(ch, 'E'):
|
||||
// fall through — let stateParams handle these with default
|
||||
// params (CUP/VPA default to row 1, CUD/CNL 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
|
||||
|
|
@ -240,6 +344,35 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -153,17 +153,16 @@ func TestParseOneColours(t *testing.T) {
|
|||
|
||||
func TestParseOneIgnoresUnknownSequences(t *testing.T) {
|
||||
// Escape sequences the interpreter doesn't implement -- whether well-formed-but-unsupported
|
||||
// (cursor movement, private modes, DECSCUSR, …) or outright malformed -- must be silently
|
||||
// (private modes, DECSCUSR, …) or outright malformed -- must be silently
|
||||
// consumed rather than leaked into the view as literal text.
|
||||
scenarios := []string{
|
||||
"\x1b[?9001h", // DEC private-mode set (?-prefix)
|
||||
"\x1b[?25l", // hide cursor
|
||||
"\x1b[?25h", // show cursor
|
||||
"\x1b[2;J", // erase display (unusual 2;J variant)
|
||||
"\x1b[H", // cursor home — final byte immediately after [
|
||||
"\x1b[5;1;H", // cursor position with multiple params
|
||||
"\x1b[H", // cursor home — re-anchors to row 1 (no-op when already there)
|
||||
"\x1bc", // RIS — single-char ESC sequence
|
||||
"\x1b[;5H", // empty first param (';' immediately after '[')
|
||||
"\x1b[;5H", // empty first param — defaults to row 1, no-op
|
||||
"\x1b[ q", // intermediate byte with no params (DECSCUSR family)
|
||||
"\x1b[0 q", // intermediate byte after a param
|
||||
"\x1b[1;;m", // malformed SGR: empty middle param
|
||||
|
|
@ -184,6 +183,66 @@ func TestParseOneIgnoresUnknownSequences(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestParseOneCursorPositioning(t *testing.T) {
|
||||
// Cursor-positioning escapes that advance the row forward emit a
|
||||
// cursorDown instruction; backward / same-row moves are ignored
|
||||
// because the view's buffer is line-based.
|
||||
scenarios := []struct {
|
||||
input string
|
||||
startRow int // parser's screenRow before parsing
|
||||
wantAdvance int // 0 means "no instruction emitted"
|
||||
}{
|
||||
{"\x1b[5;1H", 1, 4}, // CUP — absolute row 5 from row 1
|
||||
{"\x1b[5H", 1, 4}, // CUP with only the row param
|
||||
{"\x1b[5;1H", 5, 0}, // CUP to the same row we're on — no-op
|
||||
{"\x1b[2;1H", 5, 0}, // CUP backward — ignored
|
||||
{"\x1b[5;1f", 1, 4}, // HVP alias for CUP
|
||||
{"\x1b[5d", 1, 4}, // VPA — absolute row
|
||||
{"\x1b[2d", 5, 0}, // VPA backward — ignored
|
||||
{"\x1b[3B", 1, 3}, // CUD — relative
|
||||
{"\x1b[B", 1, 1}, // CUD with default param of 1
|
||||
{"\x1b[2E", 1, 2}, // CNL — relative
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
ei := newEscapeInterpreter(OutputNormal)
|
||||
ei.screenRow = s.startRow
|
||||
parseEscRunes(t, ei, s.input)
|
||||
if s.wantAdvance == 0 {
|
||||
_, noop := ei.instruction.(noInstruction)
|
||||
assert.True(t, noop, "input %q at row %d should be a no-op", s.input, s.startRow)
|
||||
} else {
|
||||
cd, ok := ei.instruction.(cursorDown)
|
||||
if assert.True(t, ok, "input %q at row %d should emit cursorDown", s.input, s.startRow) {
|
||||
assert.Equal(t, s.wantAdvance, cd.n, "input %q at row %d", s.input, s.startRow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOneCursorHomeReanchors(t *testing.T) {
|
||||
// ConPTY emits cursor-home ([H) after [2J at the start of every screen.
|
||||
// In a view that isn't rewound in lockstep with ConPTY (the command log)
|
||||
// screenRow has drifted, so home must re-anchor it to the current write
|
||||
// position rather than be dropped as a backward move — otherwise the
|
||||
// absolute CUPs that follow compute negative, dropped advances and the
|
||||
// rows ConPTY positioned with collapse together.
|
||||
ei := newEscapeInterpreter(OutputNormal)
|
||||
ei.screenRow = 12 // accumulated drift from earlier command-log output
|
||||
|
||||
parseEscRunes(t, ei, "\x1b[H")
|
||||
assert.Equal(t, 1, ei.screenRow, "home should re-anchor screenRow")
|
||||
_, noop := ei.instruction.(noInstruction)
|
||||
assert.True(t, noop, "home should not emit an instruction")
|
||||
|
||||
// A subsequent CUP now advances relative to the re-anchored origin.
|
||||
parseEscRunes(t, ei, "\x1b[3;1H")
|
||||
cd, ok := ei.instruction.(cursorDown)
|
||||
if assert.True(t, ok, "CUP after home should emit cursorDown") {
|
||||
assert.Equal(t, 2, cd.n)
|
||||
}
|
||||
}
|
||||
|
||||
func parseEscRunes(t *testing.T, ei *escapeInterpreter, runes string) {
|
||||
t.Helper()
|
||||
for _, b := range []byte(runes) {
|
||||
|
|
|
|||
|
|
@ -834,6 +834,7 @@ func (v *View) write(p []byte) {
|
|||
|
||||
if v.pendingNewline {
|
||||
advanceToNextLine()
|
||||
v.ei.notifyRowAdvance()
|
||||
v.pendingNewline = false
|
||||
}
|
||||
|
||||
|
|
@ -855,11 +856,20 @@ func (v *View) write(p []byte) {
|
|||
case characterEquals(chr, '\n') || isCRLF(chr):
|
||||
finishLine()
|
||||
advanceToNextLine()
|
||||
v.ei.notifyRowAdvance()
|
||||
case characterEquals(chr, '\r'):
|
||||
finishLine()
|
||||
v.wx = 0
|
||||
v.ei.notifyColumnReset()
|
||||
default:
|
||||
truncateLine, cells := v.parseInput(chr, width, v.wx, v.wy)
|
||||
if cd, ok := v.ei.instruction.(cursorDown); ok {
|
||||
v.ei.instructionRead()
|
||||
for range cd.n {
|
||||
v.autoRenderHyperlinksInCurrentLine()
|
||||
advanceToNextLine()
|
||||
}
|
||||
}
|
||||
if cells == nil {
|
||||
continue
|
||||
}
|
||||
|
|
@ -867,6 +877,17 @@ func (v *View) write(p []byte) {
|
|||
if truncateLine {
|
||||
v.lines[v.wy].cells = v.lines[v.wy].cells[:v.wx]
|
||||
}
|
||||
// Soft-wrap tracking. truncateLine is true exactly when the
|
||||
// cells are from \x1b[K filling to end of line — ConPTY
|
||||
// doesn't advance the cursor for that, so we shouldn't count
|
||||
// it toward wraps either.
|
||||
if !truncateLine {
|
||||
totalWidth := 0
|
||||
for _, c := range cells {
|
||||
totalWidth += c.width
|
||||
}
|
||||
v.ei.notifyCellsWritten(totalWidth, v.InnerWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1116,6 +1137,7 @@ func (v *View) FlushStaleCells() {
|
|||
|
||||
func (v *View) rewind() {
|
||||
v.ei.reset()
|
||||
v.ei.resetScreenCursor()
|
||||
|
||||
v.SetReadPos(0, 0)
|
||||
v.SetWritePos(0, 0)
|
||||
|
|
|
|||
|
|
@ -253,10 +253,7 @@ func TestWriteCursorPositionEscape(t *testing.T) {
|
|||
got = append(got, cellsToStrings(l.cells))
|
||||
}
|
||||
|
||||
/* EXPECTED:
|
||||
assert.Equal(t, [][]string{{"a"}, {}, {"b"}}, got)
|
||||
ACTUAL: */
|
||||
assert.Equal(t, [][]string{{"a"}, {"b"}}, got)
|
||||
}
|
||||
|
||||
func stringToCells(s string) []cell {
|
||||
|
|
|
|||
Loading…
Reference in a new issue