Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,30 @@
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.
- `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.
- 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.
100 changes: 100 additions & 0 deletions alt_screen_test.go
Original file line number Diff line number Diff line change
@@ -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{
" ",
" ",
" ",
" ",
" ",
})
}
198 changes: 178 additions & 20 deletions csi.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,20 @@ 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
interm string
priv bool
}

func (c *csiEscape) reset() {
c.buf = c.buf[:0]
c.args = c.args[:0]
c.mode = 0
c.prefix = 0
c.interm = ""
c.priv = false
}

Expand All @@ -36,14 +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] == '?' {
c.priv = true
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 {
Expand All @@ -68,6 +83,20 @@ 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
}
goto unknown
}

switch c.mode {
default:
goto unknown
Expand All @@ -79,7 +108,9 @@ 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 {
// TODO: write vt102 id
t.replyPrimaryDA()
} else {
goto unknown
}
case 'C', 'a': // CUF, HPR - cursor <n> forward
t.moveTo(t.cur.X+c.maxarg(0, 1), t.cur.Y)
Expand Down Expand Up @@ -145,7 +176,7 @@ func (t *State) handleCSI() {
case 'L': // IL - insert <n> 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 <n> lines
t.deleteLines(c.arg(0, 1))
case 'X': // ECH - erase <n> chars
Expand All @@ -160,7 +191,7 @@ func (t *State) handleCSI() {
case 'd': // VPA - move to <row>
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':
Expand All @@ -169,21 +200,148 @@ 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)
t.saveCursor()
case 'u': // DECRC - restore cursor position (ANSI.SYS)
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) 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 {
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) 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 || 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)
}
Loading