diff --git a/pkg/gocui/escape.go b/pkg/gocui/escape.go index c252fa20b..ad862a596 100644 --- a/pkg/gocui/escape.go +++ b/pkg/gocui/escape.go @@ -54,6 +54,14 @@ 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() {} @@ -272,10 +280,11 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { 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, 'B'), characterEquals(ch, 'E'), + characterEquals(ch, 'C'): // fall through — let stateParams handle these with default - // params (CUP/VPA default to row 1, CUD/CNL default to advance - // by 1). + // 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 @@ -376,6 +385,14 @@ func (ei *escapeInterpreter) parseOne(ch []byte) (isEscape bool, err error) { 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` = diff --git a/pkg/gocui/escape_test.go b/pkg/gocui/escape_test.go index a7e8ce02f..39ccbe908 100644 --- a/pkg/gocui/escape_test.go +++ b/pkg/gocui/escape_test.go @@ -243,6 +243,29 @@ func TestParseOneCursorHomeReanchors(t *testing.T) { } } +func TestParseOneCursorForward(t *testing.T) { + // CUF (\x1b[NC) emits a cursorForward instruction so the view can + // materialize the N-cell gap as spaces. ConPTY uses this (often + // paired with ECH) to encode runs of default-colored spaces. + scenarios := []struct { + input string + wantN int + }{ + {"\x1b[5C", 5}, + {"\x1b[1C", 1}, + {"\x1b[C", 1}, // no param defaults to 1 + } + + for _, s := range scenarios { + ei := newEscapeInterpreter(OutputNormal) + parseEscRunes(t, ei, s.input) + cf, ok := ei.instruction.(cursorForward) + if assert.True(t, ok, "input %q should emit cursorForward", s.input) { + assert.Equal(t, s.wantN, cf.n, "input %q", s.input) + } + } +} + 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 1e67f8535..d93f84954 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -1003,6 +1003,14 @@ func (v *View) parseInput(ch []byte, width int, x int, _ int) (bool, []cell) { bg: v.ei.curBgColor, } return truncateLine, []cell{} + } else if cf, ok := v.ei.instruction.(cursorForward); ok { + // emit `n` space cells under the parser-tracked SGR — used + // to materialize ConPTY's compressed runs of spaces (which + // it emits as ECH+CUF instead of literal whitespace). + v.ei.instructionRead() + repeatCount = cf.n + ch = []byte{' '} + width = 1 } else if isEscape { // do not output anything return truncateLine, nil diff --git a/pkg/gocui/view_test.go b/pkg/gocui/view_test.go index b23f914cd..f7e229f1c 100644 --- a/pkg/gocui/view_test.go +++ b/pkg/gocui/view_test.go @@ -297,10 +297,7 @@ func TestWriteCursorForwardEscape(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 TestWriteCursorPositionEscapeWithSoftWraps(t *testing.T) {