From fb295f71d6fe85e709bf8c4f5574b73c6073ff28 Mon Sep 17 00:00:00 2001 From: fy Date: Sat, 29 Nov 2025 22:09:57 +0800 Subject: [PATCH 1/7] feat: add SGR 2(Faint) support --- ioctl_other.go | 1 + ioctl_posix.go | 1 + state.go | 28 +++++++++++++++++++++++++--- vt_other.go | 1 + vt_posix.go | 1 + vt_test.go | 19 +++++++++++++++++++ 6 files changed, 48 insertions(+), 3 deletions(-) diff --git a/ioctl_other.go b/ioctl_other.go index 0aa1868..921464e 100644 --- a/ioctl_other.go +++ b/ioctl_other.go @@ -1,3 +1,4 @@ +//go:build plan9 || nacl || windows // +build plan9 nacl windows package vt10x diff --git a/ioctl_posix.go b/ioctl_posix.go index 7b81b3a..ae86d2f 100644 --- a/ioctl_posix.go +++ b/ioctl_posix.go @@ -1,3 +1,4 @@ +//go:build linux || darwin || dragonfly || solaris || openbsd || netbsd || freebsd // +build linux darwin dragonfly solaris openbsd netbsd freebsd package vt10x diff --git a/state.go b/state.go index fbc2f94..ea69a21 100644 --- a/state.go +++ b/state.go @@ -18,6 +18,7 @@ const ( attrItalic attrBlink attrWrap + attrFaint ) const ( @@ -156,6 +157,12 @@ func (t *State) Cell(x, y int) Glyph { return cell } +// RawLines returns the backed lines slice without taking any locks. The caller +// must ensure serialization if concurrent access is possible. +func (t *State) RawLines() []line { + return t.lines +} + // Cursor returns the current position of the cursor. func (t *State) Cursor() Cursor { return t.cur @@ -268,15 +275,28 @@ func (t *State) setChar(c rune, attr *Glyph, x, y int) { t.lines[y][x] = *attr t.lines[y][x].Char = c //if t.options.BrightBold && attr.Mode&attrBold != 0 && attr.FG < 8 { - if attr.Mode&attrBold != 0 && attr.FG < 8 { + if attr.Mode&attrBold != 0 && attr.Mode&attrFaint == 0 && attr.FG < 8 { t.lines[y][x].FG = attr.FG + 8 } + if attr.Mode&attrFaint != 0 && attr.Mode&attrBold == 0 { + t.lines[y][x].FG = dimColor(attr.FG) + } if attr.Mode&attrReverse != 0 { t.lines[y][x].FG = attr.BG t.lines[y][x].BG = attr.FG } } +func dimColor(c Color) Color { + if c >= DefaultFG { + return c + } + r := (c >> 16) & 0xff + g := (c >> 8) & 0xff + b := c & 0xff + return Color((r>>1)<<16 | (g>>1)<<8 | (b >> 1)) +} + func (t *State) defaultCursor() Cursor { c := Cursor{} c.Attr.FG = DefaultFG @@ -611,11 +631,13 @@ func (t *State) setAttr(attr []int) { a := attr[i] switch a { case 0: - t.cur.Attr.Mode &^= attrReverse | attrUnderline | attrBold | attrItalic | attrBlink + t.cur.Attr.Mode &^= attrReverse | attrUnderline | attrBold | attrItalic | attrBlink | attrFaint t.cur.Attr.FG = DefaultFG t.cur.Attr.BG = DefaultBG case 1: t.cur.Attr.Mode |= attrBold + case 2: + t.cur.Attr.Mode |= attrFaint case 3: t.cur.Attr.Mode |= attrItalic case 4: @@ -625,7 +647,7 @@ func (t *State) setAttr(attr []int) { case 7: t.cur.Attr.Mode |= attrReverse case 21, 22: - t.cur.Attr.Mode &^= attrBold + t.cur.Attr.Mode &^= attrBold | attrFaint case 23: t.cur.Attr.Mode &^= attrItalic case 24: diff --git a/vt_other.go b/vt_other.go index c9d364e..a6465a9 100644 --- a/vt_other.go +++ b/vt_other.go @@ -1,3 +1,4 @@ +//go:build plan9 || nacl || windows // +build plan9 nacl windows package vt10x diff --git a/vt_posix.go b/vt_posix.go index 80644f4..31fa07b 100644 --- a/vt_posix.go +++ b/vt_posix.go @@ -1,3 +1,4 @@ +//go:build linux || darwin || dragonfly || solaris || openbsd || netbsd || freebsd // +build linux darwin dragonfly solaris openbsd netbsd freebsd package vt10x diff --git a/vt_test.go b/vt_test.go index ac97301..1921ca9 100644 --- a/vt_test.go +++ b/vt_test.go @@ -75,3 +75,22 @@ func TestIndexColor(t *testing.T) { t.Fatal(attr.FG) } } + +func TestSGRFaint(t *testing.T) { + term := New() + if _, err := term.Write([]byte("\033[32;2mF")); err != nil && err != io.EOF { + t.Fatal(err) + } + attr := term.Cell(0, 0) + if attr.Mode&attrFaint == 0 { + t.Fatal("expected faint attribute on cell") + } + base := byte2color(2) + r := (base >> 16) & 0xff + g := (base >> 8) & 0xff + b := base & 0xff + expected := Color((r>>1)<<16 | (g>>1)<<8 | (b >> 1)) + if attr.FG != expected { + t.Fatalf("expected faint color %06x, got %06x", expected, attr.FG) + } +} From c2f2317a31880bd9297ea0208d4b4422b28a4454 Mon Sep 17 00:00:00 2001 From: fy Date: Sat, 29 Nov 2025 23:00:11 +0800 Subject: [PATCH 2/7] feat: expose attrs to package --- parse.go | 8 ++++---- state.go | 48 ++++++++++++++++++++++++------------------------ vt_test.go | 2 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/parse.go b/parse.go index 0b84145..6028de2 100644 --- a/parse.go +++ b/parse.go @@ -7,14 +7,14 @@ func isControlCode(c rune) bool { func (t *State) parse(c rune) { t.logf("%q", string(c)) if isControlCode(c) { - if t.handleControlCodes(c) || t.cur.Attr.Mode&attrGfx == 0 { + if t.handleControlCodes(c) || t.cur.Attr.Mode&AttrGfx == 0 { return } } // TODO: update selection; see st.c:2450 if t.mode&ModeWrap != 0 && t.cur.State&cursorWrapNext != 0 { - t.lines[t.cur.Y][t.cur.X].Mode |= attrWrap + t.lines[t.cur.Y][t.cur.X].Mode |= AttrWrap t.newline(true) } @@ -132,9 +132,9 @@ func (t *State) parseEscAltCharset(c rune) { t.logf("%q", string(c)) switch c { case '0': // line drawing set - t.cur.Attr.Mode |= attrGfx + t.cur.Attr.Mode |= AttrGfx case 'B': // USASCII - t.cur.Attr.Mode &^= attrGfx + t.cur.Attr.Mode &^= AttrGfx case 'A', // UK (ignored) '<', // multinational (ignored) '5', // Finnish (ignored) diff --git a/state.go b/state.go index ea69a21..4c9b798 100644 --- a/state.go +++ b/state.go @@ -11,14 +11,14 @@ const ( ) const ( - attrReverse = 1 << iota - attrUnderline - attrBold - attrGfx - attrItalic - attrBlink - attrWrap - attrFaint + AttrReverse = 1 << iota + AttrUnderline + AttrBold + AttrGfx + AttrItalic + AttrBlink + AttrWrap + AttrFaint ) const ( @@ -265,7 +265,7 @@ var gfxCharTable = [62]rune{ } func (t *State) setChar(c rune, attr *Glyph, x, y int) { - if attr.Mode&attrGfx != 0 { + if attr.Mode&AttrGfx != 0 { if c >= 0x41 && c <= 0x7e && gfxCharTable[c-0x41] != 0 { c = gfxCharTable[c-0x41] } @@ -275,13 +275,13 @@ func (t *State) setChar(c rune, attr *Glyph, x, y int) { t.lines[y][x] = *attr t.lines[y][x].Char = c //if t.options.BrightBold && attr.Mode&attrBold != 0 && attr.FG < 8 { - if attr.Mode&attrBold != 0 && attr.Mode&attrFaint == 0 && attr.FG < 8 { + if attr.Mode&AttrBold != 0 && attr.Mode&AttrFaint == 0 && attr.FG < 8 { t.lines[y][x].FG = attr.FG + 8 } - if attr.Mode&attrFaint != 0 && attr.Mode&attrBold == 0 { + if attr.Mode&AttrFaint != 0 && attr.Mode&AttrBold == 0 { t.lines[y][x].FG = dimColor(attr.FG) } - if attr.Mode&attrReverse != 0 { + if attr.Mode&AttrReverse != 0 { t.lines[y][x].FG = attr.BG t.lines[y][x].BG = attr.FG } @@ -631,31 +631,31 @@ func (t *State) setAttr(attr []int) { a := attr[i] switch a { case 0: - t.cur.Attr.Mode &^= attrReverse | attrUnderline | attrBold | attrItalic | attrBlink | attrFaint + t.cur.Attr.Mode &^= AttrReverse | AttrUnderline | AttrBold | AttrItalic | AttrBlink | AttrFaint t.cur.Attr.FG = DefaultFG t.cur.Attr.BG = DefaultBG case 1: - t.cur.Attr.Mode |= attrBold + t.cur.Attr.Mode |= AttrBold case 2: - t.cur.Attr.Mode |= attrFaint + t.cur.Attr.Mode |= AttrFaint case 3: - t.cur.Attr.Mode |= attrItalic + t.cur.Attr.Mode |= AttrItalic case 4: - t.cur.Attr.Mode |= attrUnderline + t.cur.Attr.Mode |= AttrUnderline case 5, 6: // slow, rapid blink - t.cur.Attr.Mode |= attrBlink + t.cur.Attr.Mode |= AttrBlink case 7: - t.cur.Attr.Mode |= attrReverse + t.cur.Attr.Mode |= AttrReverse case 21, 22: - t.cur.Attr.Mode &^= attrBold | attrFaint + t.cur.Attr.Mode &^= AttrBold | AttrFaint case 23: - t.cur.Attr.Mode &^= attrItalic + t.cur.Attr.Mode &^= AttrItalic case 24: - t.cur.Attr.Mode &^= attrUnderline + t.cur.Attr.Mode &^= AttrUnderline case 25, 26: - t.cur.Attr.Mode &^= attrBlink + t.cur.Attr.Mode &^= AttrBlink case 27: - t.cur.Attr.Mode &^= attrReverse + t.cur.Attr.Mode &^= AttrReverse case 38: if i+2 < len(attr) && attr[i+1] == 5 { i += 2 diff --git a/vt_test.go b/vt_test.go index 1921ca9..9ff8a3c 100644 --- a/vt_test.go +++ b/vt_test.go @@ -82,7 +82,7 @@ func TestSGRFaint(t *testing.T) { t.Fatal(err) } attr := term.Cell(0, 0) - if attr.Mode&attrFaint == 0 { + if attr.Mode&AttrFaint == 0 { t.Fatal("expected faint attribute on cell") } base := byte2color(2) From 4bc538e9f6c2d478453565750028c258a95ea6f3 Mon Sep 17 00:00:00 2001 From: fy Date: Thu, 25 Dec 2025 03:11:54 +0800 Subject: [PATCH 3/7] fix: add wide character (CJK) support Previously, each rune was treated as occupying 1 column, causing incorrect cursor positioning for wide characters like CJK text. Changes: - Add go-runewidth dependency to calculate display width - Add AttrWide and AttrWideDummy flags for wide char tracking - Move cursor by actual character width (2 for wide chars) - Handle wide char wrapping at line end - Clear orphaned cells when overwriting partial wide chars --- go.mod | 2 ++ parse.go | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- state.go | 2 ++ 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ce04aba..f4ad68d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/tuzig/vt10x go 1.14 + +require github.com/mattn/go-runewidth v0.0.19 diff --git a/parse.go b/parse.go index 6028de2..52ac336 100644 --- a/parse.go +++ b/parse.go @@ -1,5 +1,7 @@ package vt10x +import "github.com/mattn/go-runewidth" + func isControlCode(c rune) bool { return c < 0x20 || c == 0177 } @@ -23,9 +25,62 @@ func (t *State) parse(c rune) { t.logln("insert mode not implemented") } - t.setChar(c, &t.cur.Attr, t.cur.X, t.cur.Y) - if t.cur.X+1 < t.cols { - t.moveTo(t.cur.X+1, t.cur.Y) + width := runewidth.RuneWidth(c) + if width == 0 { + width = 1 // Treat zero-width as 1 for safety + } + + // For wide characters, check if we have room for both cells + if width == 2 && t.cur.X+1 >= t.cols { + // Not enough room for wide char, wrap to next line first + if t.mode&ModeWrap != 0 { + t.lines[t.cur.Y][t.cur.X].Mode |= AttrWrap + t.newline(true) + } else { + // Can't fit, just return + return + } + } + + // Clear any existing wide character that would be partially overwritten + // If we're writing over the dummy part of a wide char, clear the main part + if t.cur.X > 0 && t.lines[t.cur.Y][t.cur.X].Mode&AttrWideDummy != 0 { + t.lines[t.cur.Y][t.cur.X-1].Char = ' ' + t.lines[t.cur.Y][t.cur.X-1].Mode &^= AttrWide + t.dirty[t.cur.Y] = true + } + // If we're writing over a wide char, also clear its dummy part + if t.lines[t.cur.Y][t.cur.X].Mode&AttrWide != 0 && t.cur.X+1 < t.cols { + t.lines[t.cur.Y][t.cur.X+1].Char = ' ' + t.lines[t.cur.Y][t.cur.X+1].Mode &^= AttrWideDummy + t.dirty[t.cur.Y] = true + } + // For 2-width chars, also check if we'd overwrite a wide char's first half with our dummy + if width == 2 && t.cur.X+1 < t.cols { + if t.lines[t.cur.Y][t.cur.X+1].Mode&AttrWide != 0 && t.cur.X+2 < t.cols { + t.lines[t.cur.Y][t.cur.X+2].Char = ' ' + t.lines[t.cur.Y][t.cur.X+2].Mode &^= AttrWideDummy + t.dirty[t.cur.Y] = true + } + } + + // Set the main character + attr := t.cur.Attr + if width == 2 { + attr.Mode |= AttrWide + } + t.setChar(c, &attr, t.cur.X, t.cur.Y) + + // For wide characters, set placeholder in second cell + if width == 2 && t.cur.X+1 < t.cols { + dummyAttr := t.cur.Attr + dummyAttr.Mode |= AttrWideDummy + t.setChar(' ', &dummyAttr, t.cur.X+1, t.cur.Y) + } + + // Move cursor by character width + if t.cur.X+width < t.cols { + t.moveTo(t.cur.X+width, t.cur.Y) } else { t.cur.State |= cursorWrapNext } diff --git a/state.go b/state.go index 4c9b798..f7f5ef1 100644 --- a/state.go +++ b/state.go @@ -19,6 +19,8 @@ const ( AttrBlink AttrWrap AttrFaint + AttrWide // Wide character (occupies 2 cells) + AttrWideDummy // Placeholder for second cell of wide character ) const ( From 051b8e089f34aca0ded5bee8925afcfb148d2c5c Mon Sep 17 00:00:00 2001 From: fy Date: Wed, 14 Jan 2026 12:00:53 +0800 Subject: [PATCH 4/7] fix: text width calculate error --- go.mod | 2 +- parse.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index f4ad68d..9878d64 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,4 @@ module github.com/tuzig/vt10x go 1.14 -require github.com/mattn/go-runewidth v0.0.19 +require github.com/rivo/uniseg v0.4.7 diff --git a/parse.go b/parse.go index 52ac336..9b4bb53 100644 --- a/parse.go +++ b/parse.go @@ -1,6 +1,6 @@ package vt10x -import "github.com/mattn/go-runewidth" +import "github.com/rivo/uniseg" func isControlCode(c rune) bool { return c < 0x20 || c == 0177 @@ -25,7 +25,7 @@ func (t *State) parse(c rune) { t.logln("insert mode not implemented") } - width := runewidth.RuneWidth(c) + width := uniseg.StringWidth(string(c)) if width == 0 { width = 1 // Treat zero-width as 1 for safety } From 7144fb849786468d52b7271ad74a29f0fd1ce19b Mon Sep 17 00:00:00 2001 From: fy Date: Wed, 8 Apr 2026 00:06:40 +0800 Subject: [PATCH 5/7] feat: improve xterm-style alt screen compatibility --- alt_screen_test.go | 100 ++++++++++++++++++++++++++++ csi.go | 29 ++++++--- csi_test.go | 29 +++++++++ state.go | 158 ++++++++++++++++++++++++++++++++++++--------- 4 files changed, 279 insertions(+), 37 deletions(-) create mode 100644 alt_screen_test.go diff --git a/alt_screen_test.go b/alt_screen_test.go new file mode 100644 index 0000000..1ca981a --- /dev/null +++ b/alt_screen_test.go @@ -0,0 +1,100 @@ +package vt10x + +import ( + "io" + "strings" + "testing" +) + +func writeAll(t *testing.T, term Terminal, seq string) { + t.Helper() + if _, err := term.Write([]byte(seq)); err != nil && err != io.EOF { + t.Fatal(err) + } +} + +func rowString(term Terminal, row int) string { + cols, _ := term.Size() + return extractStr(term, 0, cols-1, row) +} + +func assertScreenRows(t *testing.T, term Terminal, expected []string) { + t.Helper() + for row, want := range expected { + if got := rowString(term, row); got != want { + t.Fatalf("row %d mismatch: got %q want %q", row+1, got, want) + } + } +} + +func TestAltScreenResetsScrollRegion(t *testing.T) { + term := New(WithSize(10, 5)) + writeAll(t, term, "\x1b[2;4r\x1b[?1049h\x1b[0m\x1b[2J\x1b[3J\x1b[H"+ + "1111111111\r\n2222222222\r\n3333333333\r\n4444444444\r\n5555555555") + + assertScreenRows(t, term, []string{ + "1111111111", + "2222222222", + "3333333333", + "4444444444", + "5555555555", + }) +} + +func TestAltScreenKeepsOriginButUsesFullViewportMargins(t *testing.T) { + term := New(WithSize(10, 5)) + writeAll(t, term, "\x1b[2;4r\x1b[?6h\x1b[?1049h\x1b[0m\x1b[2J\x1b[3J\x1b[H"+ + "1111111111\r\n2222222222\r\n3333333333\r\n4444444444\r\n5555555555") + + assertScreenRows(t, term, []string{ + "1111111111", + "2222222222", + "3333333333", + "4444444444", + "5555555555", + }) +} + +func TestAltScreenRoundTripRestoresNormalScreen(t *testing.T) { + term := New(WithSize(10, 5)) + writeAll(t, term, "\x1b[3;4HX\x1b[?1049hALT\x1b[?1049l") + + if got := rowString(term, 2); got != " X " { + t.Fatalf("normal screen content mismatch: got %q", got) + } + + cur := term.Cursor() + if cur.X != 4 || cur.Y != 2 { + t.Fatalf("cursor mismatch after 1049 round trip: got (%d,%d)", cur.X, cur.Y) + } + + writeAll(t, term, "\x1b[?1049h") + assertScreenRows(t, term, []string{ + " ", + " ", + " ", + " ", + " ", + }) +} + +func TestResetClearsEntireViewport(t *testing.T) { + term := New(WithSize(10, 5)) + rows := []string{ + "AAAAAAAAAA", + "BBBBBBBBBB", + "CCCCCCCCCC", + "DDDDDDDDDD", + "EEEEEEEEEE", + } + writeAll(t, term, strings.Join(rows, "\r\n")) + writeAll(t, term, "\x1bc") + + assertScreenRows(t, term, []string{ + " ", + " ", + " ", + " ", + " ", + }) +} diff --git a/csi.go b/csi.go index f5df174..3d5b362 100644 --- a/csi.go +++ b/csi.go @@ -9,16 +9,18 @@ import ( // CSI (Control Sequence Introducer) // ESC+[ type csiEscape struct { - buf []byte - args []int - mode byte - priv bool + buf []byte + args []int + mode byte + prefix byte + priv bool } func (c *csiEscape) reset() { c.buf = c.buf[:0] c.args = c.args[:0] c.mode = 0 + c.prefix = 0 c.priv = false } @@ -38,8 +40,9 @@ func (c *csiEscape) parse() { } s := string(c.buf) c.args = c.args[:0] - if s[0] == '?' { - c.priv = true + if s[0] == '?' || s[0] == '>' || s[0] == '<' || s[0] == '=' { + c.prefix = s[0] + c.priv = s[0] == '?' s = s[1:] } s = s[:len(s)-1] @@ -78,8 +81,12 @@ func (t *State) handleCSI() { case 'B', 'e': // CUD, VPR - cursor down t.moveTo(t.cur.X, t.cur.Y+c.maxarg(0, 1)) case 'c': // DA - device attributes - if c.arg(0, 0) == 0 { - // TODO: write vt102 id + if c.prefix == 0 && c.arg(0, 0) == 0 { + // Reply with an xterm-style primary DA for compatibility with modern TUIs. + // Strict VT102 would traditionally report ESC[?6c here. + _, _ = t.w.Write([]byte("\033[?1;2c")) + } else { + goto unknown } case 'C', 'a': // CUF, HPR - cursor forward t.moveTo(t.cur.X+c.maxarg(0, 1), t.cur.Y) @@ -178,8 +185,14 @@ func (t *State) handleCSI() { t.moveAbsTo(0, 0) } case 's': // DECSC - save cursor position (ANSI.SYS) + if c.priv || c.prefix != 0 { + goto unknown + } t.saveCursor() case 'u': // DECRC - restore cursor position (ANSI.SYS) + if c.priv || c.prefix != 0 { + goto unknown + } t.restoreCursor() } return diff --git a/csi_test.go b/csi_test.go index 86c2fb3..d3b9157 100644 --- a/csi_test.go +++ b/csi_test.go @@ -1,6 +1,7 @@ package vt10x import ( + "bytes" "testing" ) @@ -33,4 +34,32 @@ func TestCSIParse(t *testing.T) { if csi.mode != 'l' || csi.arg(0, 0) != 25 || csi.priv != true || len(csi.args) != 1 { t.Fatal("CSI parse mismatch") } + + csi.reset() + csi.buf = []byte(">7u") + csi.parse() + if csi.mode != 'u' || csi.prefix != '>' || csi.priv || csi.arg(0, 0) != 7 || len(csi.args) != 1 { + t.Fatal("CSI parse mismatch") + } +} + +func TestXtermStylePrimaryDAResponse(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply)) + if _, err := term.Write([]byte("\033[c")); err != nil { + t.Fatal(err) + } + if got := reply.String(); got != "\033[?1;2c" { + t.Fatalf("unexpected DA response: %q", got) + } +} + +func TestPrivateCSIUIgnored(t *testing.T) { + term := New(WithSize(10, 1)) + if _, err := term.Write([]byte("ab\033[scd\033[?uX")); err != nil { + t.Fatal(err) + } + if got := extractStr(term, 0, 4, 0); got != "abcdX" { + t.Fatalf("expected private CSI u to be ignored, got %q", got) + } } diff --git a/state.go b/state.go index f7f5ef1..9edd987 100644 --- a/state.go +++ b/state.go @@ -19,7 +19,7 @@ const ( AttrBlink AttrWrap AttrFaint - AttrWide // Wide character (occupies 2 cells) + AttrWide // Wide character (occupies 2 cells) AttrWideDummy // Placeholder for second cell of wide character ) @@ -96,12 +96,17 @@ type State struct { anydirty bool cur, curSaved Cursor top, bottom int // scroll limits + otherCur Cursor + otherCurSaved Cursor + otherTop int + otherBottom int mode ModeFlag state parseState str strEscape csi csiEscape numlock bool tabs []bool + otherTabs []bool title string colorOverride map[Color]Color } @@ -206,6 +211,78 @@ func (t *State) resetChanges() { t.changed = 0 } +func (t *State) resetTabs(tabs []bool) { + for i := range tabs { + tabs[i] = false + } + for i := tabspaces; i < len(tabs); i += tabspaces { + tabs[i] = true + } +} + +func (t *State) resizeTabs(tabs []bool, oldCols, newCols int) []bool { + resized := make([]bool, newCols) + if len(tabs) == 0 || oldCols <= 0 { + t.resetTabs(resized) + return resized + } + + copy(resized, tabs) + if newCols <= oldCols { + return resized + } + + lastStop := oldCols - 1 + if lastStop >= len(tabs) { + lastStop = len(tabs) - 1 + } + for lastStop > 0 && !tabs[lastStop] { + lastStop-- + } + for lastStop += tabspaces; lastStop < newCols; lastStop += tabspaces { + resized[lastStop] = true + } + return resized +} + +func (t *State) fillBuffer(lines []line, attr Glyph) { + for y := range lines { + for x := range lines[y] { + lines[y][x] = attr + lines[y][x].Char = ' ' + } + } +} + +func (t *State) resetInactiveScreen(fill Glyph, cursor Cursor) { + t.fillBuffer(t.altLines, fill) + t.otherCur = cursor + t.otherCurSaved = t.defaultCursor() + t.otherTop = 0 + t.otherBottom = t.rows - 1 + if len(t.otherTabs) != t.cols { + t.otherTabs = make([]bool, t.cols) + } + t.resetTabs(t.otherTabs) +} + +func (t *State) clampCursor(cur *Cursor, top, bottom int) { + cur.X = clamp(cur.X, 0, t.cols-1) + + minY := 0 + maxY := t.rows - 1 + if cur.State&cursorOrigin != 0 { + minY = top + maxY = bottom + } + cur.Y = clamp(cur.Y, minY, maxY) +} + +func (t *State) clampSavedCursor(cur *Cursor) { + cur.X = clamp(cur.X, 0, t.cols-1) + cur.Y = clamp(cur.Y, 0, t.rows-1) +} + func (t *State) saveCursor() { t.curSaved = t.cur } @@ -308,17 +385,21 @@ func (t *State) defaultCursor() Cursor { func (t *State) reset() { t.cur = t.defaultCursor() - t.saveCursor() - for i := range t.tabs { - t.tabs[i] = false - } - for i := tabspaces; i < len(t.tabs); i += tabspaces { - t.tabs[i] = true - } + t.curSaved = t.cur + t.resetTabs(t.tabs) t.top = 0 t.bottom = t.rows - 1 + t.otherCur = t.defaultCursor() + t.otherCurSaved = t.otherCur + t.otherTop = 0 + t.otherBottom = t.rows - 1 + if len(t.otherTabs) != t.cols { + t.otherTabs = make([]bool, t.cols) + } + t.resetTabs(t.otherTabs) t.mode = ModeWrap - t.clear(0, 0, t.rows-1, t.cols-1) + t.clear(0, 0, t.cols-1, t.rows-1) + t.fillBuffer(t.altLines, t.otherCur.Attr) t.moveTo(0, 0) } @@ -336,11 +417,12 @@ func (t *State) resize(cols, rows int) bool { copy(t.altLines, t.altLines[slide:slide+rows]) } - lines, altLines, tabs := t.lines, t.altLines, t.tabs + lines, altLines, tabs, otherTabs := t.lines, t.altLines, t.tabs, t.otherTabs t.lines = make([]line, rows) t.altLines = make([]line, rows) t.dirty = make([]bool, rows) - t.tabs = make([]bool, cols) + t.tabs = t.resizeTabs(tabs, t.cols, cols) + t.otherTabs = t.resizeTabs(otherTabs, t.cols, cols) minrows := min(rows, t.rows) mincols := min(cols, t.cols) @@ -354,21 +436,16 @@ func (t *State) resize(cols, rows int) bool { copy(t.lines[i], lines[i]) copy(t.altLines[i], altLines[i]) } - copy(t.tabs, tabs) - if cols > t.cols { - i := t.cols - 1 - for i > 0 && !tabs[i] { - i-- - } - for i += tabspaces; i < len(tabs); i += tabspaces { - tabs[i] = true - } - } t.cols = cols t.rows = rows t.setScroll(0, rows-1) - t.moveTo(t.cur.X, t.cur.Y) + t.otherTop = 0 + t.otherBottom = rows - 1 + t.clampCursor(&t.cur, t.top, t.bottom) + t.clampSavedCursor(&t.curSaved) + t.clampCursor(&t.otherCur, t.otherTop, t.otherBottom) + t.clampSavedCursor(&t.otherCurSaved) for i := 0; i < 2; i++ { if mincols < cols && minrows > 0 { t.clear(mincols, 0, cols-1, minrows-1) @@ -432,6 +509,11 @@ func (t *State) moveTo(x, y int) { func (t *State) swapScreen() { t.lines, t.altLines = t.altLines, t.lines + t.cur, t.otherCur = t.otherCur, t.cur + t.curSaved, t.otherCurSaved = t.otherCurSaved, t.curSaved + t.top, t.otherTop = t.otherTop, t.top + t.bottom, t.otherBottom = t.otherBottom, t.bottom + t.tabs, t.otherTabs = t.otherTabs, t.tabs t.mode ^= ModeAltScreen t.dirtyAll() } @@ -569,25 +651,43 @@ func (t *State) setMode(priv bool, set bool, args []int) { t.modMode(set, ModeMouseSgr) case 1034: t.modMode(set, Mode8bit) - case 1049, // = 1047 and 1048 - 47, 1047: + case 47, 1047: alt := t.mode&ModeAltScreen != 0 - if alt { - t.clear(0, 0, t.cols-1, t.rows-1) + if set { + if !alt { + t.resetInactiveScreen(t.cur.Attr, t.cur) + t.swapScreen() + } + break } - if !set || !alt { + if alt { t.swapScreen() + t.resetInactiveScreen(t.defaultCursor().Attr, t.defaultCursor()) } - if a != 1049 { + break + case 1049: + alt := t.mode&ModeAltScreen != 0 + if set { + t.saveCursor() + if !alt { + t.resetInactiveScreen(t.cur.Attr, t.cur) + t.swapScreen() + } break } - fallthrough + if alt { + t.swapScreen() + t.restoreCursor() + t.resetInactiveScreen(t.defaultCursor().Attr, t.defaultCursor()) + } + break case 1048: if set { t.saveCursor() } else { t.restoreCursor() } + break case 1001: // mouse highlight mode; can hang the terminal by design when // implemented From da1b8f9c435d0629d74f967afc37847dd6405d5b Mon Sep 17 00:00:00 2001 From: fy Date: Wed, 8 Apr 2026 01:16:31 +0800 Subject: [PATCH 6/7] feat: add xterm-style query compatibility --- README.md | 25 +++++++++++- csi.go | 70 ++++++++++++++++++++++++-------- csi_test.go | 67 ++++++++++++++++++++++++++++++ parse.go | 2 + state.go | 7 +++- str.go | 115 +++++++++++++++++++++++++++++++++++++--------------- str_test.go | 47 +++++++++++++++++++++ vt.go | 24 ++++++++--- vt_other.go | 2 +- vt_posix.go | 2 +- vt_test.go | 60 +++++++++++++++++++++++++++ 11 files changed, 364 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 420318f..5869ce5 100644 --- a/README.md +++ b/README.md @@ -6,4 +6,27 @@ Package vt10x is a vt10x terminal emulation backend, influenced largely by st, rxvt, xterm, and iTerm as reference. Use it for terminal muxing, a terminal emulation frontend, or wherever else you need -terminal emulation. +terminal emulation. It also answers common xterm-style startup probes +such as CPR, primary/secondary DA, and OSC 10/11/12 color queries. +Secondary DA stays conservative by default, and callers can opt into a +broader xterm-oriented compatibility profile with `WithXtermStyle()`. + +## Partial xterm compatibility + +vt10x is not a full xterm clone, but it intentionally supports a growing +set of xterm-style startup and query behaviors used by modern TUIs. + +Currently supported xterm-oriented behavior includes: + +- `CSI c` primary DA reply with an xterm-style `ESC[?1;2c` response. +- `CSI > c` secondary DA reply, conservative by default and xterm.js-like + when `WithXtermStyle()` is enabled. +- `CSI 6n` CPR and `CSI ? 6n` DECXCPR cursor position reports. +- `OSC 10;?`, `OSC 11;?`, and `OSC 12;?` foreground/background/cursor + color queries. +- OSC color replies that mirror the incoming BEL vs ST terminator. +- Prefixed CSI parsing that does not fall through to ordinary non-prefixed + handlers when the prefixed form is unsupported. + +This keeps vt10x conservative by default while making it easier to move +closer to xterm / xterm.js behavior over time. diff --git a/csi.go b/csi.go index 3d5b362..a21c966 100644 --- a/csi.go +++ b/csi.go @@ -71,6 +71,13 @@ func (c *csiEscape) maxarg(i, def int) int { func (t *State) handleCSI() { c := &t.csi + if c.prefix != 0 { + if t.handlePrefixedCSI() { + return + } + goto unknown + } + switch c.mode { default: goto unknown @@ -81,7 +88,7 @@ func (t *State) handleCSI() { case 'B', 'e': // CUD, VPR - cursor down t.moveTo(t.cur.X, t.cur.Y+c.maxarg(0, 1)) case 'c': // DA - device attributes - if c.prefix == 0 && c.arg(0, 0) == 0 { + if c.arg(0, 0) == 0 { // Reply with an xterm-style primary DA for compatibility with modern TUIs. // Strict VT102 would traditionally report ESC[?6c here. _, _ = t.w.Write([]byte("\033[?1;2c")) @@ -152,7 +159,7 @@ func (t *State) handleCSI() { case 'L': // IL - insert blank lines t.insertBlankLines(c.arg(0, 1)) case 'l': // RM - reset mode - t.setMode(c.priv, false, c.args) + t.setMode(false, false, c.args) case 'M': // DL - delete lines t.deleteLines(c.arg(0, 1)) case 'X': // ECH - erase chars @@ -167,7 +174,7 @@ func (t *State) handleCSI() { case 'd': // VPA - move to t.moveAbsTo(t.cur.X, c.arg(0, 1)-1) case 'h': // SM - set terminal mode - t.setMode(c.priv, true, c.args) + t.setMode(false, true, c.args) case 'm': // SGR - terminal attribute (color) t.setAttr(c.args) case 'n': @@ -176,27 +183,58 @@ func (t *State) handleCSI() { t.w.Write([]byte("\033[0n")) case 6: // CPR - cursor position report t.w.Write([]byte(fmt.Sprintf("\033[%d;%dR", t.cur.Y+1, t.cur.X+1))) - } - case 'r': // DECSTBM - set scrolling region - if c.priv { + default: goto unknown - } else { - t.setScroll(c.arg(0, 1)-1, c.arg(1, t.rows)-1) - t.moveAbsTo(0, 0) } + case 'r': // DECSTBM - set scrolling region + t.setScroll(c.arg(0, 1)-1, c.arg(1, t.rows)-1) + t.moveAbsTo(0, 0) case 's': // DECSC - save cursor position (ANSI.SYS) - if c.priv || c.prefix != 0 { - goto unknown - } t.saveCursor() case 'u': // DECRC - restore cursor position (ANSI.SYS) - if c.priv || c.prefix != 0 { - goto unknown - } t.restoreCursor() } return unknown: // TODO: get rid of this goto - t.logf("unknown CSI sequence '%c'\n", c.mode) + t.logUnknownCSI(c) // TODO: c.dump() } + +func (t *State) handlePrefixedCSI() bool { + c := &t.csi + switch c.prefix { + case '?': + switch c.mode { + case 'h': + t.setMode(true, true, c.args) + return true + case 'l': + t.setMode(true, false, c.args) + return true + case 'n': + switch c.arg(0, 0) { + case 6: // DECXCPR - DEC-specific cursor position report + _, _ = t.w.Write([]byte(fmt.Sprintf("\033[?%d;%dR", t.cur.Y+1, t.cur.X+1))) + return true + } + } + case '>': + switch c.mode { + case 'c': // DA2 - secondary device attributes + if c.arg(0, 0) == 0 { + _, _ = t.w.Write([]byte(t.secondaryDA)) + return true + } + } + } + + return false +} + +func (t *State) logUnknownCSI(c *csiEscape) { + if c.prefix != 0 { + t.logf("unknown CSI sequence '%c%c'\n", c.prefix, c.mode) + return + } + t.logf("unknown CSI sequence '%c'\n", c.mode) +} diff --git a/csi_test.go b/csi_test.go index d3b9157..426557f 100644 --- a/csi_test.go +++ b/csi_test.go @@ -54,6 +54,43 @@ func TestXtermStylePrimaryDAResponse(t *testing.T) { } } +func TestXtermStyleSecondaryDAResponse(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply)) + if _, err := term.Write([]byte("\033[>c")); err != nil { + t.Fatal(err) + } + if got := reply.String(); got != "\033[>0;95;0c" { + t.Fatalf("unexpected secondary DA response: %q", got) + } +} + +func TestConfigurableSecondaryDAResponse(t *testing.T) { + var reply bytes.Buffer + term := New( + WithWriter(&reply), + WithXtermStyle(), + ) + if _, err := term.Write([]byte("\033[>c")); err != nil { + t.Fatal(err) + } + if got := reply.String(); got != "\033[>0;276;0c" { + t.Fatalf("unexpected configurable secondary DA response: %q", got) + } +} + +func TestPrivateDSRCPRResponse(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply), WithSize(10, 5)) + writeAll(t, term, "\033[3;4H\033[?6n") + if got := reply.String(); got != "\033[?3;4R" { + t.Fatalf("unexpected private CPR response: %q", got) + } + if cur := term.Cursor(); cur.X != 3 || cur.Y != 2 { + t.Fatalf("unexpected cursor after private CPR: (%d,%d)", cur.X, cur.Y) + } +} + func TestPrivateCSIUIgnored(t *testing.T) { term := New(WithSize(10, 1)) if _, err := term.Write([]byte("ab\033[scd\033[?uX")); err != nil { @@ -63,3 +100,33 @@ func TestPrivateCSIUIgnored(t *testing.T) { t.Fatalf("expected private CSI u to be ignored, got %q", got) } } + +func TestUnsupportedPrefixedCSIIsIgnored(t *testing.T) { + term := New(WithSize(5, 3)) + writeAll(t, term, "abcde\033[s") + + st := term.(*terminal) + beforeCur := st.cur + beforeSaved := st.curSaved + beforeTop := st.top + beforeBottom := st.bottom + beforeMode := st.mode + + writeAll(t, term, "\033[>2J\033[=2r\033[ c (DA2) to an xterm.js-style identity string, +// and the option may grow to cover additional xterm-compatible behavior. +func WithXtermStyle() TerminalOption { + return func(info *TerminalInfo) { + info.secondaryDAReply = xtermStyleSecondaryDAReply + } +} + // New returns a new virtual terminal emulator. func New(opts ...TerminalOption) Terminal { info := TerminalInfo{ - w: ioutil.Discard, - cols: 80, - rows: 24, + w: ioutil.Discard, + cols: 80, + rows: 24, + secondaryDAReply: defaultSecondaryDAReply, } for _, opt := range opts { opt(&info) diff --git a/vt_other.go b/vt_other.go index a6465a9..69a97c7 100644 --- a/vt_other.go +++ b/vt_other.go @@ -16,7 +16,7 @@ type terminal struct { } func newTerminal(info TerminalInfo) *terminal { - t := &terminal{newState(info.w)} + t := &terminal{newState(info.w, info.secondaryDAReply)} t.init(info.cols, info.rows) return t } diff --git a/vt_posix.go b/vt_posix.go index 31fa07b..6af31a3 100644 --- a/vt_posix.go +++ b/vt_posix.go @@ -16,7 +16,7 @@ type terminal struct { } func newTerminal(info TerminalInfo) *terminal { - t := &terminal{newState(info.w)} + t := &terminal{newState(info.w, info.secondaryDAReply)} t.init(info.cols, info.rows) return t } diff --git a/vt_test.go b/vt_test.go index 9ff8a3c..22dc1ba 100644 --- a/vt_test.go +++ b/vt_test.go @@ -1,6 +1,7 @@ package vt10x import ( + "bytes" "io" "strings" "testing" @@ -94,3 +95,62 @@ func TestSGRFaint(t *testing.T) { t.Fatalf("expected faint color %06x, got %06x", expected, attr.FG) } } + +func TestCodexStyleStartupTraceQueries(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply), WithSize(10, 5)) + st := term.(*terminal) + + writeAll(t, term, "HELLO\033[s\033[3;4H") + + beforeCur := st.cur + beforeSaved := st.curSaved + beforeTop := st.top + beforeBottom := st.bottom + beforeMode := st.mode + beforeRows := []string{ + rowString(term, 0), + rowString(term, 1), + rowString(term, 2), + rowString(term, 3), + rowString(term, 4), + } + + writeAll(t, term, "\033[6n\033[c\033]10;?\033\\\033]11;?\033\\\033[?u\033[>7u\033[?6n\033[>c") + + expected := "\033[3;4R" + + "\033[?1;2c" + + oscColorReply(10, byte2color(int(LightGrey)), "\033\\") + + oscColorReply(11, byte2color(int(Black)), "\033\\") + + "\033[?3;4R" + + "\033[>0;95;0c" + if got := reply.String(); got != expected { + t.Fatalf("unexpected startup trace replies: %q", got) + } + + if st.cur != beforeCur { + t.Fatalf("cursor changed after startup trace: before=%+v after=%+v", beforeCur, st.cur) + } + if st.curSaved != beforeSaved { + t.Fatalf("saved cursor changed after startup trace: before=%+v after=%+v", beforeSaved, st.curSaved) + } + if st.top != beforeTop || st.bottom != beforeBottom { + t.Fatalf("scroll region changed after startup trace: before=(%d,%d) after=(%d,%d)", beforeTop, beforeBottom, st.top, st.bottom) + } + if st.mode != beforeMode { + t.Fatalf("mode changed after startup trace: before=%v after=%v", beforeMode, st.mode) + } + + afterRows := []string{ + rowString(term, 0), + rowString(term, 1), + rowString(term, 2), + rowString(term, 3), + rowString(term, 4), + } + for i := range beforeRows { + if afterRows[i] != beforeRows[i] { + t.Fatalf("row %d changed after startup trace: before=%q after=%q", i+1, beforeRows[i], afterRows[i]) + } + } +} From 4d4db3ffa8878267ae6664d71bb0d1637af1cee0 Mon Sep 17 00:00:00 2001 From: fy Date: Wed, 8 Apr 2026 02:02:23 +0800 Subject: [PATCH 7/7] feat: extend xterm-style query replies --- README.md | 3 ++ csi.go | 131 +++++++++++++++++++++++++++++++++++++++++++++++----- csi_test.go | 56 ++++++++++++++++++++++ parse.go | 2 +- state.go | 3 ++ vt_test.go | 37 +++++++++++++++ 6 files changed, 219 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 5869ce5..ff2d4f8 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,12 @@ set of xterm-style startup and query behaviors used by modern TUIs. Currently supported xterm-oriented behavior includes: - `CSI c` primary DA reply with an xterm-style `ESC[?1;2c` response. +- `ESC Z` DECID compatibility, mapped to the same primary DA reply. - `CSI > c` secondary DA reply, conservative by default and xterm.js-like when `WithXtermStyle()` is enabled. - `CSI 6n` CPR and `CSI ? 6n` DECXCPR cursor position reports. +- `CSI Ps $ p` and `CSI ? Ps $ p` request-mode reports for supported ANSI and + DEC private modes, including bracketed paste and focus reporting. - `OSC 10;?`, `OSC 11;?`, and `OSC 12;?` foreground/background/cursor color queries. - OSC color replies that mirror the incoming BEL vs ST terminator. diff --git a/csi.go b/csi.go index a21c966..f7d48c1 100644 --- a/csi.go +++ b/csi.go @@ -13,6 +13,7 @@ type csiEscape struct { args []int mode byte prefix byte + interm string priv bool } @@ -21,6 +22,7 @@ func (c *csiEscape) reset() { c.args = c.args[:0] c.mode = 0 c.prefix = 0 + c.interm = "" c.priv = false } @@ -38,15 +40,25 @@ func (c *csiEscape) parse() { if len(c.buf) == 1 { return } - s := string(c.buf) + b := c.buf[:len(c.buf)-1] c.args = c.args[:0] - if s[0] == '?' || s[0] == '>' || s[0] == '<' || s[0] == '=' { - c.prefix = s[0] - c.priv = s[0] == '?' - s = s[1:] + if len(b) > 0 && (b[0] == '?' || b[0] == '>' || b[0] == '<' || b[0] == '=') { + c.prefix = b[0] + c.priv = b[0] == '?' + b = b[1:] } - s = s[:len(s)-1] - ss := strings.Split(s, ";") + params := b + for i, ch := range b { + if (ch < '0' || ch > '9') && ch != ';' { + params = b[:i] + c.interm = string(b[i:]) + break + } + } + if len(params) == 0 { + return + } + ss := strings.Split(string(params), ";") for _, p := range ss { i, err := strconv.Atoi(p) if err != nil { @@ -71,6 +83,13 @@ func (c *csiEscape) maxarg(i, def int) int { func (t *State) handleCSI() { c := &t.csi + if c.interm != "" { + if t.handleIntermediateCSI() { + return + } + goto unknown + } + if c.prefix != 0 { if t.handlePrefixedCSI() { return @@ -89,9 +108,7 @@ func (t *State) handleCSI() { t.moveTo(t.cur.X, t.cur.Y+c.maxarg(0, 1)) case 'c': // DA - device attributes if c.arg(0, 0) == 0 { - // Reply with an xterm-style primary DA for compatibility with modern TUIs. - // Strict VT102 would traditionally report ESC[?6c here. - _, _ = t.w.Write([]byte("\033[?1;2c")) + t.replyPrimaryDA() } else { goto unknown } @@ -200,6 +217,23 @@ unknown: // TODO: get rid of this goto // TODO: c.dump() } +func (t *State) handleIntermediateCSI() bool { + c := &t.csi + switch c.interm { + case "$": + switch c.mode { + case 'p': // DECRQM / ANSI RMQ - request mode + if len(c.args) == 0 { + return false + } + t.replyMode(c.priv, c.arg(0, 0), t.modeStatus(c.priv, c.arg(0, 0))) + return true + } + } + + return false +} + func (t *State) handlePrefixedCSI() bool { c := &t.csi switch c.prefix { @@ -231,9 +265,82 @@ func (t *State) handlePrefixedCSI() bool { return false } +func (t *State) replyPrimaryDA() { + // Reply with an xterm-style primary DA for compatibility with modern TUIs. + // Strict VT102 would traditionally report ESC[?6c here. + _, _ = t.w.Write([]byte("\033[?1;2c")) +} + +func (t *State) replyMode(priv bool, mode, status int) { + prefix := "" + if priv { + prefix = "?" + } + _, _ = t.w.Write([]byte(fmt.Sprintf("\033[%s%d;%d$y", prefix, mode, status))) +} + +func (t *State) modeStatus(priv bool, mode int) int { + set := false + known := true + + if priv { + switch mode { + case 1: + set = t.mode&ModeAppCursor != 0 + case 6: + set = t.cur.State&cursorOrigin != 0 + case 7: + set = t.mode&ModeWrap != 0 + case 25: + set = t.mode&ModeHide == 0 + case 47, 1047, 1049: + set = t.mode&ModeAltScreen != 0 + case 66: + set = t.mode&ModeAppKeypad != 0 + case 1000: + set = t.mode&ModeMouseButton != 0 + case 1002: + set = t.mode&ModeMouseMotion != 0 + case 1003: + set = t.mode&ModeMouseMany != 0 + case 1004: + set = t.mode&ModeFocus != 0 + case 1006: + set = t.mode&ModeMouseSgr != 0 + case 1034: + set = t.mode&Mode8bit != 0 + case 2004: + set = t.mode&ModeBracketedPaste != 0 + default: + known = false + } + } else { + switch mode { + case 2: + set = t.mode&ModeKeyboardLock != 0 + case 4: + set = t.mode&ModeInsert != 0 + case 12: + set = t.mode&ModeEcho != 0 + case 20: + set = t.mode&ModeCRLF != 0 + default: + known = false + } + } + + if !known { + return 0 + } + if set { + return 1 + } + return 2 +} + func (t *State) logUnknownCSI(c *csiEscape) { - if c.prefix != 0 { - t.logf("unknown CSI sequence '%c%c'\n", c.prefix, c.mode) + if c.prefix != 0 || c.interm != "" { + t.logf("unknown CSI sequence '%c%s%c'\n", c.prefix, c.interm, c.mode) return } t.logf("unknown CSI sequence '%c'\n", c.mode) diff --git a/csi_test.go b/csi_test.go index 426557f..66e6d17 100644 --- a/csi_test.go +++ b/csi_test.go @@ -41,6 +41,13 @@ func TestCSIParse(t *testing.T) { if csi.mode != 'u' || csi.prefix != '>' || csi.priv || csi.arg(0, 0) != 7 || len(csi.args) != 1 { t.Fatal("CSI parse mismatch") } + + csi.reset() + csi.buf = []byte("?2004$p") + csi.parse() + if csi.mode != 'p' || csi.prefix != '?' || csi.interm != "$" || !csi.priv || csi.arg(0, 0) != 2004 || len(csi.args) != 1 { + t.Fatal("CSI parse mismatch") + } } func TestXtermStylePrimaryDAResponse(t *testing.T) { @@ -79,6 +86,17 @@ func TestConfigurableSecondaryDAResponse(t *testing.T) { } } +func TestDECIDResponse(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply)) + if _, err := term.Write([]byte("\033Z")); err != nil { + t.Fatal(err) + } + if got := reply.String(); got != "\033[?1;2c" { + t.Fatalf("unexpected DECID response: %q", got) + } +} + func TestPrivateDSRCPRResponse(t *testing.T) { var reply bytes.Buffer term := New(WithWriter(&reply), WithSize(10, 5)) @@ -130,3 +148,41 @@ func TestUnsupportedPrefixedCSIIsIgnored(t *testing.T) { t.Fatalf("mode changed after ignored prefixed CSI: before=%v after=%v", beforeMode, st.mode) } } + +func TestPrivateModeReportForBracketedPaste(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply)) + + writeAll(t, term, "\033[?2004$p") + if got := reply.String(); got != "\033[?2004;2$y" { + t.Fatalf("unexpected initial DECRQM response: %q", got) + } + + reply.Reset() + writeAll(t, term, "\033[?2004h\033[?2004$p") + if got := reply.String(); got != "\033[?2004;1$y" { + t.Fatalf("unexpected enabled DECRQM response: %q", got) + } + + reply.Reset() + writeAll(t, term, "\033[?2004l\033[?2004$p\033[?2026$p") + if got := reply.String(); got != "\033[?2004;2$y\033[?2026;0$y" { + t.Fatalf("unexpected final DECRQM response: %q", got) + } +} + +func TestANSIModeReportForInsertMode(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply)) + + writeAll(t, term, "\033[4$p") + if got := reply.String(); got != "\033[4;2$y" { + t.Fatalf("unexpected ANSI RMQ response: %q", got) + } + + reply.Reset() + writeAll(t, term, "\033[4h\033[4$p") + if got := reply.String(); got != "\033[4;1$y" { + t.Fatalf("unexpected enabled ANSI RMQ response: %q", got) + } +} diff --git a/parse.go b/parse.go index de01595..7f6de64 100644 --- a/parse.go +++ b/parse.go @@ -127,7 +127,7 @@ func (t *State) parseEsc(c rune) { t.moveTo(t.cur.X, t.cur.Y-1) } case 'Z': // DECID - identify terminal - // TODO: write to our writer our id + t.replyPrimaryDA() case 'c': // RIS - reset to initial state t.reset() case '=': // DECPAM - application keypad diff --git a/state.go b/state.go index ccf1b64..f85be6d 100644 --- a/state.go +++ b/state.go @@ -53,6 +53,7 @@ const ( ModeFocus ModeMouseX10 ModeMouseMany + ModeBracketedPaste ModeMouseMask = ModeMouseButton | ModeMouseMotion | ModeMouseX10 | ModeMouseMany ) @@ -652,6 +653,8 @@ func (t *State) setMode(priv bool, set bool, args []int) { t.modMode(set, ModeMouseMany) case 1004: // send focus events to tty t.modMode(set, ModeFocus) + case 2004: // bracketed paste mode + t.modMode(set, ModeBracketedPaste) case 1006: // extended reporting mode t.modMode(set, ModeMouseSgr) case 1034: diff --git a/vt_test.go b/vt_test.go index 22dc1ba..f5b1fb1 100644 --- a/vt_test.go +++ b/vt_test.go @@ -154,3 +154,40 @@ func TestCodexStyleStartupTraceQueries(t *testing.T) { } } } + +func TestStartupTraceModeQueriesDoNotChangeScreenState(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply), WithSize(10, 3)) + st := term.(*terminal) + + writeAll(t, term, "abc\033[s\033[2;2H") + beforeCur := st.cur + beforeSaved := st.curSaved + beforeMode := st.mode + beforeTop := st.top + beforeBottom := st.bottom + beforeRows := []string{rowString(term, 0), rowString(term, 1), rowString(term, 2)} + + writeAll(t, term, "\033[?2004$p\033[?1004$p\033[4$p\033[?2026$p") + + if got := reply.String(); got != "\033[?2004;2$y\033[?1004;2$y\033[4;2$y\033[?2026;0$y" { + t.Fatalf("unexpected mode query replies: %q", got) + } + if st.cur != beforeCur { + t.Fatalf("cursor changed after mode queries: before=%+v after=%+v", beforeCur, st.cur) + } + if st.curSaved != beforeSaved { + t.Fatalf("saved cursor changed after mode queries: before=%+v after=%+v", beforeSaved, st.curSaved) + } + if st.mode != beforeMode { + t.Fatalf("mode changed after mode queries: before=%v after=%v", beforeMode, st.mode) + } + if st.top != beforeTop || st.bottom != beforeBottom { + t.Fatalf("scroll region changed after mode queries: before=(%d,%d) after=(%d,%d)", beforeTop, beforeBottom, st.top, st.bottom) + } + for i, want := range beforeRows { + if got := rowString(term, i); got != want { + t.Fatalf("row %d changed after mode queries: before=%q after=%q", i+1, want, got) + } + } +}