diff --git a/README.md b/README.md index 420318f..ff2d4f8 100644 --- a/README.md +++ b/README.md @@ -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. 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..f7d48c1 100644 --- a/csi.go +++ b/csi.go @@ -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 } @@ -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 { @@ -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 @@ -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 forward t.moveTo(t.cur.X+c.maxarg(0, 1), t.cur.Y) @@ -145,7 +176,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 @@ -160,7 +191,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': @@ -169,14 +200,12 @@ 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) @@ -184,6 +213,135 @@ func (t *State) handleCSI() { } 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) +} diff --git a/csi_test.go b/csi_test.go index 86c2fb3..66e6d17 100644 --- a/csi_test.go +++ b/csi_test.go @@ -1,6 +1,7 @@ package vt10x import ( + "bytes" "testing" ) @@ -33,4 +34,155 @@ 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") + } + + 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) { + 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 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 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)) + 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 { + t.Fatal(err) + } + if got := extractStr(term, 0, 4, 0); got != "abcdX" { + 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[= 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 } @@ -72,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 @@ -107,6 +162,7 @@ func (t *State) parseEscStr(c rune) { case '\033': t.state = t.parseEscStrEnd case '\a': // backwards compatiblity to xterm + t.str.useST = false t.state = t.parse t.handleSTR() default: @@ -121,6 +177,7 @@ func (t *State) parseEscStrEnd(c rune) { t.logf("%q", string(c)) t.state = t.parse if c == '\\' { + t.str.useST = true t.handleSTR() } } @@ -132,9 +189,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 fbc2f94..f85be6d 100644 --- a/state.go +++ b/state.go @@ -11,13 +11,16 @@ const ( ) const ( - attrReverse = 1 << iota - attrUnderline - attrBold - attrGfx - attrItalic - attrBlink - attrWrap + AttrReverse = 1 << iota + AttrUnderline + AttrBold + AttrGfx + AttrItalic + AttrBlink + AttrWrap + AttrFaint + AttrWide // Wide character (occupies 2 cells) + AttrWideDummy // Placeholder for second cell of wide character ) const ( @@ -50,6 +53,7 @@ const ( ModeFocus ModeMouseX10 ModeMouseMany + ModeBracketedPaste ModeMouseMask = ModeMouseButton | ModeMouseMotion | ModeMouseX10 | ModeMouseMany ) @@ -93,20 +97,30 @@ 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 + secondaryDA string } -func newState(w io.Writer) *State { +func newState(w io.Writer, secondaryDA string) *State { + if secondaryDA == "" { + secondaryDA = defaultSecondaryDAReply + } return &State{ w: w, colorOverride: make(map[Color]Color), + secondaryDA: secondaryDA, } } @@ -156,6 +170,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 @@ -197,6 +217,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 } @@ -258,7 +350,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] } @@ -268,15 +360,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&attrReverse != 0 { + 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 @@ -286,17 +391,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) } @@ -314,11 +423,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) @@ -332,21 +442,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) @@ -410,6 +515,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() } @@ -543,29 +653,49 @@ 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: 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 @@ -611,29 +741,31 @@ 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 + t.cur.Attr.Mode |= AttrBold + case 2: + 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 + 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/str.go b/str.go index 2a42b04..7cc34e7 100644 --- a/str.go +++ b/str.go @@ -12,15 +12,17 @@ import ( // as far as I can tell, don't really have a name; STR is the name I took from // suckless which I imagine comes from rxvt or xterm). type strEscape struct { - typ rune - buf []rune - args []string + typ rune + buf []rune + args []string + useST bool } func (s *strEscape) reset() { s.typ = 0 s.buf = s.buf[:0] s.args = nil + s.useST = false } func (s *strEscape) put(c rune) { @@ -56,6 +58,13 @@ func (s *strEscape) argString(i int, def string) string { return s.args[i] } +func (s *strEscape) terminator() string { + if s.useST { + return "\033\\" + } + return "\a" +} + func (t *State) handleSTR() { s := &t.str s.parse() @@ -93,24 +102,24 @@ func (t *State) handleSTR() { if p != nil && *p == "?" { t.oscColorResponse(int(DefaultBG), 11) } else if err := t.setColorName(int(DefaultBG), p); err != nil { + t.logf("invalid background color: %s\n", maybe(p)) + } else { + // TODO: redraw + } + case 12: + if len(s.args) < 2 { + break + } + + c := s.argString(1, "") + p := &c + if p != nil && *p == "?" { + t.oscColorResponse(int(DefaultCursor), 12) + } else if err := t.setColorName(int(DefaultCursor), p); err != nil { t.logf("invalid cursor color: %s\n", maybe(p)) } else { // TODO: redraw } - // case 12: - // if len(s.args) < 2 { - // break - // } - - // c := s.argString(1, "") - // p := &c - // if p != nil && *p == "?" { - // t.oscColorResponse(int(DefaultCursor), 12) - // } else if err := t.setColorName(int(DefaultCursor), p); err != nil { - // t.logf("invalid background color: %s\n", p) - // } else { - // // TODO: redraw - // } case 4: // color set if len(s.args) < 3 { break @@ -154,7 +163,7 @@ func (t *State) handleSTR() { } func (t *State) setColorName(j int, p *string) error { - if !between(j, 0, 1<<24) { + if !t.validColorSlot(j) { return fmt.Errorf("invalid color value %d", j) } @@ -173,34 +182,76 @@ func (t *State) setColorName(j int, p *string) error { return nil } +func (t *State) validColorSlot(j int) bool { + if between(j, 0, 255) { + return true + } + + switch Color(j) { + case DefaultFG, DefaultBG, DefaultCursor: + return true + } + + return between(j, 0, 1<<24-1) +} + func (t *State) oscColorResponse(j, num int) { - if j < 0 { + c, ok := t.dynamicColorValue(j) + if !ok { t.logf("failed to fetch osc color %d\n", j) return } - k, ok := t.colorOverride[Color(j)] - if ok { - j = int(k) - } - - r, g, b := rgb(j) - t.w.Write([]byte(fmt.Sprintf("\033]%d;rgb:%02x%02x/%02x%02x/%02x%02x\007", num, r, r, g, g, b, b))) + r, g, b := rgb(c) + t.w.Write([]byte(fmt.Sprintf("\033]%d;rgb:%02x%02x/%02x%02x/%02x%02x%s", num, r, r, g, g, b, b, t.str.terminator()))) } func (t *State) osc4ColorResponse(j int) { - if j < 0 { + c, ok := t.paletteColorValue(j) + if !ok { t.logf("failed to fetch osc4 color %d\n", j) return } - k, ok := t.colorOverride[Color(j)] - if ok { - j = int(k) + r, g, b := rgb(c) + t.w.Write([]byte(fmt.Sprintf("\033]4;%d;rgb:%02x%02x/%02x%02x/%02x%02x%s", j, r, r, g, g, b, b, t.str.terminator()))) +} + +func (t *State) dynamicColorValue(j int) (int, bool) { + if j < 0 { + return 0, false + } + + if k, ok := t.colorOverride[Color(j)]; ok { + return int(k), true + } + + switch Color(j) { + case DefaultFG: + return int(byte2color(int(LightGrey))), true + case DefaultBG: + return int(byte2color(int(Black))), true + case DefaultCursor: + return int(byte2color(int(LightGrey))), true + default: + if between(j, 0, 1<<24-1) { + return j, true + } + } + + return 0, false +} + +func (t *State) paletteColorValue(j int) (int, bool) { + if !between(j, 0, 255) { + return 0, false + } + + if k, ok := t.colorOverride[Color(j)]; ok { + return int(k), true } - r, g, b := rgb(j) - t.w.Write([]byte(fmt.Sprintf("\033]4;%d;rgb:%02x%02x/%02x%02x/%02x%02x\007", j, r, r, g, g, b, b))) + return int(byte2color(j)), true } func rgb(j int) (r, g, b int) { diff --git a/str_test.go b/str_test.go index 974f15e..0901efb 100644 --- a/str_test.go +++ b/str_test.go @@ -1,9 +1,16 @@ package vt10x import ( + "bytes" + "fmt" "testing" ) +func oscColorReply(num int, c Color, term string) string { + r, g, b := rgb(int(c)) + return fmt.Sprintf("\033]%d;rgb:%02x%02x/%02x%02x/%02x%02x%s", num, r, r, g, g, b, b, term) +} + func TestSTRParse(t *testing.T) { var str strEscape str.reset() @@ -170,3 +177,43 @@ func TestParseColor(t *testing.T) { }) } } + +func TestOSCColorQueriesReplyWithMatchingTerminator(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply)) + + writeAll(t, term, "\033]10;?\a\033]11;?\033\\\033]12;?\a") + + expected := oscColorReply(10, byte2color(int(LightGrey)), "\a") + + oscColorReply(11, byte2color(int(Black)), "\033\\") + + oscColorReply(12, byte2color(int(LightGrey)), "\a") + if got := reply.String(); got != expected { + t.Fatalf("unexpected OSC color replies: %q", got) + } +} + +func TestOSC4ColorQueryUsesPaletteIndex(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply)) + + writeAll(t, term, "\033]4;1;rgb:12/34/56\a") + reply.Reset() + writeAll(t, term, "\033]4;1;?\a") + + if got := reply.String(); got != "\033]4;1;rgb:1212/3434/5656\a" { + t.Fatalf("unexpected OSC 4 color reply: %q", got) + } +} + +func TestOSCCursorColorSetAndQuery(t *testing.T) { + var reply bytes.Buffer + term := New(WithWriter(&reply)) + + writeAll(t, term, "\033]12;rgb:01/23/45\a") + reply.Reset() + writeAll(t, term, "\033]12;?\033\\") + + if got := reply.String(); got != "\033]12;rgb:0101/2323/4545\033\\" { + t.Fatalf("unexpected OSC 12 color reply: %q", got) + } +} diff --git a/vt.go b/vt.go index c4e58dd..a5c5fb0 100644 --- a/vt.go +++ b/vt.go @@ -58,10 +58,14 @@ type View interface { type TerminalOption func(*TerminalInfo) type TerminalInfo struct { - w io.Writer - cols, rows int + w io.Writer + cols, rows int + secondaryDAReply string } +const defaultSecondaryDAReply = "\033[>0;95;0c" +const xtermStyleSecondaryDAReply = "\033[>0;276;0c" + func WithWriter(w io.Writer) TerminalOption { return func(info *TerminalInfo) { info.w = w @@ -75,12 +79,22 @@ func WithSize(cols, rows int) TerminalOption { } } +// WithXtermStyle enables vt10x's xterm-oriented compatibility profile. +// Today this switches CSI > 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 c9d364e..69a97c7 100644 --- a/vt_other.go +++ b/vt_other.go @@ -1,3 +1,4 @@ +//go:build plan9 || nacl || windows // +build plan9 nacl windows package vt10x @@ -15,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 80644f4..6af31a3 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 @@ -15,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 ac97301..f5b1fb1 100644 --- a/vt_test.go +++ b/vt_test.go @@ -1,6 +1,7 @@ package vt10x import ( + "bytes" "io" "strings" "testing" @@ -75,3 +76,118 @@ 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) + } +} + +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]) + } + } +} + +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) + } + } +}