From 180fe0cd267ff52a9afe8b004fa6dbdaab352634 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Tue, 30 Jun 2026 09:45:26 +0200 Subject: [PATCH] Convert forward cursor-positioning escapes into row advances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pkg/gocui/escape.go | 139 ++++++++++++++++++++++++++++++++++++++- pkg/gocui/escape_test.go | 67 +++++++++++++++++-- pkg/gocui/view.go | 22 +++++++ pkg/gocui/view_test.go | 3 - 4 files changed, 221 insertions(+), 10 deletions(-) diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index 726fd4de7..c252fa20b 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -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 diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index cb8eb1a4b..a7e8ce02f 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -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) { diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 77492b9b1..1e67f8535 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -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) diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index 58c83e126..e8d68f1b4 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -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 {