diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..e4c28eac --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.ttf filter=lfs diff=lfs merge=lfs -text diff --git a/ansi/background.go b/ansi/background.go index 6c66e629..2383cf09 100644 --- a/ansi/background.go +++ b/ansi/background.go @@ -1,9 +1,73 @@ package ansi import ( + "fmt" "image/color" ) +// Colorizer is a [color.Color] interface that can be formatted as a string. +type Colorizer interface { + color.Color + fmt.Stringer +} + +// HexColorizer is a [color.Color] that can be formatted as a hex string. +type HexColorizer struct{ color.Color } + +var _ Colorizer = HexColorizer{} + +// String returns the color as a hex string. If the color is nil, an empty +// string is returned. +func (h HexColorizer) String() string { + if h.Color == nil { + return "" + } + r, g, b, _ := h.RGBA() + // Get the lower 8 bits + r &= 0xff + g &= 0xff + b &= 0xff + return fmt.Sprintf("#%02x%02x%02x", uint8(r), uint8(g), uint8(b)) //nolint:gosec +} + +// XRGBColorizer is a [color.Color] that can be formatted as an XParseColor +// rgb: string. +// +// See: https://linux.die.net/man/3/xparsecolor +type XRGBColorizer struct{ color.Color } + +var _ Colorizer = XRGBColorizer{} + +// String returns the color as an XParseColor rgb: string. If the color is nil, +// an empty string is returned. +func (x XRGBColorizer) String() string { + if x.Color == nil { + return "" + } + r, g, b, _ := x.RGBA() + // Get the lower 8 bits + return fmt.Sprintf("rgb:%04x/%04x/%04x", r, g, b) +} + +// XRGBAColorizer is a [color.Color] that can be formatted as an XParseColor +// rgba: string. +// +// See: https://linux.die.net/man/3/xparsecolor +type XRGBAColorizer struct{ color.Color } + +var _ Colorizer = XRGBAColorizer{} + +// String returns the color as an XParseColor rgba: string. If the color is nil, +// an empty string is returned. +func (x XRGBAColorizer) String() string { + if x.Color == nil { + return "" + } + r, g, b, a := x.RGBA() + // Get the lower 8 bits + return fmt.Sprintf("rgba:%04x/%04x/%04x/%04x", r, g, b, a) +} + // SetForegroundColor returns a sequence that sets the default terminal // foreground color. // @@ -14,7 +78,16 @@ import ( // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands func SetForegroundColor(c color.Color) string { - return "\x1b]10;" + colorToHexString(c) + "\x07" + var s string + switch c := c.(type) { + case Colorizer: + s = c.String() + case fmt.Stringer: + s = c.String() + default: + s = HexColorizer{c}.String() + } + return "\x1b]10;" + s + "\x07" } // RequestForegroundColor is a sequence that requests the current default @@ -39,7 +112,16 @@ const ResetForegroundColor = "\x1b]110\x07" // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands func SetBackgroundColor(c color.Color) string { - return "\x1b]11;" + colorToHexString(c) + "\x07" + var s string + switch c := c.(type) { + case Colorizer: + s = c.String() + case fmt.Stringer: + s = c.String() + default: + s = HexColorizer{c}.String() + } + return "\x1b]11;" + s + "\x07" } // RequestBackgroundColor is a sequence that requests the current default @@ -63,7 +145,16 @@ const ResetBackgroundColor = "\x1b]111\x07" // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands func SetCursorColor(c color.Color) string { - return "\x1b]12;" + colorToHexString(c) + "\x07" + var s string + switch c := c.(type) { + case Colorizer: + s = c.String() + case fmt.Stringer: + s = c.String() + default: + s = HexColorizer{c}.String() + } + return "\x1b]12;" + s + "\x07" } // RequestCursorColor is a sequence that requests the current terminal cursor diff --git a/ansi/background_test.go b/ansi/background_test.go index 960d92e5..16409f75 100644 --- a/ansi/background_test.go +++ b/ansi/background_test.go @@ -31,3 +31,19 @@ func TestStringImplementations(t *testing.T) { cursorColor) } } + +func TestColorizer(t *testing.T) { + hex := ansi.HexColorizer{ansi.BrightBlack} + xrgb := ansi.XRGBColorizer{ansi.ExtendedColor(235)} + xrgba := ansi.XRGBAColorizer{ansi.TrueColor(0x00ff00)} + + if seq := ansi.SetForegroundColor(hex); seq != "\x1b]10;#808080\x07" { + t.Errorf("Unexpected sequence for HexColorizer: got %q", seq) + } + if seq := ansi.SetForegroundColor(xrgb); seq != "\x1b]10;rgb:2626/2626/2626\x07" { + t.Errorf("Unexpected sequence for XRGBColorizer: got %q", seq) + } + if seq := ansi.SetForegroundColor(xrgba); seq != "\x1b]10;rgba:0000/ffff/0000/ffff\x07" { + t.Errorf("Unexpected sequence for XRGBAColorizer: got %q", seq) + } +} diff --git a/ansi/c0.go b/ansi/c0.go index 13e3c6c3..28ff7c2a 100644 --- a/ansi/c0.go +++ b/ansi/c0.go @@ -69,4 +69,11 @@ const ( RS = 0x1E // US is the unit separator character (Caret: ^_). US = 0x1F + + // LS0 is the locking shift 0 character. + // This is an alias for [SI]. + LS0 = SI + // LS1 is the locking shift 1 character. + // This is an alias for [SO]. + LS1 = SO ) diff --git a/ansi/charset.go b/ansi/charset.go new file mode 100644 index 00000000..50fff51f --- /dev/null +++ b/ansi/charset.go @@ -0,0 +1,55 @@ +package ansi + +// SelectCharacterSet sets the G-set character designator to the specified +// character set. +// +// ESC Ps Pd +// +// Where Ps is the G-set character designator, and Pd is the identifier. +// For 94-character sets, the designator can be one of: +// - ( G0 +// - ) G1 +// - * G2 +// - + G3 +// +// For 96-character sets, the designator can be one of: +// - - G1 +// - . G2 +// - / G3 +// +// Some common 94-character sets are: +// - 0 DEC Special Drawing Set +// - A United Kingdom (UK) +// - B United States (USASCII) +// +// Examples: +// +// ESC ( B Select character set G0 = United States (USASCII) +// ESC ( 0 Select character set G0 = Special Character and Line Drawing Set +// ESC ) 0 Select character set G1 = Special Character and Line Drawing Set +// ESC * A Select character set G2 = United Kingdom (UK) +// +// See: https://vt100.net/docs/vt510-rm/SCS.html +func SelectCharacterSet(gset byte, charset byte) string { + return "\x1b" + string(gset) + string(charset) +} + +// SCS is an alias for SelectCharacterSet. +func SCS(gset byte, charset byte) string { + return SelectCharacterSet(gset, charset) +} + +// Locking Shift 1 Right (LS1R) shifts G1 into GR character set. +const LS1R = "\x1b~" + +// Locking Shift 2 (LS2) shifts G2 into GL character set. +const LS2 = "\x1bn" + +// Locking Shift 2 Right (LS2R) shifts G2 into GR character set. +const LS2R = "\x1b}" + +// Locking Shift 3 (LS3) shifts G3 into GL character set. +const LS3 = "\x1bo" + +// Locking Shift 3 Right (LS3R) shifts G3 into GR character set. +const LS3R = "\x1b|" diff --git a/ansi/csi.go b/ansi/csi.go index b7e5bd2d..db7f7f9e 100644 --- a/ansi/csi.go +++ b/ansi/csi.go @@ -3,8 +3,6 @@ package ansi import ( "bytes" "strconv" - - "github.com/charmbracelet/x/ansi/parser" ) // CsiSequence represents a control sequence introducer (CSI) sequence. @@ -23,7 +21,7 @@ type CsiSequence struct { // This is a slice of integers, where each integer is a 32-bit integer // containing the parameter value in the lower 31 bits and a flag in the // most significant bit indicating whether there are more sub-parameters. - Params []int + Params []Parameter // Cmd contains the raw command of the sequence. // The command is a 32-bit integer containing the CSI command byte in the @@ -35,17 +33,25 @@ type CsiSequence struct { // Is represented as: // // 'u' | '?' << 8 - Cmd int + Cmd Command } var _ Sequence = CsiSequence{} +// Clone returns a deep copy of the CSI sequence. +func (s CsiSequence) Clone() Sequence { + return CsiSequence{ + Params: append([]Parameter(nil), s.Params...), + Cmd: s.Cmd, + } +} + // Marker returns the marker byte of the CSI sequence. // This is always gonna be one of the following '<' '=' '>' '?' and in the // range of 0x3C-0x3F. // Zero is returned if the sequence does not have a marker. func (s CsiSequence) Marker() int { - return parser.Marker(s.Cmd) + return s.Cmd.Marker() } // Intermediate returns the intermediate byte of the CSI sequence. @@ -54,51 +60,22 @@ func (s CsiSequence) Marker() int { // ',', '-', '.', '/'. // Zero is returned if the sequence does not have an intermediate byte. func (s CsiSequence) Intermediate() int { - return parser.Intermediate(s.Cmd) + return s.Cmd.Intermediate() } // Command returns the command byte of the CSI sequence. func (s CsiSequence) Command() int { - return parser.Command(s.Cmd) -} - -// Param returns the parameter at the given index. -// It returns -1 if the parameter does not exist. -func (s CsiSequence) Param(i int) int { - return parser.Param(s.Params, i) -} - -// HasMore returns true if the parameter has more sub-parameters. -func (s CsiSequence) HasMore(i int) bool { - return parser.HasMore(s.Params, i) + return s.Cmd.Command() } -// Subparams returns the sub-parameters of the given parameter. -// It returns nil if the parameter does not exist. -func (s CsiSequence) Subparams(i int) []int { - return parser.Subparams(s.Params, i) -} - -// Len returns the number of parameters in the sequence. -// This will return the number of parameters in the sequence, excluding any -// sub-parameters. -func (s CsiSequence) Len() int { - return parser.Len(s.Params) -} - -// Range iterates over the parameters of the sequence and calls the given -// function for each parameter. -// The function should return false to stop the iteration. -func (s CsiSequence) Range(fn func(i int, param int, hasMore bool) bool) { - parser.Range(s.Params, fn) -} - -// Clone returns a copy of the CSI sequence. -func (s CsiSequence) Clone() Sequence { - return CsiSequence{ - Params: append([]int(nil), s.Params...), - Cmd: s.Cmd, +// Param is a helper that returns the parameter at the given index and falls +// back to the default value if the parameter is missing. If the index is out +// of bounds, it returns the default value and false. +func (s CsiSequence) Param(i, def int) (int, bool) { + if i < 0 || i >= len(s.Params) { + return def, false } + return s.Params[i].Param(def), true } // String returns a string representation of the sequence. @@ -114,23 +91,25 @@ func (s CsiSequence) buffer() *bytes.Buffer { if m := s.Marker(); m != 0 { b.WriteByte(byte(m)) } - s.Range(func(i, param int, hasMore bool) bool { + for i, p := range s.Params { + param := p.Param(-1) if param >= 0 { b.WriteString(strconv.Itoa(param)) } if i < len(s.Params)-1 { - if hasMore { + if p.HasMore() { b.WriteByte(':') } else { b.WriteByte(';') } } - return true - }) + } if i := s.Intermediate(); i != 0 { b.WriteByte(byte(i)) } - b.WriteByte(byte(s.Command())) + if cmd := s.Command(); cmd != 0 { + b.WriteByte(byte(cmd)) + } return &b } diff --git a/ansi/csi_test.go b/ansi/csi_test.go index e9d3882f..7da8b713 100644 --- a/ansi/csi_test.go +++ b/ansi/csi_test.go @@ -104,16 +104,16 @@ func TestCsiSequence_Param(t *testing.T) { i int want int }{ - { - name: "no param", - s: CsiSequence{}, - i: 0, - want: -1, - }, + // { + // name: "no param", + // s: CsiSequence{}, + // i: 0, + // want: -1, + // }, { name: "param", s: CsiSequence{ - Params: []int{1, 2, 3}, + Params: []Parameter{1, 2, 3}, }, i: 1, want: 2, @@ -121,7 +121,7 @@ func TestCsiSequence_Param(t *testing.T) { { name: "missing param", s: CsiSequence{ - Params: []int{1, parser.MissingParam, 3}, + Params: []Parameter{1, parser.MissingParam, 3}, }, i: 1, want: -1, @@ -129,7 +129,7 @@ func TestCsiSequence_Param(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.s.Param(tt.i); got != tt.want { + if got := tt.s.Params[tt.i]; got.Param(-1) != tt.want { t.Errorf("CsiSequence.Param() = %v, want %v", got, tt.want) } }) @@ -143,16 +143,16 @@ func TestCsiSequence_HasMore(t *testing.T) { i int want bool }{ - { - name: "no param", - s: CsiSequence{}, - i: 0, - want: false, - }, + // { + // name: "no param", + // s: CsiSequence{}, + // i: 0, + // want: false, + // }, { name: "has more", s: CsiSequence{ - Params: []int{1 | parser.HasMoreFlag, 2, 3}, + Params: []Parameter{1 | parser.HasMoreFlag, 2, 3}, }, i: 0, want: true, @@ -160,7 +160,7 @@ func TestCsiSequence_HasMore(t *testing.T) { { name: "no more", s: CsiSequence{ - Params: []int{1, 2, 3}, + Params: []Parameter{1, 2, 3}, }, i: 0, want: false, @@ -168,7 +168,7 @@ func TestCsiSequence_HasMore(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.s.HasMore(tt.i); got != tt.want { + if got := tt.s.Params[tt.i].HasMore(); got != tt.want { t.Errorf("CsiSequence.HasMore() = %v, want %v", got, tt.want) } }) @@ -189,91 +189,30 @@ func TestCsiSequence_Len(t *testing.T) { { name: "len", s: CsiSequence{ - Params: []int{1, 2, 3}, + Params: []Parameter{1, 2, 3}, }, want: 3, }, { name: "len with missing param", s: CsiSequence{ - Params: []int{1, parser.MissingParam, 3}, + Params: []Parameter{1, parser.MissingParam, 3}, }, want: 3, }, { name: "len with more flag", s: CsiSequence{ - Params: []int{1 | parser.HasMoreFlag, 2, 3}, + Params: []Parameter{1 | parser.HasMoreFlag, 2, 3}, }, - want: 2, + want: 3, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.s.Len(); got != tt.want { + if got := len(tt.s.Params); got != tt.want { t.Errorf("CsiSequence.Len() = %v, want %v", got, tt.want) } }) } } - -func TestCsiSequence_String(t *testing.T) { - tests := []struct { - name string - s CsiSequence - want string - }{ - { - name: "empty", - s: CsiSequence{Cmd: 'R'}, - want: "\x1b[R", - }, - { - name: "with data", - s: CsiSequence{ - Cmd: 'A', - Params: []int{1, 2, 3}, - }, - want: "\x1b[1;2;3A", - }, - { - name: "with more flag", - s: CsiSequence{ - Cmd: 'A', - Params: []int{1 | parser.HasMoreFlag, 2, 3}, - }, - want: "\x1b[1:2;3A", - }, - { - name: "with intermediate", - s: CsiSequence{ - Cmd: 'A' | '$'< | text ST // // See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-PC-Style-Function-Keys -// Deprecated: use [ReportNameVersion] instead. -const RequestXTVersion = "\x1b[>0q" +// Deprecated: use [RequestNameVersion] instead. +const RequestXTVersion = RequestNameVersion // PrimaryDeviceAttributes (DA1) is a control sequence that reports the // terminal's primary device attributes. // // CSI c // CSI 0 c +// CSI ? Ps ; ... c +// +// If no attributes are given, or if the attribute is 0, this function returns +// the request sequence. Otherwise, it returns the response sequence. // // See https://vt100.net/docs/vt510-rm/DA1.html -const ( - PrimaryDeviceAttributes = "\x1b[c" - DA1 = PrimaryDeviceAttributes -) +func PrimaryDeviceAttributes(attrs ...int) string { + if len(attrs) == 0 { + return "\x1b[c" + } else if len(attrs) == 1 && attrs[0] == 0 { + return "\x1b[0c" + } + + as := make([]string, len(attrs)) + for i, a := range attrs { + as[i] = strconv.Itoa(a) + } + return "\x1b[?" + strings.Join(as, ";") + "c" +} + +// DA1 is an alias for [PrimaryDeviceAttributes]. +func DA1(attrs ...int) string { + return PrimaryDeviceAttributes(attrs...) +} // RequestPrimaryDeviceAttributes is a control sequence that requests the // terminal's primary device attributes (DA1). @@ -40,5 +63,58 @@ const ( // CSI c // // See https://vt100.net/docs/vt510-rm/DA1.html -// Deprecated: use [PrimaryDeviceAttributes] instead. const RequestPrimaryDeviceAttributes = "\x1b[c" + +// SecondaryDeviceAttributes (DA2) is a control sequence that reports the +// terminal's secondary device attributes. +// +// CSI > c +// CSI > 0 c +// CSI > Ps ; ... c +// +// See https://vt100.net/docs/vt510-rm/DA2.html +func SecondaryDeviceAttributes(attrs ...int) string { + if len(attrs) == 0 { + return "\x1b[>c" + } + + as := make([]string, len(attrs)) + for i, a := range attrs { + as[i] = strconv.Itoa(a) + } + return "\x1b[>" + strings.Join(as, ";") + "c" +} + +// DA2 is an alias for [SecondaryDeviceAttributes]. +func DA2(attrs ...int) string { + return SecondaryDeviceAttributes(attrs...) +} + +// TertiaryDeviceAttributes (DA3) is a control sequence that reports the +// terminal's tertiary device attributes. +// +// CSI = c +// CSI = 0 c +// DCS ! | Text ST +// +// Where Text is the unit ID for the terminal. +// +// If no unit ID is given, or if the unit ID is 0, this function returns the +// request sequence. Otherwise, it returns the response sequence. +// +// See https://vt100.net/docs/vt510-rm/DA3.html +func TertiaryDeviceAttributes(unitID string) string { + switch unitID { + case "": + return "\x1b[=c" + case "0": + return "\x1b[=0c" + } + + return "\x1bP!|" + unitID + "\x1b\\" +} + +// DA3 is an alias for [TertiaryDeviceAttributes]. +func DA3(unitID string) string { + return TertiaryDeviceAttributes(unitID) +} diff --git a/ansi/cursor.go b/ansi/cursor.go index 6e944c25..321ee750 100644 --- a/ansi/cursor.go +++ b/ansi/cursor.go @@ -24,8 +24,8 @@ const ( DECRC = RestoreCursor ) -// CursorPositionReport (CPR) is an escape sequence that requests the current -// cursor position. +// RequestCursorPosition is an escape sequence that requests the current cursor +// position. // // CSI 6 n // @@ -36,12 +36,9 @@ const ( // // Where Pl is the line number and Pc is the column number. // See: https://vt100.net/docs/vt510-rm/CPR.html -const ( - CursorPositionReport = "\x1b[6n" - CPR = CursorPositionReport -) +const RequestCursorPosition = "\x1b[6n" -// ExtendedCursorPosition (DECXCPR) is a sequence for requesting the +// RequestExtendedCursorPosition (DECXCPR) is a sequence for requesting the // cursor position report including the current page number. // // CSI ? 6 n @@ -54,10 +51,7 @@ const ( // Where Pl is the line number, Pc is the column number, and Pp is the page // number. // See: https://vt100.net/docs/vt510-rm/DECXCPR.html -const ( - ExtendedCursorPosition = "\x1b[?6n" - DECXCPR = ExtendedCursorPosition -) +const RequestExtendedCursorPosition = "\x1b[?6n" // CursorUp (CUU) returns a sequence for moving the cursor up n cells. // @@ -568,3 +562,58 @@ func DECSCUSR(style int) string { func SetPointerShape(shape string) string { return "\x1b]22;" + shape + "\x07" } + +// ReverseIndex (RI) is an escape sequence for moving the cursor up one line in +// the same column. If the cursor is at the top margin, the screen scrolls +// down. +// +// This has the same effect as [RI]. +const ReverseIndex = "\x1bM" + +// HorizontalPositionAbsolute (HPA) returns a sequence for moving the cursor to +// the given column. This has the same effect as [CUP]. +// +// Default is 1. +// +// CSI n ` +// +// See: https://vt100.net/docs/vt510-rm/HPA.html +func HorizontalPositionAbsolute(col int) string { + var s string + if col > 0 { + s = strconv.Itoa(col) + } + return "\x1b[" + s + "`" +} + +// HPA is an alias for [HorizontalPositionAbsolute]. +func HPA(col int) string { + return HorizontalPositionAbsolute(col) +} + +// HorizontalPositionRelative (HPR) returns a sequence for moving the cursor +// right n columns relative to the current position. This has the same effect +// as [CUP]. +// +// Default is 1. +// +// CSI n a +// +// See: https://vt100.net/docs/vt510-rm/HPR.html +func HorizontalPositionRelative(n int) string { + var s string + if n > 0 { + s = strconv.Itoa(n) + } + return "\x1b[" + s + "a" +} + +// HPR is an alias for [HorizontalPositionRelative]. +func HPR(n int) string { + return HorizontalPositionRelative(n) +} + +// Index (IND) is an escape sequence for moving the cursor down one line in the +// same column. If the cursor is at the bottom margin, the screen scrolls up. +// This has the same effect as [IND]. +const Index = "\x1bD" diff --git a/ansi/dcs.go b/ansi/dcs.go index 185f0b52..03d5ebfc 100644 --- a/ansi/dcs.go +++ b/ansi/dcs.go @@ -3,8 +3,7 @@ package ansi import ( "bytes" "strconv" - - "github.com/charmbracelet/x/ansi/parser" + "strings" ) // DcsSequence represents a Device Control String (DCS) escape sequence. @@ -22,7 +21,7 @@ type DcsSequence struct { // This is a slice of integers, where each integer is a 32-bit integer // containing the parameter value in the lower 31 bits and a flag in the // most significant bit indicating whether there are more sub-parameters. - Params []int + Params []Parameter // Data contains the string raw data of the sequence. // This is the data between the final byte and the escape sequence terminator. @@ -38,17 +37,31 @@ type DcsSequence struct { // Is represented as: // // 'r' | '>' << 8 | '$' << 16 - Cmd int + Cmd Command } var _ Sequence = DcsSequence{} +// Clone returns a deep copy of the DCS sequence. +func (s DcsSequence) Clone() Sequence { + return DcsSequence{ + Params: append([]Parameter(nil), s.Params...), + Data: append([]byte(nil), s.Data...), + Cmd: s.Cmd, + } +} + +// Split returns a slice of data split by the semicolon. +func (s DcsSequence) Split() []string { + return strings.Split(string(s.Data), ";") +} + // Marker returns the marker byte of the DCS sequence. // This is always gonna be one of the following '<' '=' '>' '?' and in the // range of 0x3C-0x3F. // Zero is returned if the sequence does not have a marker. func (s DcsSequence) Marker() int { - return parser.Marker(s.Cmd) + return s.Cmd.Marker() } // Intermediate returns the intermediate byte of the DCS sequence. @@ -57,52 +70,22 @@ func (s DcsSequence) Marker() int { // ',', '-', '.', '/'. // Zero is returned if the sequence does not have an intermediate byte. func (s DcsSequence) Intermediate() int { - return parser.Intermediate(s.Cmd) + return s.Cmd.Intermediate() } // Command returns the command byte of the CSI sequence. func (s DcsSequence) Command() int { - return parser.Command(s.Cmd) -} - -// Param returns the parameter at the given index. -// It returns -1 if the parameter does not exist. -func (s DcsSequence) Param(i int) int { - return parser.Param(s.Params, i) -} - -// HasMore returns true if the parameter has more sub-parameters. -func (s DcsSequence) HasMore(i int) bool { - return parser.HasMore(s.Params, i) -} - -// Subparams returns the sub-parameters of the given parameter. -// It returns nil if the parameter does not exist. -func (s DcsSequence) Subparams(i int) []int { - return parser.Subparams(s.Params, i) -} - -// Len returns the number of parameters in the sequence. -// This will return the number of parameters in the sequence, excluding any -// sub-parameters. -func (s DcsSequence) Len() int { - return parser.Len(s.Params) -} - -// Range iterates over the parameters of the sequence and calls the given -// function for each parameter. -// The function should return false to stop the iteration. -func (s DcsSequence) Range(fn func(i int, param int, hasMore bool) bool) { - parser.Range(s.Params, fn) + return s.Cmd.Command() } -// Clone returns a copy of the DCS sequence. -func (s DcsSequence) Clone() Sequence { - return DcsSequence{ - Params: append([]int(nil), s.Params...), - Data: append([]byte(nil), s.Data...), - Cmd: s.Cmd, +// Param is a helper that returns the parameter at the given index and falls +// back to the default value if the parameter is missing. If the index is out +// of bounds, it returns the default value and false. +func (s DcsSequence) Param(i, def int) (int, bool) { + if i < 0 || i >= len(s.Params) { + return def, false } + return s.Params[i].Param(def), true } // String returns a string representation of the sequence. @@ -118,23 +101,25 @@ func (s DcsSequence) buffer() *bytes.Buffer { if m := s.Marker(); m != 0 { b.WriteByte(byte(m)) } - s.Range(func(i, param int, hasMore bool) bool { - if param >= -1 { + for i, p := range s.Params { + param := p.Param(-1) + if param >= 0 { b.WriteString(strconv.Itoa(param)) } if i < len(s.Params)-1 { - if hasMore { + if p.HasMore() { b.WriteByte(':') } else { b.WriteByte(';') } } - return true - }) + } if i := s.Intermediate(); i != 0 { b.WriteByte(byte(i)) } - b.WriteByte(byte(s.Command())) + if cmd := s.Command(); cmd != 0 { + b.WriteByte(byte(cmd)) + } b.Write(s.Data) b.WriteByte(ESC) b.WriteByte('\\') diff --git a/ansi/focus.go b/ansi/focus.go new file mode 100644 index 00000000..4e0207ce --- /dev/null +++ b/ansi/focus.go @@ -0,0 +1,9 @@ +package ansi + +// Focus is an escape sequence to notify the terminal that it has focus. +// This is used with [FocusEventMode]. +const Focus = "\x1b[I" + +// Blur is an escape sequence to notify the terminal that it has lost focus. +// This is used with [FocusEventMode]. +const Blur = "\x1b[O" diff --git a/ansi/mode.go b/ansi/mode.go index 6a0f71bc..2e77c738 100644 --- a/ansi/mode.go +++ b/ansi/mode.go @@ -5,6 +5,43 @@ import ( "strings" ) +// ModeSetting represents a mode setting. +type ModeSetting byte + +// ModeSetting constants. +const ( + ModeNotRecognized ModeSetting = iota + ModeSet + ModeReset + ModePermanentlySet + ModePermanentlyReset +) + +// IsNotRecognized returns true if the mode is not recognized. +func (m ModeSetting) IsNotRecognized() bool { + return m == ModeNotRecognized +} + +// IsSet returns true if the mode is set or permanently set. +func (m ModeSetting) IsSet() bool { + return m == ModeSet || m == ModePermanentlySet +} + +// IsReset returns true if the mode is reset or permanently reset. +func (m ModeSetting) IsReset() bool { + return m == ModeReset || m == ModePermanentlyReset +} + +// IsPermanentlySet returns true if the mode is permanently set. +func (m ModeSetting) IsPermanentlySet() bool { + return m == ModePermanentlySet +} + +// IsPermanentlyReset returns true if the mode is permanently reset. +func (m ModeSetting) IsPermanentlyReset() bool { + return m == ModePermanentlyReset +} + // Mode represents an interface for terminal modes. // Modes can be set, reset, and requested. type Mode interface { @@ -76,12 +113,10 @@ func setMode(reset bool, modes ...Mode) string { return seq + strconv.Itoa(modes[0].Mode()) + cmd } - var ( - dec bool - list []string - ) - for _, m := range modes { - list = append(list, strconv.Itoa(m.Mode())) + var dec bool + list := make([]string, len(modes)) + for i, m := range modes { + list[i] = strconv.Itoa(m.Mode()) switch m.(type) { case DECMode: dec = true @@ -141,18 +176,19 @@ func DECRQM(m Mode) string { // 4: Permanent reset // // See: https://vt100.net/docs/vt510-rm/DECRPM.html -func ReportMode(mode, value int) string { - if mode < 0 { - mode = 0 - } - if value < 0 { +func ReportMode(mode Mode, value ModeSetting) string { + if value > 4 { value = 0 } - return "\x1b[" + strconv.Itoa(mode) + ";" + strconv.Itoa(value) + "$y" + switch mode.(type) { + case DECMode: + return "\x1b[?" + strconv.Itoa(mode.Mode()) + ";" + strconv.Itoa(int(value)) + "$y" + } + return "\x1b[" + strconv.Itoa(mode.Mode()) + ";" + strconv.Itoa(int(value)) + "$y" } // DECRPM is an alias for [ReportMode]. -func DECRPM(mode, value int) string { +func DECRPM(mode Mode, value ModeSetting) string { return ReportMode(mode, value) } @@ -172,6 +208,75 @@ func (m DECMode) Mode() int { return int(m) } +// Keyboard Action Mode (KAM) is a mode that controls locking of the keyboard. +// When the keyboard is locked, it cannot send data to the terminal. +// +// See: https://vt100.net/docs/vt510-rm/KAM.html +const ( + KeyboardActionMode = ANSIMode(2) + KAM = KeyboardActionMode + + SetKeyboardActionMode = "\x1b[2h" + ResetKeyboardActionMode = "\x1b[2l" + RequestKeyboardActionMode = "\x1b[2$p" +) + +// Insert/Replace Mode (IRM) is a mode that determines whether characters are +// inserted or replaced when typed. +// +// When enabled, characters are inserted at the cursor position pushing the +// characters to the right. When disabled, characters replace the character at +// the cursor position. +// +// See: https://vt100.net/docs/vt510-rm/IRM.html +const ( + InsertReplaceMode = ANSIMode(4) + IRM = InsertReplaceMode + + SetInsertReplaceMode = "\x1b[4h" + ResetInsertReplaceMode = "\x1b[4l" + RequestInsertReplaceMode = "\x1b[4$p" +) + +// Send Receive Mode (SRM) or Local Echo Mode is a mode that determines whether +// the terminal echoes characters back to the host. When enabled, the terminal +// sends characters to the host as they are typed. +// +// See: https://vt100.net/docs/vt510-rm/SRM.html +const ( + SendReceiveMode = ANSIMode(12) + LocalEchoMode = SendReceiveMode + SRM = SendReceiveMode + + SetSendReceiveMode = "\x1b[12h" + ResetSendReceiveMode = "\x1b[12l" + RequestSendReceiveMode = "\x1b[12$p" + + SetLocalEchoMode = "\x1b[12h" + ResetLocalEchoMode = "\x1b[12l" + RequestLocalEchoMode = "\x1b[12$p" +) + +// Line Feed/New Line Mode (LNM) is a mode that determines whether the terminal +// interprets the line feed character as a new line. +// +// When enabled, the terminal interprets the line feed character as a new line. +// When disabled, the terminal interprets the line feed character as a line feed. +// +// A new line moves the cursor to the first position of the next line. +// A line feed moves the cursor down one line without changing the column +// scrolling the screen if necessary. +// +// See: https://vt100.net/docs/vt510-rm/LNM.html +const ( + LineFeedNewLineMode = ANSIMode(20) + LNM = LineFeedNewLineMode + + SetLineFeedNewLineMode = "\x1b[20h" + ResetLineFeedNewLineMode = "\x1b[20l" + RequestLineFeedNewLineMode = "\x1b[20$p" +) + // Cursor Keys Mode (DECCKM) is a mode that determines whether the cursor keys // send ANSI cursor sequences or application sequences. // @@ -197,23 +302,24 @@ const ( // See: https://vt100.net/docs/vt510-rm/DECOM.html const ( OriginMode = DECMode(6) + DECOM = OriginMode SetOriginMode = "\x1b[?6h" ResetOriginMode = "\x1b[?6l" RequestOriginMode = "\x1b[?6$p" ) -// Autowrap Mode (DECAWM) is a mode that determines whether the cursor wraps +// Auto Wrap Mode (DECAWM) is a mode that determines whether the cursor wraps // to the next line when it reaches the right margin. // // See: https://vt100.net/docs/vt510-rm/DECAWM.html const ( - AutowrapMode = DECMode(7) - DECAWM = AutowrapMode + AutoWrapMode = DECMode(7) + DECAWM = AutoWrapMode - SetAutowrapMode = "\x1b[?7h" - ResetAutowrapMode = "\x1b[?7l" - RequestAutowrapMode = "\x1b[?7$p" + SetAutoWrapMode = "\x1b[?7h" + ResetAutoWrapMode = "\x1b[?7l" + RequestAutoWrapMode = "\x1b[?7$p" ) // X10 Mouse Mode is a mode that determines whether the mouse reports on button @@ -256,6 +362,7 @@ const ( // Text Cursor Enable Mode (DECTCEM) is a mode that shows/hides the cursor. // // See: https://vt100.net/docs/vt510-rm/DECTCEM.html +// // Deprecated: use [SetTextCursorEnableMode] and [ResetTextCursorEnableMode] instead. const ( CursorEnableMode = DECMode(25) @@ -277,6 +384,32 @@ const ( RequestNumericKeypadMode = "\x1b[?66$p" ) +// Backarrow Key Mode (DECBKM) is a mode that determines whether the backspace +// key sends a backspace or delete character. Disabled by default. +// +// See: https://vt100.net/docs/vt510-rm/DECBKM.html +const ( + BackarrowKeyMode = DECMode(67) + DECBKM = BackarrowKeyMode + + SetBackarrowKeyMode = "\x1b[?67h" + ResetBackarrowKeyMode = "\x1b[?67l" + RequestBackarrowKeyMode = "\x1b[?67$p" +) + +// Left Right Margin Mode (DECLRMM) is a mode that determines whether the left +// and right margins can be set with [DECSLRM]. +// +// See: https://vt100.net/docs/vt510-rm/DECLRMM.html +const ( + LeftRightMarginMode = DECMode(69) + DECLRMM = LeftRightMarginMode + + SetLeftRightMarginMode = "\x1b[?69h" + ResetLeftRightMarginMode = "\x1b[?69l" + RequestLeftRightMarginMode = "\x1b[?69$p" +) + // Normal Mouse Mode is a mode that determines whether the mouse reports on // button presses and releases. It will also report modifier keys, wheel // events, and extra buttons. @@ -296,6 +429,7 @@ const ( // button press and release. // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking +// // Deprecated: use [NormalMouseMode] instead. const ( MouseMode = DECMode(1000) @@ -328,6 +462,7 @@ const ( // button presses, releases, and highlighted cells. // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking +// // Deprecated: use [HighlightMouseMode] instead. const ( MouseHiliteMode = DECMode(1001) @@ -353,6 +488,7 @@ const ( // reports on button press, release, and motion events. // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking +// // Deprecated: use [ButtonEventMouseMode] instead. const ( MouseCellMotionMode = DECMode(1002) @@ -378,6 +514,7 @@ const ( // button press, release, motion, and highlight events. // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking +// // Deprecated: use [AnyEventMouseMode] instead. const ( MouseAllMotionMode = DECMode(1003) @@ -414,7 +551,7 @@ const ( RequestReportFocus = "\x1b[?1004$p" ) -// Mouse SGR Extended Mode is a mode that changes the mouse tracking encoding +// SGR Extended Mouse Mode is a mode that changes the mouse tracking encoding // to use SGR parameters. // // The terminal responds with the following encoding: @@ -425,36 +562,111 @@ const ( // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking const ( - MouseSgrExtMode = DECMode(1006) + SgrExtMouseMode = DECMode(1006) SetSgrExtMouseMode = "\x1b[?1006h" ResetSgrExtMouseMode = "\x1b[?1006l" RequestSgrExtMouseMode = "\x1b[?1006$p" ) -// Deprecated: use [SetSgrExtMouseMode], [ResetSgrExtMouseMode], and -// [RequestSgrExtMouseMode] instead. +// Deprecated: use [SgrExtMouseMode] [SetSgrExtMouseMode], +// [ResetSgrExtMouseMode], and [RequestSgrExtMouseMode] instead. const ( + MouseSgrExtMode = DECMode(1006) EnableMouseSgrExt = "\x1b[?1006h" DisableMouseSgrExt = "\x1b[?1006l" RequestMouseSgrExt = "\x1b[?1006$p" ) +// UTF-8 Extended Mouse Mode is a mode that changes the mouse tracking encoding +// to use UTF-8 parameters. +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking +const ( + Utf8ExtMouseMode = DECMode(1005) + + SetUtf8ExtMouseMode = "\x1b[?1005h" + ResetUtf8ExtMouseMode = "\x1b[?1005l" + RequestUtf8ExtMouseMode = "\x1b[?1005$p" +) + +// URXVT Extended Mouse Mode is a mode that changes the mouse tracking encoding +// to use an alternate encoding. +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking +const ( + UrxvtExtMouseMode = DECMode(1015) + + SetUrxvtExtMouseMode = "\x1b[?1015h" + ResetUrxvtExtMouseMode = "\x1b[?1015l" + RequestUrxvtExtMouseMode = "\x1b[?1015$p" +) + +// SGR Pixel Extended Mouse Mode is a mode that changes the mouse tracking +// encoding to use SGR parameters with pixel coordinates. +// +// This is similar to [SgrExtMouseMode], but also reports pixel coordinates. +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking +const ( + SgrPixelExtMouseMode = DECMode(1016) + + SetSgrPixelExtMouseMode = "\x1b[?1016h" + ResetSgrPixelExtMouseMode = "\x1b[?1016l" + RequestSgrPixelExtMouseMode = "\x1b[?1016$p" +) + +// Alternate Screen Mode is a mode that determines whether the alternate screen +// buffer is active. When this mode is enabled, the alternate screen buffer is +// cleared. +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer +const ( + AltScreenMode = DECMode(1047) + + SetAltScreenMode = "\x1b[?1047h" + ResetAltScreenMode = "\x1b[?1047l" + RequestAltScreenMode = "\x1b[?1047$p" +) + +// Save Cursor Mode is a mode that saves the cursor position. +// This is equivalent to [SaveCursor] and [RestoreCursor]. +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer +const ( + SaveCursorMode = DECMode(1048) + + SetSaveCursorMode = "\x1b[?1048h" + ResetSaveCursorMode = "\x1b[?1048l" + RequestSaveCursorMode = "\x1b[?1048$p" +) + +// Alternate Screen Save Cursor Mode is a mode that saves the cursor position as in +// [SaveCursorMode], switches to the alternate screen buffer as in [AltScreenMode], +// and clears the screen on switch. +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer +const ( + AltScreenSaveCursorMode = DECMode(1049) + + SetAltScreenSaveCursorMode = "\x1b[?1049h" + ResetAltScreenSaveCursorMode = "\x1b[?1049l" + RequestAltScreenSaveCursorMode = "\x1b[?1049$p" +) + // Alternate Screen Buffer is a mode that determines whether the alternate screen // buffer is active. // // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer +// +// Deprecated: use [AltScreenSaveCursorMode] instead. const ( AltScreenBufferMode = DECMode(1049) SetAltScreenBufferMode = "\x1b[?1049h" ResetAltScreenBufferMode = "\x1b[?1049l" RequestAltScreenBufferMode = "\x1b[?1049$p" -) -// Deprecated: use [SetAltScreenBufferMode], [ResetAltScreenBufferMode], and -// [RequestAltScreenBufferMode] instead. -const ( EnableAltScreenBuffer = "\x1b[?1049h" DisableAltScreenBuffer = "\x1b[?1049l" RequestAltScreenBuffer = "\x1b[?1049$p" diff --git a/ansi/mouse.go b/ansi/mouse.go new file mode 100644 index 00000000..bae52b71 --- /dev/null +++ b/ansi/mouse.go @@ -0,0 +1,36 @@ +package ansi + +import ( + "fmt" +) + +// MouseX10 returns an escape sequence representing a mouse event in X10 mode. +// Note that this requires the terminal support X10 mouse modes. +// +// CSI M Cb Cx Cy +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking +func MouseX10(b byte, x, y int) string { + const x10Offset = 32 + return "\x1b[M" + string(b+x10Offset) + string(byte(x)+x10Offset+1) + string(byte(y)+x10Offset+1) +} + +// MouseSgr returns an escape sequence representing a mouse event in SGR mode. +// +// CSI < Cb ; Cx ; Cy M +// CSI < Cb ; Cx ; Cy m (release) +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#Mouse%20Tracking +func MouseSgr(b byte, x, y int, release bool) string { + s := "M" + if release { + s = "m" + } + if x < 0 { + x = -x + } + if y < 0 { + y = -y + } + return fmt.Sprintf("\x1b[<%d;%d;%d%s", b, x+1, y+1, s) +} diff --git a/ansi/osc.go b/ansi/osc.go index 40b543c2..25adff10 100644 --- a/ansi/osc.go +++ b/ansi/osc.go @@ -27,23 +27,24 @@ type OscSequence struct { var _ Sequence = OscSequence{} -// Command returns the command of the OSC sequence. -func (s OscSequence) Command() int { - return s.Cmd +// Clone returns a deep copy of the OSC sequence. +func (o OscSequence) Clone() Sequence { + return OscSequence{ + Data: append([]byte(nil), o.Data...), + Cmd: o.Cmd, + } } -// Params returns the parameters of the OSC sequence split by ';'. -// The first element is the identifier command. -func (s OscSequence) Params() []string { - return strings.Split(string(s.Data), ";") +// Split returns a slice of data split by the semicolon with the first element +// being the identifier command. +func (o OscSequence) Split() []string { + return strings.Split(string(o.Data), ";") } -// Clone returns a copy of the OSC sequence. -func (s OscSequence) Clone() Sequence { - return OscSequence{ - Data: append([]byte(nil), s.Data...), - Cmd: s.Cmd, - } +// Command returns the OSC command. This is always gonna be a positive integer +// that identifies the OSC sequence. +func (o OscSequence) Command() int { + return o.Cmd } // String returns the string representation of the OSC sequence. diff --git a/ansi/osc_test.go b/ansi/osc_test.go index a0c58657..e5285c86 100644 --- a/ansi/osc_test.go +++ b/ansi/osc_test.go @@ -1,6 +1,10 @@ package ansi -import "testing" +import ( + "strconv" + "strings" + "testing" +) func TestOscSequence_String(t *testing.T) { tests := []struct { @@ -24,10 +28,33 @@ func TestOscSequence_String(t *testing.T) { }, want: "\x1b]1;hello\x07", }, + { + name: "window name", + s: OscSequence{ + Cmd: 2, + Data: []byte("2;[No Name] - - NVIM"), + }, + want: "\x1b]2;[No Name] - - NVIM\a", + }, + { + name: "reset cursor color", + s: OscSequence{ + Cmd: 112, + Data: []byte("112"), + }, + want: "\x1b]112\a", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := tt.s.String(); got != tt.want { + parts := strings.Split(string(tt.s.Data), ";") + if len(parts) == 0 { + t.Errorf("OSC sequence data is empty") + } + if cmd, err := strconv.Atoi(parts[0]); err != nil || cmd != tt.s.Cmd { + t.Errorf("OSC sequence command is invalid") + } + if got := "\x1b]" + string(tt.s.Data) + "\x07"; got != tt.want { t.Errorf("OscSequence.String() = %q, want %q", got, tt.want) } }) diff --git a/ansi/parser.go b/ansi/parser.go index e1a09df7..618900cc 100644 --- a/ansi/parser.go +++ b/ansi/parser.go @@ -20,130 +20,200 @@ type ParserDispatcher func(Sequence) // //go:generate go run ./gen.go type Parser struct { - // Params contains the raw parameters of the sequence. + // the dispatch function to call when a sequence is complete + dispatcher ParserDispatcher + + // params contains the raw parameters of the sequence. // These parameters used when constructing CSI and DCS sequences. - Params []int + params []int - // Data contains the raw data of the sequence. + // data contains the raw data of the sequence. // These data used when constructing OSC, DCS, SOS, PM, and APC sequences. - Data []byte + data []byte - // DataLen keeps track of the length of the data buffer. - // If DataLen is -1, the data buffer is unlimited and will grow as needed. - // Otherwise, DataLen is limited by the size of the Data buffer. - DataLen int + // dataLen keeps track of the length of the data buffer. + // If dataLen is -1, the data buffer is unlimited and will grow as needed. + // Otherwise, dataLen is limited by the size of the data buffer. + dataLen int - // ParamsLen keeps track of the number of parameters. - // This is limited by the size of the Params buffer. + // paramsLen keeps track of the number of parameters. + // This is limited by the size of the params buffer. // // This is also used when collecting UTF-8 runes to keep track of the // number of rune bytes collected. - ParamsLen int + paramsLen int - // Cmd contains the raw command along with the private marker and + // cmd contains the raw command along with the private marker and // intermediate bytes of the sequence. // The first lower byte contains the command byte, the next byte contains // the private marker, and the next byte contains the intermediate byte. // // This is also used when collecting UTF-8 runes treating it as a slice of // 4 bytes. - Cmd int + cmd int + + // state is the current state of the parser. + state byte +} + +// NewParser returns a new parser with an optional [ParserDispatcher]. +// The [Parser] uses a default size of 32 for the parameters and 64KB for the +// data buffer. Use [Parser.SetParamsSize] and [Parser.SetDataSize] to set the +// size of the parameters and data buffer respectively. +func NewParser(d ParserDispatcher) *Parser { + p := new(Parser) + p.SetDispatcher(d) + p.SetParamsSize(parser.MaxParamsSize) + p.SetDataSize(1024 * 64) // 64KB data buffer + return p +} - // State is the current state of the parser. - State byte +// SetDispatcher sets the dispatcher function to call when a sequence is +// complete. +func (p *Parser) SetDispatcher(d ParserDispatcher) { + p.dispatcher = d } -// NewParser returns a new parser with the given sizes allocated. -// If dataSize is zero, the underlying data buffer will be unlimited and will +// SetParamsSize sets the size of the parameters buffer. +// This is used when constructing CSI and DCS sequences. +func (p *Parser) SetParamsSize(size int) { + p.params = make([]int, size) +} + +// SetDataSize sets the size of the data buffer. +// This is used when constructing OSC, DCS, SOS, PM, and APC sequences. +// If size is less than or equal to 0, the data buffer is unlimited and will // grow as needed. -func NewParser(paramsSize, dataSize int) *Parser { - s := new(Parser) - if dataSize <= 0 { - dataSize = 0 - s.DataLen = -1 +func (p *Parser) SetDataSize(size int) { + if size <= 0 { + size = 0 + p.dataLen = -1 } - s.Params = make([]int, paramsSize) - s.Data = make([]byte, dataSize) - return s + p.data = make([]byte, size) +} + +// Params returns the list of parsed packed parameters. +func (p *Parser) Params() []Parameter { + return unsafe.Slice((*Parameter)(unsafe.Pointer(&p.params[0])), p.paramsLen) +} + +// Param returns the parameter at the given index and falls back to the default +// value if the parameter is missing. If the index is out of bounds, it returns +// the default value and false. +func (p *Parser) Param(i, def int) (int, bool) { + if i < 0 || i >= p.paramsLen { + return def, false + } + return Parameter(p.params[i]).Param(def), true +} + +// Cmd returns the packed command of the last dispatched sequence. +func (p *Parser) Cmd() Command { + return Command(p.cmd) +} + +// Rune returns the last dispatched sequence as a rune. +func (p *Parser) Rune() rune { + rw := utf8ByteLen(byte(p.cmd & 0xff)) + if rw == -1 { + return utf8.RuneError + } + r, _ := utf8.DecodeRune((*[utf8.UTFMax]byte)(unsafe.Pointer(&p.cmd))[:rw]) + return r +} + +// Data returns the raw data of the last dispatched sequence. +func (p *Parser) Data() []byte { + return p.data[:p.dataLen] } // Reset resets the parser to its initial state. func (p *Parser) Reset() { p.clear() - p.State = parser.GroundState + p.state = parser.GroundState } // clear clears the parser parameters and command. func (p *Parser) clear() { - if len(p.Params) > 0 { - p.Params[0] = parser.MissingParam + if len(p.params) > 0 { + p.params[0] = parser.MissingParam } - p.ParamsLen = 0 - p.Cmd = 0 + p.paramsLen = 0 + p.cmd = 0 +} + +// State returns the current state of the parser. +func (p *Parser) State() parser.State { + return p.state } // StateName returns the name of the current state. func (p *Parser) StateName() string { - return parser.StateNames[p.State] + return parser.StateNames[p.state] } // Parse parses the given dispatcher and byte buffer. -func (p *Parser) Parse(dispatcher ParserDispatcher, b []byte) { +// Deprecated: Loop over the buffer and call [Parser.Advance] instead. +func (p *Parser) Parse(b []byte) { for i := 0; i < len(b); i++ { - p.Advance(dispatcher, b[i], i < len(b)-1) + p.Advance(b[i]) } } -// Advance advances the parser with the given dispatcher and byte. -func (p *Parser) Advance(dispatcher ParserDispatcher, b byte, more bool) parser.Action { - switch p.State { +// Advance advances the parser using the given byte. It returns the action +// performed by the parser. +func (p *Parser) Advance(b byte) parser.Action { + switch p.state { case parser.Utf8State: // We handle UTF-8 here. - return p.advanceUtf8(dispatcher, b) + return p.advanceUtf8(b) default: - return p.advance(dispatcher, b, more) + return p.advance(b) } } func (p *Parser) collectRune(b byte) { - if p.ParamsLen >= utf8.UTFMax { + if p.paramsLen >= utf8.UTFMax { return } - shift := p.ParamsLen * 8 - p.Cmd &^= 0xff << shift - p.Cmd |= int(b) << shift - p.ParamsLen++ + shift := p.paramsLen * 8 + p.cmd &^= 0xff << shift + p.cmd |= int(b) << shift + p.paramsLen++ +} + +func (p *Parser) dispatch(s Sequence) { + if p.dispatcher != nil { + p.dispatcher(s) + } } -func (p *Parser) advanceUtf8(dispatcher ParserDispatcher, b byte) parser.Action { +func (p *Parser) advanceUtf8(b byte) parser.Action { // Collect UTF-8 rune bytes. p.collectRune(b) - rw := utf8ByteLen(byte(p.Cmd & 0xff)) + rw := utf8ByteLen(byte(p.cmd & 0xff)) if rw == -1 { // We panic here because the first byte comes from the state machine, // if this panics, it means there is a bug in the state machine! panic("invalid rune") // unreachable } - if p.ParamsLen < rw { + if p.paramsLen < rw { return parser.CollectAction } // We have enough bytes to decode the rune using unsafe - r, _ := utf8.DecodeRune((*[utf8.UTFMax]byte)(unsafe.Pointer(&p.Cmd))[:rw]) - if dispatcher != nil { - dispatcher(Rune(r)) - } + p.dispatch(Rune(p.Rune())) - p.State = parser.GroundState - p.ParamsLen = 0 + p.state = parser.GroundState + p.paramsLen = 0 return parser.PrintAction } -func (p *Parser) advance(d ParserDispatcher, b byte, more bool) parser.Action { - state, action := parser.Table.Transition(p.State, b) +func (p *Parser) advance(b byte) parser.Action { + state, action := parser.Table.Transition(p.state, b) // We need to clear the parser state if the state changes from EscapeState. // This is because when we enter the EscapeState, we don't get a chance to @@ -151,59 +221,53 @@ func (p *Parser) advance(d ParserDispatcher, b byte, more bool) parser.Action { // ST (\x1b\\ or \x9c), we dispatch the current sequence and transition to // EscapeState. However, the parser state is not cleared in this case and // we need to clear it here before dispatching the esc sequence. - if p.State != state { - if p.State == parser.EscapeState { - p.performAction(d, parser.ClearAction, state, b) + if p.state != state { + if p.state == parser.EscapeState { + p.performAction(parser.ClearAction, state, b) } if action == parser.PutAction && - p.State == parser.DcsEntryState && state == parser.DcsStringState { + p.state == parser.DcsEntryState && state == parser.DcsStringState { // XXX: This is a special case where we need to start collecting // non-string parameterized data i.e. doesn't follow the ECMA-48 § // 5.4.1 string parameters format. - p.performAction(d, parser.StartAction, state, 0) + p.performAction(parser.StartAction, state, 0) } } // Handle special cases switch { - case b == ESC && p.State == parser.EscapeState: + case b == ESC && p.state == parser.EscapeState: // Two ESCs in a row - p.performAction(d, parser.ExecuteAction, state, b) - if !more { - // Two ESCs at the end of the buffer - p.performAction(d, parser.ExecuteAction, state, b) - } - case b == ESC && !more: - // Last byte is an ESC - p.performAction(d, parser.ExecuteAction, state, b) - case p.State == parser.EscapeState && b == 'P' && !more: - // ESC P (DCS) at the end of the buffer - p.performAction(d, parser.DispatchAction, state, b) - case p.State == parser.EscapeState && b == 'X' && !more: - // ESC X (SOS) at the end of the buffer - p.performAction(d, parser.DispatchAction, state, b) - case p.State == parser.EscapeState && b == '[' && !more: - // ESC [ (CSI) at the end of the buffer - p.performAction(d, parser.DispatchAction, state, b) - case p.State == parser.EscapeState && b == ']' && !more: - // ESC ] (OSC) at the end of the buffer - p.performAction(d, parser.DispatchAction, state, b) - case p.State == parser.EscapeState && b == '^' && !more: - // ESC ^ (PM) at the end of the buffer - p.performAction(d, parser.DispatchAction, state, b) - case p.State == parser.EscapeState && b == '_' && !more: - // ESC _ (APC) at the end of the buffer - p.performAction(d, parser.DispatchAction, state, b) + p.performAction(parser.ExecuteAction, state, b) default: - p.performAction(d, action, state, b) + p.performAction(action, state, b) } - p.State = state + p.state = state return action } -func (p *Parser) performAction(dispatcher ParserDispatcher, action parser.Action, state parser.State, b byte) { +func (p *Parser) parseStringCmd() { + // Try to parse the command + datalen := len(p.data) + if p.dataLen >= 0 { + datalen = p.dataLen + } + for i := 0; i < datalen; i++ { + d := p.data[i] + if d < '0' || d > '9' { + break + } + if p.cmd == parser.MissingCommand { + p.cmd = 0 + } + p.cmd *= 10 + p.cmd += int(d - '0') + } +} + +func (p *Parser) performAction(action parser.Action, state parser.State, b byte) { switch action { case parser.IgnoreAction: break @@ -212,131 +276,117 @@ func (p *Parser) performAction(dispatcher ParserDispatcher, action parser.Action p.clear() case parser.PrintAction: - if dispatcher != nil { - dispatcher(Rune(b)) - } + p.dispatch(Rune(b)) case parser.ExecuteAction: - if dispatcher != nil { - dispatcher(ControlCode(b)) - } + p.dispatch(ControlCode(b)) case parser.MarkerAction: // Collect private marker // we only store the last marker - p.Cmd &^= 0xff << parser.MarkerShift - p.Cmd |= int(b) << parser.MarkerShift + p.cmd &^= 0xff << parser.MarkerShift + p.cmd |= int(b) << parser.MarkerShift case parser.CollectAction: if state == parser.Utf8State { // Reset the UTF-8 counter - p.ParamsLen = 0 + p.paramsLen = 0 p.collectRune(b) } else { // Collect intermediate bytes // we only store the last intermediate byte - p.Cmd &^= 0xff << parser.IntermedShift - p.Cmd |= int(b) << parser.IntermedShift + p.cmd &^= 0xff << parser.IntermedShift + p.cmd |= int(b) << parser.IntermedShift } case parser.ParamAction: // Collect parameters - if p.ParamsLen >= len(p.Params) { + if p.paramsLen >= len(p.params) { break } if b >= '0' && b <= '9' { - if p.Params[p.ParamsLen] == parser.MissingParam { - p.Params[p.ParamsLen] = 0 + if p.params[p.paramsLen] == parser.MissingParam { + p.params[p.paramsLen] = 0 } - p.Params[p.ParamsLen] *= 10 - p.Params[p.ParamsLen] += int(b - '0') + p.params[p.paramsLen] *= 10 + p.params[p.paramsLen] += int(b - '0') } if b == ':' { - p.Params[p.ParamsLen] |= parser.HasMoreFlag + p.params[p.paramsLen] |= parser.HasMoreFlag } if b == ';' || b == ':' { - p.ParamsLen++ - if p.ParamsLen < len(p.Params) { - p.Params[p.ParamsLen] = parser.MissingParam + p.paramsLen++ + if p.paramsLen < len(p.params) { + p.params[p.paramsLen] = parser.MissingParam } } case parser.StartAction: - if p.DataLen < 0 && p.Data != nil { - p.Data = p.Data[:0] + if p.dataLen < 0 && p.data != nil { + p.data = p.data[:0] } else { - p.DataLen = 0 + p.dataLen = 0 } - if p.State >= parser.DcsEntryState && p.State <= parser.DcsStringState { + if p.state >= parser.DcsEntryState && p.state <= parser.DcsStringState { // Collect the command byte for DCS - p.Cmd |= int(b) + p.cmd |= int(b) } else { - p.Cmd = parser.MissingCommand + p.cmd = parser.MissingCommand } case parser.PutAction: - switch p.State { + switch p.state { case parser.OscStringState: - if b == ';' && p.Cmd == parser.MissingCommand { - // Try to parse the command - datalen := len(p.Data) - if p.DataLen >= 0 { - datalen = p.DataLen - } - for i := 0; i < datalen; i++ { - d := p.Data[i] - if d < '0' || d > '9' { - break - } - if p.Cmd == parser.MissingCommand { - p.Cmd = 0 - } - p.Cmd *= 10 - p.Cmd += int(d - '0') - } + if b == ';' && p.cmd == parser.MissingCommand { + p.parseStringCmd() } } - if p.DataLen < 0 { - p.Data = append(p.Data, b) + if p.dataLen < 0 { + p.data = append(p.data, b) } else { - if p.DataLen < len(p.Data) { - p.Data[p.DataLen] = b - p.DataLen++ + if p.dataLen < len(p.data) { + p.data[p.dataLen] = b + p.dataLen++ } } case parser.DispatchAction: // Increment the last parameter - if p.ParamsLen > 0 && p.ParamsLen < len(p.Params)-1 || - p.ParamsLen == 0 && len(p.Params) > 0 && p.Params[0] != parser.MissingParam { - p.ParamsLen++ + if p.paramsLen > 0 && p.paramsLen < len(p.params)-1 || + p.paramsLen == 0 && len(p.params) > 0 && p.params[0] != parser.MissingParam { + p.paramsLen++ + } + + if p.state == parser.OscStringState && p.cmd == parser.MissingCommand { + // Ensure we have a command for OSC + p.parseStringCmd() } - if dispatcher == nil { + if p.dispatcher == nil { break } var seq Sequence - data := p.Data - if p.DataLen >= 0 { - data = data[:p.DataLen] + data := p.data + if p.dataLen >= 0 { + data = data[:p.dataLen] } - switch p.State { + switch p.state { case parser.CsiEntryState, parser.CsiParamState, parser.CsiIntermediateState: - p.Cmd |= int(b) - seq = CsiSequence{Cmd: p.Cmd, Params: p.Params[:p.ParamsLen]} + p.cmd |= int(b) + seq = CsiSequence{Cmd: Command(p.cmd), Params: p.Params()} case parser.EscapeState, parser.EscapeIntermediateState: - p.Cmd |= int(b) - seq = EscSequence(p.Cmd) + p.cmd |= int(b) + seq = EscSequence(p.cmd) case parser.DcsEntryState, parser.DcsParamState, parser.DcsIntermediateState, parser.DcsStringState: - seq = DcsSequence{Cmd: p.Cmd, Params: p.Params[:p.ParamsLen], Data: data} + seq = DcsSequence{Cmd: Command(p.cmd), Params: p.Params(), Data: data} case parser.OscStringState: - seq = OscSequence{Cmd: p.Cmd, Data: data} + seq = OscSequence{Cmd: p.cmd, Data: data} case parser.SosStringState: seq = SosSequence{Data: data} case parser.PmStringState: @@ -345,7 +395,7 @@ func (p *Parser) performAction(dispatcher ParserDispatcher, action parser.Action seq = ApcSequence{Data: data} } - dispatcher(seq) + p.dispatch(seq) } } diff --git a/ansi/parser_apc_test.go b/ansi/parser_apc_test.go index 7c715e86..cef58bc1 100644 --- a/ansi/parser_apc_test.go +++ b/ansi/parser_apc_test.go @@ -20,7 +20,7 @@ func TestSosPmApcSequence(t *testing.T) { t.Run(c.name, func(t *testing.T) { dispatcher := &testDispatcher{} parser := testParser(dispatcher) - parser.Parse(dispatcher.Dispatch, []byte(c.input)) + parser.Parse([]byte(c.input)) assertEqual(t, len(c.expected), len(dispatcher.dispatched)) assertEqual(t, c.expected, dispatcher.dispatched) }) diff --git a/ansi/parser_csi_test.go b/ansi/parser_csi_test.go index 070a3258..8cbd09cd 100644 --- a/ansi/parser_csi_test.go +++ b/ansi/parser_csi_test.go @@ -24,7 +24,7 @@ func TestCsiSequence(t *testing.T) { input: "\x1b[7m", expected: []Sequence{ CsiSequence{ - Params: []int{7}, + Params: []Parameter{7}, Cmd: 'm', }, }, @@ -34,14 +34,14 @@ func TestCsiSequence(t *testing.T) { input: "\x1b[0mabc\x1b[1;2m", expected: []Sequence{ CsiSequence{ - Params: []int{0}, + Params: []Parameter{0}, Cmd: 'm', }, Rune('a'), Rune('b'), Rune('c'), CsiSequence{ - Params: []int{1, 2}, + Params: []Parameter{1, 2}, Cmd: 'm', }, }, @@ -51,7 +51,7 @@ func TestCsiSequence(t *testing.T) { input: "\x1b[" + strings.Repeat("1;", 31) + "p", expected: []Sequence{ CsiSequence{ - Params: []int{ + Params: []Parameter{ 1, 1, 1, @@ -78,7 +78,7 @@ func TestCsiSequence(t *testing.T) { input: "\x1b[" + strings.Repeat("1;", 18) + "p", expected: []Sequence{ CsiSequence{ - Params: []int{ + Params: []Parameter{ 1, 1, 1, @@ -105,7 +105,7 @@ func TestCsiSequence(t *testing.T) { input: "\x1b[4;m", expected: []Sequence{ CsiSequence{ - Params: []int{4, parser.MissingParam}, + Params: []Parameter{4, parser.MissingParam}, Cmd: 'm', }, }, @@ -115,7 +115,7 @@ func TestCsiSequence(t *testing.T) { input: "\x1b[;4m", expected: []Sequence{ CsiSequence{ - Params: []int{parser.MissingParam, 4}, + Params: []Parameter{parser.MissingParam, 4}, Cmd: 'm', }, }, @@ -125,7 +125,7 @@ func TestCsiSequence(t *testing.T) { input: "\x1b[" + strconv.Itoa(parser.MaxParam) + "m", expected: []Sequence{ CsiSequence{ - Params: []int{parser.MaxParam}, + Params: []Parameter{parser.MaxParam}, Cmd: 'm', }, }, @@ -135,7 +135,7 @@ func TestCsiSequence(t *testing.T) { input: "\x1b[3;1\x1b[?1049h", expected: []Sequence{ CsiSequence{ - Params: []int{1049}, + Params: []Parameter{1049}, Cmd: 'h' | '?'< 0 { - p.Params[0] = parser.MissingParam + if len(p.params) > 0 { + p.params[0] = parser.MissingParam } - p.Cmd = 0 - p.ParamsLen = 0 - p.DataLen = 0 + p.cmd = 0 + p.paramsLen = 0 + p.dataLen = 0 } state = EscapeState continue case CSI, DCS: if p != nil { - if len(p.Params) > 0 { - p.Params[0] = parser.MissingParam + if len(p.params) > 0 { + p.params[0] = parser.MissingParam } - p.Cmd = 0 - p.ParamsLen = 0 - p.DataLen = 0 + p.cmd = 0 + p.paramsLen = 0 + p.dataLen = 0 } state = MarkerState continue case OSC, APC, SOS, PM: if p != nil { - p.Cmd = parser.MissingCommand - p.DataLen = 0 + p.cmd = parser.MissingCommand + p.dataLen = 0 } state = StringState continue } if p != nil { - p.DataLen = 0 - p.ParamsLen = 0 - p.Cmd = 0 + p.dataLen = 0 + p.paramsLen = 0 + p.cmd = 0 } if c > US && c < DEL { // ASCII printable characters @@ -132,8 +130,8 @@ func DecodeSequence[T string | []byte](b T, state byte, p *Parser) (seq T, width if c >= '<' && c <= '?' { if p != nil { // We only collect the last marker character. - p.Cmd &^= 0xff << parser.MarkerShift - p.Cmd |= int(c) << parser.MarkerShift + p.cmd &^= 0xff << parser.MarkerShift + p.cmd |= int(c) << parser.MarkerShift } break } @@ -143,27 +141,27 @@ func DecodeSequence[T string | []byte](b T, state byte, p *Parser) (seq T, width case ParamsState: if c >= '0' && c <= '9' { if p != nil { - if p.Params[p.ParamsLen] == parser.MissingParam { - p.Params[p.ParamsLen] = 0 + if p.params[p.paramsLen] == parser.MissingParam { + p.params[p.paramsLen] = 0 } - p.Params[p.ParamsLen] *= 10 - p.Params[p.ParamsLen] += int(c - '0') + p.params[p.paramsLen] *= 10 + p.params[p.paramsLen] += int(c - '0') } break } if c == ':' { if p != nil { - p.Params[p.ParamsLen] |= parser.HasMoreFlag + p.params[p.paramsLen] |= parser.HasMoreFlag } } if c == ';' || c == ':' { if p != nil { - p.ParamsLen++ - if p.ParamsLen < len(p.Params) { - p.Params[p.ParamsLen] = parser.MissingParam + p.paramsLen++ + if p.paramsLen < len(p.params) { + p.params[p.paramsLen] = parser.MissingParam } } break @@ -174,35 +172,36 @@ func DecodeSequence[T string | []byte](b T, state byte, p *Parser) (seq T, width case IntermedState: if c >= ' ' && c <= '/' { if p != nil { - p.Cmd &^= 0xff << parser.IntermedShift - p.Cmd |= int(c) << parser.IntermedShift + p.cmd &^= 0xff << parser.IntermedShift + p.cmd |= int(c) << parser.IntermedShift } break } - state = NormalState + if p != nil { + // Increment the last parameter + if p.paramsLen > 0 && p.paramsLen < len(p.params)-1 || + p.paramsLen == 0 && len(p.params) > 0 && p.params[0] != parser.MissingParam { + p.paramsLen++ + } + } + if c >= '@' && c <= '~' { if p != nil { - // Increment the last parameter - if p.ParamsLen > 0 && p.ParamsLen < len(p.Params)-1 || - p.ParamsLen == 0 && len(p.Params) > 0 && p.Params[0] != parser.MissingParam { - p.ParamsLen++ - } - - p.Cmd &^= 0xff - p.Cmd |= int(c) + p.cmd &^= 0xff + p.cmd |= int(c) } if HasDcsPrefix(b) { // Continue to collect DCS data if p != nil { - p.DataLen = 0 + p.dataLen = 0 } state = StringState continue } - return b[:i+1], 0, i + 1, state + return b[:i+1], 0, i + 1, NormalState } // Invalid CSI/DCS sequence @@ -211,18 +210,18 @@ func DecodeSequence[T string | []byte](b T, state byte, p *Parser) (seq T, width switch c { case '[', 'P': if p != nil { - if len(p.Params) > 0 { - p.Params[0] = parser.MissingParam + if len(p.params) > 0 { + p.params[0] = parser.MissingParam } - p.ParamsLen = 0 - p.Cmd = 0 + p.paramsLen = 0 + p.cmd = 0 } state = MarkerState continue case ']', 'X', '^', '_': if p != nil { - p.Cmd = parser.MissingCommand - p.DataLen = 0 + p.cmd = parser.MissingCommand + p.dataLen = 0 } state = StringState continue @@ -230,14 +229,14 @@ func DecodeSequence[T string | []byte](b T, state byte, p *Parser) (seq T, width if c >= ' ' && c <= '/' { if p != nil { - p.Cmd &^= 0xff << parser.IntermedShift - p.Cmd |= int(c) << parser.IntermedShift + p.cmd &^= 0xff << parser.IntermedShift + p.cmd |= int(c) << parser.IntermedShift } continue } else if c >= '0' && c <= '~' { if p != nil { - p.Cmd &^= 0xff - p.Cmd |= int(c) + p.cmd &^= 0xff + p.cmd |= int(c) } return b[:i+1], 0, i + 1, NormalState } @@ -281,9 +280,9 @@ func DecodeSequence[T string | []byte](b T, state byte, p *Parser) (seq T, width return b[:i], 0, i, NormalState } - if p != nil && p.DataLen < len(p.Data) { - p.Data[p.DataLen] = c - p.DataLen++ + if p != nil && p.dataLen < len(p.data) { + p.data[p.dataLen] = c + p.dataLen++ // Parse the OSC command number if c == ';' && HasOscPrefix(b) { @@ -297,34 +296,22 @@ func DecodeSequence[T string | []byte](b T, state byte, p *Parser) (seq T, width } func parseOscCmd(p *Parser) { - if p == nil || p.Cmd != parser.MissingCommand { + if p == nil || p.cmd != parser.MissingCommand { return } - for j := 0; j < p.DataLen; j++ { - d := p.Data[j] + for j := 0; j < p.dataLen; j++ { + d := p.data[j] if d < '0' || d > '9' { break } - if p.Cmd == parser.MissingCommand { - p.Cmd = 0 + if p.cmd == parser.MissingCommand { + p.cmd = 0 } - p.Cmd *= 10 - p.Cmd += int(d - '0') + p.cmd *= 10 + p.cmd += int(d - '0') } } -// Index returns the index of the first occurrence of the given byte slice in -// the data. It returns -1 if the byte slice is not found. -func Index[T string | []byte](data, b T) int { - switch data := any(data).(type) { - case string: - return strings.Index(data, string(b)) - case []byte: - return bytes.Index(data, []byte(b)) - } - panic("unreachable") -} - // Equal returns true if the given byte slices are equal. func Equal[T string | []byte](a, b T) bool { return string(a) == string(b) @@ -402,49 +389,73 @@ func FirstGraphemeCluster[T string | []byte](b T, state int) (T, T, int, int) { panic("unreachable") } -// Cmd represents a sequence command. This is used to pack/unpack a sequence +// Command represents a sequence command. This is used to pack/unpack a sequence // command with its intermediate and marker characters. Those are commonly // found in CSI and DCS sequences. -type Cmd int +type Command int -// Marker returns the marker byte of the CSI sequence. +// Marker returns the unpacked marker byte of the CSI sequence. // This is always gonna be one of the following '<' '=' '>' '?' and in the // range of 0x3C-0x3F. // Zero is returned if the sequence does not have a marker. -func (c Cmd) Marker() int { +func (c Command) Marker() int { return parser.Marker(int(c)) } -// Intermediate returns the intermediate byte of the CSI sequence. +// Intermediate returns the unpacked intermediate byte of the CSI sequence. // An intermediate byte is in the range of 0x20-0x2F. This includes these // characters from ' ', '!', '"', '#', '$', '%', '&', ”', '(', ')', '*', '+', // ',', '-', '.', '/'. // Zero is returned if the sequence does not have an intermediate byte. -func (c Cmd) Intermediate() int { +func (c Command) Intermediate() int { return parser.Intermediate(int(c)) } -// Command returns the command byte of the CSI sequence. -func (c Cmd) Command() int { +// Command returns the unpacked command byte of the CSI sequence. +func (c Command) Command() int { return parser.Command(int(c)) } -// Param represents a sequence parameter. Sequence parameters with +// Cmd returns a packed [Command] with the given command, marker, and +// intermediate. +// The first byte is the command, the next shift is the marker, and the next +// shift is the intermediate. +// +// Even though this function takes integers, it only uses the lower 8 bits of +// each integer. +func Cmd(marker, inter, cmd int) (c Command) { + c = Command(cmd & parser.CommandMask) + c |= Command(marker&parser.CommandMask) << parser.MarkerShift + c |= Command(inter&parser.CommandMask) << parser.IntermedShift + return +} + +// Parameter represents a sequence parameter. Sequence parameters with // sub-parameters are packed with the HasMoreFlag set. This is used to unpack // the parameters from a CSI and DCS sequences. -type Param int +type Parameter int -// Param returns the parameter at the given index. -// It returns -1 if the parameter does not exist. -func (s Param) Param() int { +// Param returns the unpacked parameter at the given index. +// It returns the default value if the parameter is missing. +func (s Parameter) Param(def int) int { p := int(s) & parser.ParamMask if p == parser.MissingParam { - return -1 + return def } return p } -// HasMore returns true if the parameter has more sub-parameters. -func (s Param) HasMore() bool { - return int(s)&parser.HasMoreFlag != 0 +// HasMore unpacks the HasMoreFlag from the parameter. +func (s Parameter) HasMore() bool { + return s&parser.HasMoreFlag != 0 +} + +// Param returns a packed [Parameter] with the given parameter and whether this +// parameter has following sub-parameters. +func Param(p int, hasMore bool) (s Parameter) { + s = Parameter(p & parser.ParamMask) + if hasMore { + s |= Parameter(parser.HasMoreFlag) + } + return } diff --git a/ansi/parser_decode_test.go b/ansi/parser_decode_test.go index 5bd3eefd..67ea137a 100644 --- a/ansi/parser_decode_test.go +++ b/ansi/parser_decode_test.go @@ -241,7 +241,7 @@ func TestDecodeSequence(t *testing.T) { name: "unterminated CSI with escape sequence", input: []byte("\x1b[1;2;3\x1bOa"), expected: []expectedSequence{ - {seq: []byte("\x1b[1;2;3"), n: 7, params: []int{1, 2}}, // params get reset and ignored when unterminated + {seq: []byte("\x1b[1;2;3"), n: 7, params: []int{1, 2, 3}}, // params get reset and ignored when unterminated {seq: []byte("\x1bO"), n: 2, cmd: 'O'}, {seq: []byte{'a'}, n: 1, width: 1}, }, @@ -306,16 +306,18 @@ func TestDecodeSequence(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - p := NewParser(32, 1024) + p := NewParser(nil) + p.SetParamsSize(32) + p.SetDataSize(1024) var state byte input := tc.input results := make([]expectedSequence, 0) for len(input) > 0 { seq, width, n, newState := DecodeSequence(input, state, p) - params := append([]int(nil), p.Params[:p.ParamsLen]...) - data := append([]byte(nil), p.Data[:p.DataLen]...) - results = append(results, expectedSequence{seq: seq, width: width, n: n, params: params, data: data, cmd: p.Cmd}) + params := append([]int(nil), p.params[:p.paramsLen]...) + data := append([]byte(nil), p.data[:p.dataLen]...) + results = append(results, expectedSequence{seq: seq, width: width, n: n, params: params, data: data, cmd: p.cmd}) state = newState input = input[n:] } @@ -339,7 +341,7 @@ func TestDecodeSequence(t *testing.T) { t.Errorf("expected %q data, got %q", string(tc.expected[i].data), string(r.data)) } if len(r.params) != len(tc.expected[i].params) { - t.Errorf("expected %d params, got %d", len(tc.expected[i].params), len(r.params)) + t.Fatalf("expected %d params, got %d", len(tc.expected[i].params), len(r.params)) } if len(tc.expected[i].params) > 0 { for j, p := range r.params { @@ -388,7 +390,9 @@ func BenchmarkDecodeSequence(b *testing.B) { var state byte var n int input := []byte("\x1b[1;2;3màbc\x90?123;456+q\x9c\x7f ") - p := NewParser(32, 1024) + p := NewParser(nil) + p.SetParamsSize(32) + p.SetDataSize(1024) b.ReportAllocs() for i := 0; i < b.N; i++ { in := input @@ -400,11 +404,107 @@ func BenchmarkDecodeSequence(b *testing.B) { } func BenchmarkDecodeParser(b *testing.B) { - p := NewParser(32, 1024) + p := NewParser(nil) + p.SetParamsSize(32) + p.SetDataSize(1024) input := []byte("\x1b[1;2;3màbc\x90?123;456+q\x9c\x7f ") b.ReportAllocs() for i := 0; i < b.N; i++ { - p.Parse(func(s Sequence) { - }, input) + p.Parse(input) + } +} + +func TestCommand(t *testing.T) { + cases := []struct { + name string + cmd int + mark int + inter int + expected Command + }{ + { + name: "CUU", // Cursor Up + cmd: 'A', + expected: 'A', + }, + { + name: "DECAWM", // Auto Wrap Mode + cmd: 'h', + mark: '?', + expected: 'h' | '?'<', + inter: '(', + expected: 'x' | '>'<', + inter: 256 + '(', + expected: 'x' | '>'< 0 { + l = strconv.Itoa(left) + } + if right > 0 { + r = strconv.Itoa(right) + } + return "\x1b[" + l + ";" + r + "s" +} + +// DECSLRM is an alias for [SetLeftRightMargins]. +func DECSLRM(left, right int) string { + return SetLeftRightMargins(left, right) +} + // SetScrollingRegion (DECSTBM) sets the top and bottom margins for the scrolling // region. The default is the entire screen. // @@ -250,3 +277,133 @@ const ( SetTabEvery8Columns = "\x1b[?5W" DECST8C = SetTabEvery8Columns ) + +// HorizontalTabSet (HTS) sets a horizontal tab stop at the current cursor +// column. +// +// This is equivalent to [HTS]. +// +// ESC H +// +// See: https://vt100.net/docs/vt510-rm/HTS.html +const HorizontalTabSet = "\x1bH" + +// TabClear (TBC) clears tab stops. +// +// Default is 0. +// +// Possible values: +// 0: Clear tab stop at the current column. (default) +// 3: Clear all tab stops. +// +// CSI Pn g +// +// See: https://vt100.net/docs/vt510-rm/TBC.html +func TabClear(n int) string { + var s string + if n > 0 { + s = strconv.Itoa(n) + } + return "\x1b[" + s + "g" +} + +// TBC is an alias for [TabClear]. +func TBC(n int) string { + return TabClear(n) +} + +// RequestPresentationStateReport (DECRQPSR) requests the terminal to send a +// report of the presentation state. This includes the cursor information [DECCIR], +// and tab stop [DECTABSR] reports. +// +// Default is 0. +// +// Possible values: +// 0: Error, request ignored. +// 1: Cursor information report [DECCIR]. +// 2: Tab stop report [DECTABSR]. +// +// CSI Ps $ w +// +// See: https://vt100.net/docs/vt510-rm/DECRQPSR.html +func RequestPresentationStateReport(n int) string { + var s string + if n > 0 { + s = strconv.Itoa(n) + } + return "\x1b[" + s + "$w" +} + +// DECRQPSR is an alias for [RequestPresentationStateReport]. +func DECRQPSR(n int) string { + return RequestPresentationStateReport(n) +} + +// TabStopReport (DECTABSR) is the response to a tab stop report request. +// It reports the tab stops set in the terminal. +// +// The response is a list of tab stops separated by a slash (/) character. +// +// DCS 2 $ u D ... D ST +// +// Where D is a decimal number representing a tab stop. +// +// See: https://vt100.net/docs/vt510-rm/DECTABSR.html +func TabStopReport(stops ...int) string { + var s []string + for _, v := range stops { + s = append(s, strconv.Itoa(v)) + } + return "\x1bP2$u" + strings.Join(s, "/") + "\x1b\\" +} + +// DECTABSR is an alias for [TabStopReport]. +func DECTABSR(stops ...int) string { + return TabStopReport(stops...) +} + +// CursorInformationReport (DECCIR) is the response to a cursor information +// report request. It reports the cursor position, visual attributes, and +// character protection attributes. It also reports the status of origin mode +// [DECOM] and the current active character set. +// +// The response is a list of values separated by a semicolon (;) character. +// +// DCS 1 $ u D ... D ST +// +// Where D is a decimal number representing a value. +// +// See: https://vt100.net/docs/vt510-rm/DECCIR.html +func CursorInformationReport(values ...int) string { + var s []string + for _, v := range values { + s = append(s, strconv.Itoa(v)) + } + return "\x1bP1$u" + strings.Join(s, ";") + "\x1b\\" +} + +// DECCIR is an alias for [CursorInformationReport]. +func DECCIR(values ...int) string { + return CursorInformationReport(values...) +} + +// RepeatPreviousCharacter (REP) repeats the previous character n times. +// This is identical to typing the same character n times. +// +// Default is 1. +// +// CSI Pn b +// +// See: ECMA-48 § 8.3.103 +func RepeatPreviousCharacter(n int) string { + var s string + if n > 1 { + s = strconv.Itoa(n) + } + return "\x1b[" + s + "b" +} + +// REP is an alias for [RepeatPreviousCharacter]. +func REP(n int) string { + return RepeatPreviousCharacter(n) +} diff --git a/ansi/sequence.go b/ansi/sequence.go index f294a229..4e974d1e 100644 --- a/ansi/sequence.go +++ b/ansi/sequence.go @@ -8,12 +8,19 @@ import ( // Sequence represents an ANSI sequence. This can be a control sequence, escape // sequence, a printable character, etc. +// A Sequence can be one of the following types: +// - [Rune] +// - [ControlCode] +// - [Grapheme] +// - [EscSequence] +// - [CsiSequence] +// - [OscSequence] +// - [DcsSequence] +// - [SosSequence] +// - [PmSequence] +// - [ApcSequence] type Sequence interface { - // String returns the string representation of the sequence. - String() string - // Bytes returns the byte representation of the sequence. - Bytes() []byte - // Clone returns a copy of the sequence. + // Clone returns a deep copy of the sequence. Clone() Sequence } @@ -22,19 +29,22 @@ type Rune rune var _ Sequence = Rune(0) -// Bytes implements Sequence. -func (r Rune) Bytes() []byte { - return []byte(string(r)) +// Clone returns a deep copy of the rune. +func (r Rune) Clone() Sequence { + return r } -// String implements Sequence. -func (r Rune) String() string { - return string(r) +// Grapheme represents a grapheme cluster. +type Grapheme struct { + Cluster string + Width int } -// Clone implements Sequence. -func (r Rune) Clone() Sequence { - return r +var _ Sequence = Grapheme{} + +// Clone returns a deep copy of the grapheme. +func (g Grapheme) Clone() Sequence { + return g } // ControlCode represents a control code character. This is a character that @@ -54,13 +64,13 @@ func (c ControlCode) String() string { return string(c) } -// Clone implements Sequence. +// Clone returns a deep copy of the control code. func (c ControlCode) Clone() Sequence { return c } // EscSequence represents an escape sequence. -type EscSequence int +type EscSequence Command var _ Sequence = EscSequence(0) @@ -71,7 +81,9 @@ func (e EscSequence) buffer() *bytes.Buffer { if i := parser.Intermediate(int(e)); i != 0 { b.WriteByte(byte(i)) } - b.WriteByte(byte(e.Command())) + if cmd := e.Command(); cmd != 0 { + b.WriteByte(byte(cmd)) + } return &b } @@ -85,19 +97,19 @@ func (e EscSequence) String() string { return e.buffer().String() } -// Clone implements Sequence. +// Clone returns a deep copy of the escape sequence. func (e EscSequence) Clone() Sequence { return e } // Command returns the command byte of the escape sequence. func (e EscSequence) Command() int { - return parser.Command(int(e)) + return Command(e).Command() } // Intermediate returns the intermediate byte of the escape sequence. func (e EscSequence) Intermediate() int { - return parser.Intermediate(int(e)) + return Command(e).Intermediate() } // SosSequence represents a SOS sequence. @@ -106,12 +118,7 @@ type SosSequence struct { Data []byte } -var _ Sequence = &SosSequence{} - -// Clone implements Sequence. -func (s SosSequence) Clone() Sequence { - return SosSequence{Data: append([]byte(nil), s.Data...)} -} +var _ Sequence = SosSequence{} // Bytes implements Sequence. func (s SosSequence) Bytes() []byte { @@ -132,18 +139,20 @@ func (s SosSequence) buffer() *bytes.Buffer { return &b } +// Clone returns a deep copy of the SOS sequence. +func (s SosSequence) Clone() Sequence { + return SosSequence{ + Data: append([]byte(nil), s.Data...), + } +} + // PmSequence represents a PM sequence. type PmSequence struct { // Data contains the raw data of the sequence. Data []byte } -var _ Sequence = &PmSequence{} - -// Clone implements Sequence. -func (s PmSequence) Clone() Sequence { - return PmSequence{Data: append([]byte(nil), s.Data...)} -} +var _ Sequence = PmSequence{} // Bytes implements Sequence. func (s PmSequence) Bytes() []byte { @@ -165,17 +174,26 @@ func (s PmSequence) buffer() *bytes.Buffer { return &b } +// Clone returns a deep copy of the PM sequence. +func (p PmSequence) Clone() Sequence { + return PmSequence{ + Data: append([]byte(nil), p.Data...), + } +} + // ApcSequence represents an APC sequence. type ApcSequence struct { // Data contains the raw data of the sequence. Data []byte } -var _ Sequence = &ApcSequence{} +var _ Sequence = ApcSequence{} -// Clone implements Sequence. -func (s ApcSequence) Clone() Sequence { - return ApcSequence{Data: append([]byte(nil), s.Data...)} +// Clone returns a deep copy of the APC sequence. +func (a ApcSequence) Clone() Sequence { + return ApcSequence{ + Data: append([]byte(nil), a.Data...), + } } // Bytes implements Sequence. diff --git a/ansi/status.go b/ansi/status.go new file mode 100644 index 00000000..6366607a --- /dev/null +++ b/ansi/status.go @@ -0,0 +1,115 @@ +package ansi + +import ( + "strconv" + "strings" +) + +// Status represents a terminal status report. +type Status interface { + // Status returns the status report identifier. + Status() int +} + +// ANSIStatus represents an ANSI terminal status report. +type ANSIStatus int //nolint:revive + +// Status returns the status report identifier. +func (s ANSIStatus) Status() int { + return int(s) +} + +// DECStatus represents a DEC terminal status report. +type DECStatus int + +// Status returns the status report identifier. +func (s DECStatus) Status() int { + return int(s) +} + +// DeviceStatusReport (DSR) is a control sequence that reports the terminal's +// status. +// The terminal responds with a DSR sequence. +// +// CSI Ps n +// CSI ? Ps n +// +// If one of the statuses is a [DECStatus], the sequence will use the DEC +// format. +// +// See also https://vt100.net/docs/vt510-rm/DSR.html +func DeviceStatusReport(statues ...Status) string { + var dec bool + list := make([]string, len(statues)) + seq := "\x1b[" + for i, status := range statues { + list[i] = strconv.Itoa(status.Status()) + switch status.(type) { + case DECStatus: + dec = true + } + } + if dec { + seq += "?" + } + return seq + strings.Join(list, ";") + "n" +} + +// DSR is an alias for [DeviceStatusReport]. +func DSR(status Status) string { + return DeviceStatusReport(status) +} + +// CursorPositionReport (CPR) is a control sequence that reports the cursor's +// position. +// +// CSI Pl ; Pc R +// +// Where Pl is the line number and Pc is the column number. +// +// See also https://vt100.net/docs/vt510-rm/CPR.html +func CursorPositionReport(line, column int) string { + if line < 1 { + line = 1 + } + if column < 1 { + column = 1 + } + return "\x1b[" + strconv.Itoa(line) + ";" + strconv.Itoa(column) + "R" +} + +// CPR is an alias for [CursorPositionReport]. +func CPR(line, column int) string { + return CursorPositionReport(line, column) +} + +// ExtendedCursorPositionReport (DECXCPR) is a control sequence that reports the +// cursor's position along with the page number (optional). +// +// CSI ? Pl ; Pc R +// CSI ? Pl ; Pc ; Pv R +// +// Where Pl is the line number, Pc is the column number, and Pv is the page +// number. +// +// If the page number is zero or negative, the returned sequence won't include +// the page number. +// +// See also https://vt100.net/docs/vt510-rm/DECXCPR.html +func ExtendedCursorPositionReport(line, column, page int) string { + if line < 1 { + line = 1 + } + if column < 1 { + column = 1 + } + if page < 1 { + return "\x1b[?" + strconv.Itoa(line) + ";" + strconv.Itoa(column) + "R" + } + return "\x1b[?" + strconv.Itoa(line) + ";" + strconv.Itoa(column) + ";" + strconv.Itoa(page) + "R" +} + +// DECXCPR is an alias for [ExtendedCursorPositionReport]. +func DECXCPR(line, column, page int) string { + return ExtendedCursorPositionReport(line, column, page) +} diff --git a/cellbuf/buffer.go b/cellbuf/buffer.go index 845687d9..1271737c 100644 --- a/cellbuf/buffer.go +++ b/cellbuf/buffer.go @@ -1,18 +1,25 @@ package cellbuf -// Buffer is a 2D grid of cells representing a screen or terminal. -type Buffer struct { +// NewBuffer returns a new buffer with the given width and height. +func NewBuffer(width, height int) *buffer { + var buf buffer + buf.Resize(width, height) + return &buf +} + +// buffer is a 2D grid of cells representing a screen or terminal. +type buffer struct { cells []Cell width int } // Width returns the width of the buffer. -func (b *Buffer) Width() int { +func (b *buffer) Width() int { return b.width } // Height returns the height of the buffer. -func (b *Buffer) Height() int { +func (b *buffer) Height() int { if b.width == 0 { return 0 } @@ -20,23 +27,28 @@ func (b *Buffer) Height() int { } // Cell returns the cell at the given x, y position. -func (b *Buffer) Cell(x, y int) (Cell, bool) { +func (b *buffer) Cell(x, y int) *Cell { if b.width == 0 { - return Cell{}, false + return nil } height := len(b.cells) / b.width if x < 0 || x >= b.width || y < 0 || y >= height { - return Cell{}, false + return nil } idx := y*b.width + x if idx < 0 || idx >= len(b.cells) { - return Cell{}, false + return nil } - return b.cells[idx], true + return &b.cells[idx] } // Draw sets the cell at the given x, y position. -func (b *Buffer) Draw(x, y int, c Cell) (v bool) { +func (b *buffer) Draw(x, y int, c Cell) (v bool) { + return b.SetCell(x, y, &c) +} + +// SetCell sets the cell at the given x, y position. +func (b *buffer) SetCell(x, y int, c *Cell) (v bool) { if b.width == 0 { return } @@ -63,7 +75,7 @@ func (b *Buffer) Draw(x, y int, c Cell) (v bool) { } } else if prev.Width == 0 { // Writing to wide cell placeholders - for j := 1; j < 4; j++ { + for j := 1; j < 4 && idx-j >= 0; j++ { wide := b.cells[idx-j] if wide.Width > 1 { for k := 0; k < wide.Width; k++ { @@ -77,13 +89,28 @@ func (b *Buffer) Draw(x, y int, c Cell) (v bool) { } } - b.cells[idx] = c + if c == nil { + newCell := spaceCell + c = &newCell + } - // Mark wide cells with emptyCell zero width - // We set the wide cell down below - if c.Width > 1 { - for j := 1; j < c.Width && idx+j < len(b.cells); j++ { - b.cells[idx+j] = emptyCell + if c != nil && x+c.Width > b.width { + // If the cell is too wide, we write blanks with the same style. + newCell := *c + newCell.Content = " " + newCell.Width = 1 + for i := 0; i < c.Width && idx+i < len(b.cells); i++ { + b.cells[idx+i] = newCell + } + } else { + b.cells[idx] = *c + + // Mark wide cells with emptyCell zero width + // We set the wide cell down below + if c.Width > 1 { + for j := 1; j < c.Width && idx+j < len(b.cells); j++ { + b.cells[idx+j] = emptyCell + } } } @@ -91,8 +118,8 @@ func (b *Buffer) Draw(x, y int, c Cell) (v bool) { } // Clone returns a deep copy of the buffer. -func (b *Buffer) Clone() *Buffer { - var clone Buffer +func (b *buffer) Clone() *buffer { + var clone buffer clone.width = b.width clone.cells = make([]Cell, len(b.cells)) copy(clone.cells, b.cells) @@ -102,7 +129,7 @@ func (b *Buffer) Clone() *Buffer { // Resize resizes the buffer to the given width and height. It grows the buffer // if necessary and fills the new cells with space cells. Otherwise, it // truncates the buffer. -func (b *Buffer) Resize(width, height int) { +func (b *buffer) Resize(width, height int) { b.width = width if area := width * height; len(b.cells) < area { ln := len(b.cells) @@ -118,37 +145,37 @@ func (b *Buffer) Resize(width, height int) { } // Bounds returns the bounds of the buffer. -func (b *Buffer) Bounds() Rectangle { +func (b *buffer) Bounds() Rectangle { return Rect(0, 0, b.Width(), b.Height()) } // Fill fills the buffer with the given cell. If rect is not nil, it fills the // rectangle with the cell. Otherwise, it fills the whole buffer. -func (b *Buffer) Fill(c Cell, rect *Rectangle) { - Fill(b, c, rect) +func (b *buffer) Fill(c *Cell, rects ...Rectangle) { + Fill(b, c, rects...) } // Clear clears the buffer with space cells. If rect is not nil, it clears the // rectangle. Otherwise, it clears the whole buffer. -func (b *Buffer) Clear(rect *Rectangle) { - Clear(b, rect) +func (b *buffer) Clear(rects ...Rectangle) { + Clear(b, rects...) } // Paint writes the given data to the buffer. If rect is not nil, it writes the // data within the rectangle. Otherwise, it writes the data to the whole // buffer. -func (b *Buffer) Paint(m Method, data string, rect *Rectangle) []int { +func (b *buffer) Paint(m Method, data string, rect *Rectangle) []int { return Paint(b, m, data, rect) } // Render returns a string representation of the buffer with ANSI escape // sequences. -func (b *Buffer) Render(opts ...RenderOption) string { +func (b *buffer) Render(opts ...RenderOption) string { return Render(b, opts...) } // RenderLine returns a string representation of the yth line of the buffer along // with the width of the line. -func (b *Buffer) RenderLine(n int, opts ...RenderOption) (w int, line string) { +func (b *buffer) RenderLine(n int, opts ...RenderOption) (w int, line string) { return RenderLine(b, n, opts...) } diff --git a/cellbuf/cell.go b/cellbuf/cell.go index 72475aec..fc927d20 100644 --- a/cellbuf/cell.go +++ b/cellbuf/cell.go @@ -1,5 +1,7 @@ package cellbuf +import "github.com/charmbracelet/x/vt" + var ( // spaceCell is 1-cell wide, has no style, and a space rune. spaceCell = Cell{ @@ -12,41 +14,4 @@ var ( ) // Cell represents a single cell in the terminal screen. -type Cell struct { - // The style of the cell. Nil style means no style. Zero value prints a - // reset sequence. - Style Style - - // Link is the hyperlink of the cell. - Link Link - - // Content is the string representation of the cell as a grapheme cluster. - Content string - - // Width is the mono-space width of the grapheme cluster. - Width int -} - -// Equal returns whether the cell is equal to the other cell. -func (c Cell) Equal(o Cell) bool { - return c.Width == o.Width && - c.Content == o.Content && - c.Style.Equal(o.Style) && - c.Link.Equal(o.Link) -} - -// Empty returns whether the cell is empty. -func (c Cell) Empty() bool { - return c.Content == "" && - c.Width == 0 && - c.Style.Empty() && - c.Link.Empty() -} - -// Reset resets the cell to the default state zero value. -func (c *Cell) Reset() { - c.Content = "" - c.Width = 0 - c.Style.Reset() - c.Link.Reset() -} +type Cell = vt.Cell diff --git a/cellbuf/geom.go b/cellbuf/geom.go index 86bd5b78..c444b8fa 100644 --- a/cellbuf/geom.go +++ b/cellbuf/geom.go @@ -1,44 +1,21 @@ package cellbuf import ( - "fmt" - "image" + "github.com/charmbracelet/x/vt" ) // Position represents an x, y position. -type Position image.Point - -// String returns a string representation of the position. -func (p Position) String() string { - return image.Point(p).String() -} +type Position = vt.Position // Pos is a shorthand for Position{X: x, Y: y}. func Pos(x, y int) Position { - return Position{X: x, Y: y} + return vt.Pos(x, y) } // Rectange represents a rectangle. -type Rectangle struct { - X, Y, Width, Height int -} - -// String returns a string representation of the rectangle. -func (r Rectangle) String() string { - return fmt.Sprintf("(%d,%d)-(%d,%d)", r.X, r.Y, r.X+r.Width, r.Y+r.Height) -} - -// Bounds returns the rectangle as an image.Rectangle. -func (r Rectangle) Bounds() image.Rectangle { - return image.Rect(r.X, r.Y, r.X+r.Width, r.Y+r.Height) -} - -// Contains reports whether the rectangle contains the given point. -func (r Rectangle) Contains(p Position) bool { - return image.Point(p).In(r.Bounds()) -} +type Rectangle = vt.Rectangle // Rect is a shorthand for Rectangle. func Rect(x, y, w, h int) Rectangle { - return Rectangle{X: x, Y: y, Width: w, Height: h} + return vt.Rect(x, y, w, h) } diff --git a/cellbuf/go.mod b/cellbuf/go.mod index 6d7b8203..edecc09a 100644 --- a/cellbuf/go.mod +++ b/cellbuf/go.mod @@ -5,6 +5,7 @@ go 1.18 require ( github.com/charmbracelet/colorprofile v0.1.7 github.com/charmbracelet/x/ansi v0.4.5 + github.com/charmbracelet/x/vt v0.0.0-20241113152101-0af7d04e9f32 github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 ) @@ -14,5 +15,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect + golang.org/x/text v0.20.0 // indirect ) diff --git a/cellbuf/go.sum b/cellbuf/go.sum index c8e5f764..b05f10f1 100644 --- a/cellbuf/go.sum +++ b/cellbuf/go.sum @@ -4,6 +4,8 @@ github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSe github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= +github.com/charmbracelet/x/vt v0.0.0-20241113152101-0af7d04e9f32 h1:F6G/LwhlSj/oQgnNKkELI934e/oao0MM67rst7MExDY= +github.com/charmbracelet/x/vt v0.0.0-20241113152101-0af7d04e9f32/go.mod h1:+CYC0tzYqYMtIryA0lcGQgCUaAiRLaS7Rxi9R+PFii8= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 h1:D5OO0lVavz7A+Swdhp62F9gbkibxmz9B2hZ/jVdMPf0= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91/go.mod h1:Ey8PFmYwH+/td9bpiEx07Fdx9ZVkxfIjWXxBluxF4Nw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -15,5 +17,5 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= diff --git a/cellbuf/link.go b/cellbuf/link.go index 001da73c..71bad227 100644 --- a/cellbuf/link.go +++ b/cellbuf/link.go @@ -1,36 +1,15 @@ package cellbuf -import "github.com/charmbracelet/colorprofile" +import ( + "github.com/charmbracelet/colorprofile" + "github.com/charmbracelet/x/vt" +) // Link represents a hyperlink in the terminal screen. -type Link struct { - URL string - URLID string -} - -// String returns a string representation of the hyperlink. -func (h Link) String() string { - return h.URL -} - -// Reset resets the hyperlink to the default state zero value. -func (h *Link) Reset() { - h.URL = "" - h.URLID = "" -} - -// Equal returns whether the hyperlink is equal to the other hyperlink. -func (h Link) Equal(o Link) bool { - return h == o -} - -// Empty returns whether the hyperlink is empty. -func (h Link) Empty() bool { - return h.URL == "" && h.URLID == "" -} +type Link = vt.Link // Convert converts a hyperlink to respect the given color profile. -func (h Link) Convert(p colorprofile.Profile) Link { +func ConvertLink(h Link, p colorprofile.Profile) Link { if p == colorprofile.NoTTY { return Link{} } diff --git a/cellbuf/screen.go b/cellbuf/screen.go index 257cacde..a8550e6d 100644 --- a/cellbuf/screen.go +++ b/cellbuf/screen.go @@ -12,27 +12,36 @@ import ( // attributes and hyperlink. type Segment = Cell -// Screen represents a screen grid of cells. -type Screen interface { +// Buffer represents a screen grid of cells. +type Buffer interface { // Width returns the width of the grid. Width() int // Height returns the height of the grid. Height() int - // Cell returns the cell at the given position. - Cell(x, y int) (Cell, bool) + // Cell returns the cell at the given position. If the cell is out of + // bounds, it returns nil. + Cell(x, y int) *Cell - // Draw writes a cell to the grid at the given position. It returns true if - // the cell was written successfully. - Draw(x, y int, c Cell) bool + // SetCell writes a cell to the grid at the given position. It returns true + // if the cell was written successfully. If the cell is nil, a blank cell + // is written. + SetCell(x, y int, c *Cell) bool +} + +// Resizable is an interface for buffers that can be resized. +type Resizable interface { + // Resize resizes the buffer to the given width and height. + Resize(width, height int) } // Paint writes the given data to the canvas. If rect is not nil, it only // writes to the rectangle. Otherwise, it writes to the whole canvas. -func Paint(d Screen, m Method, content string, rect *Rectangle) []int { +func Paint(d Buffer, m Method, content string, rect *Rectangle) []int { if rect == nil { - rect = &Rectangle{0, 0, d.Width(), d.Height()} + r := Rect(0, 0, d.Width(), d.Height()) + rect = &r } return setContent(d, content, m, *rect) } @@ -54,7 +63,7 @@ func WithRenderProfile(p colorprofile.Profile) RenderOption { } // Render returns a string representation of the grid with ANSI escape sequences. -func Render(d Screen, opts ...RenderOption) string { +func Render(d Buffer, opts ...RenderOption) string { var opt RenderOptions for _, o := range opts { o(&opt) @@ -73,7 +82,7 @@ func Render(d Screen, opts ...RenderOption) string { // RenderLine returns a string representation of the yth line of the grid along // with the width of the line. -func RenderLine(d Screen, n int, opts ...RenderOption) (w int, line string) { +func RenderLine(d Buffer, n int, opts ...RenderOption) (w int, line string) { var opt RenderOptions for _, o := range opts { o(&opt) @@ -81,7 +90,7 @@ func RenderLine(d Screen, n int, opts ...RenderOption) (w int, line string) { return renderLine(d, n, opt) } -func renderLine(d Screen, n int, opt RenderOptions) (w int, line string) { +func renderLine(d Buffer, n int, opt RenderOptions) (w int, line string) { var pen Style var link Link var buf bytes.Buffer @@ -100,10 +109,10 @@ func renderLine(d Screen, n int, opt RenderOptions) (w int, line string) { } for x := 0; x < d.Width(); x++ { - if cell, ok := d.Cell(x, n); ok && cell.Width > 0 { + if cell := d.Cell(x, n); cell != nil && cell.Width > 0 { // Convert the cell's style and link to the given color profile. - cellStyle := cell.Style.Convert(opt.Profile) - cellLink := cell.Link.Convert(opt.Profile) + cellStyle := ConvertStyle(cell.Style, opt.Profile) + cellLink := ConvertLink(cell.Link, opt.Profile) if cellStyle.Empty() && !pen.Empty() { writePending() buf.WriteString(ansi.ResetStyle) //nolint:errcheck @@ -130,7 +139,7 @@ func renderLine(d Screen, n int, opt RenderOptions) (w int, line string) { // We only write the cell content if it's not empty. If it is, we // append it to the pending line and width to be evaluated later. - if cell.Equal(spaceCell) { + if cell.Equal(&spaceCell) { pendingLine += cell.Content pendingWidth += cell.Width } else { @@ -151,34 +160,50 @@ func renderLine(d Screen, n int, opt RenderOptions) (w int, line string) { // Fill fills the canvas with the given cell. If rect is not nil, it only fills // the rectangle. Otherwise, it fills the whole canvas. -func Fill(d Screen, c Cell, rect *Rectangle) { - if rect == nil { - rect = &Rectangle{0, 0, d.Width(), d.Height()} +func Fill(d Buffer, c *Cell, rects ...Rectangle) { + if len(rects) == 0 { + fill(d, c, Rect(0, 0, d.Width(), d.Height())) + return + } + for _, rect := range rects { + fill(d, c, rect) } +} - for y := rect.Y; y < rect.Y+rect.Height; y++ { - for x := rect.X; x < rect.X+rect.Width; x += c.Width { - d.Draw(x, y, c) //nolint:errcheck +func fill(d Buffer, c *Cell, rect Rectangle) { + cellWidth := 1 + if c != nil { + cellWidth = c.Width + } + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x += cellWidth { + d.SetCell(x, y, c) //nolint:errcheck } } } // Clear clears the canvas with space cells. If rect is not nil, it only clears // the rectangle. Otherwise, it clears the whole canvas. -func Clear(d Screen, rect *Rectangle) { - Fill(d, spaceCell, rect) +func Clear(d Buffer, rects ...Rectangle) { + if len(rects) == 0 { + fill(d, nil, Rect(0, 0, d.Width(), d.Height())) + return + } + for _, rect := range rects { + fill(d, nil, rect) + } } // Equal returns whether two grids are equal. -func Equal(a, b Screen) bool { +func Equal(a, b Buffer) bool { if a.Width() != b.Width() || a.Height() != b.Height() { return false } for y := 0; y < a.Height(); y++ { for x := 0; x < a.Width(); x++ { - ca, _ := a.Cell(x, y) - cb, _ := b.Cell(x, y) - if !ca.Equal(cb) { + ca := a.Cell(x, y) + cb := b.Cell(x, y) + if ca != nil && cb != nil && !ca.Equal(cb) { return false } } diff --git a/cellbuf/screen_write.go b/cellbuf/screen_write.go index 371ea5df..494e79ab 100644 --- a/cellbuf/screen_write.go +++ b/cellbuf/screen_write.go @@ -6,13 +6,14 @@ import ( "unicode/utf8" "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/vt" "github.com/charmbracelet/x/wcwidth" ) // setContent writes the given data to the buffer starting from the first cell. // It accepts both string and []byte data types. func setContent( - d Screen, + d Buffer, data string, method Method, rect Rectangle, @@ -20,7 +21,7 @@ func setContent( var cell Cell var pen Style var link Link - x, y := rect.X, rect.Y + x, y := rect.X(), rect.Y() p := ansi.GetParser() defer ansi.PutParser(p) @@ -29,7 +30,7 @@ func setContent( // linew is a slice of line widths. We use this to keep track of the // written widths of each line. We use this information later to optimize // rendering of the buffer. - linew := make([]int, rect.Height) + linew := make([]int, rect.Height()) var pendingWidth int @@ -53,7 +54,7 @@ func setContent( } fallthrough case 1: - if x >= rect.X+rect.Width || y >= rect.Y+rect.Height { + if x+width >= rect.X()+rect.Width() || y >= rect.Y()+rect.Height() { break } @@ -62,13 +63,13 @@ func setContent( cell.Style = pen cell.Link = link - d.Draw(x, y, cell) //nolint:errcheck + d.SetCell(x, y, &cell) //nolint:errcheck // Advance the cursor and line width x += cell.Width - if cell.Equal(spaceCell) { + if cell.Equal(&spaceCell) { pendingWidth += cell.Width - } else if y := y - rect.Y; y < len(linew) { + } else if y := y - rect.Y(); y < len(linew) { linew[y] += cell.Width + pendingWidth pendingWidth = 0 } @@ -77,20 +78,20 @@ func setContent( default: // Valid sequences always have a non-zero Cmd. switch { - case ansi.HasCsiPrefix(seq) && p.Cmd != 0: - switch p.Cmd { + case ansi.HasCsiPrefix(seq) && p.Cmd() != 0: + switch p.Cmd() { case 'm': // SGR - Select Graphic Rendition handleSgr(p, &pen) } - case ansi.HasOscPrefix(seq) && p.Cmd != 0: - switch p.Cmd { + case ansi.HasOscPrefix(seq) && p.Cmd() != 0: + switch p.Cmd() { case 8: // Hyperlinks handleHyperlinks(p, &link) } case ansi.Equal(seq, "\n"): // Reset the rest of the line - for x < rect.X+rect.Width { - d.Draw(x, y, spaceCell) //nolint:errcheck + for x < rect.X()+rect.Width() { + d.SetCell(x, y, nil) //nolint:errcheck x++ } @@ -107,8 +108,8 @@ func setContent( data = data[n:] } - for x < rect.X+rect.Width { - d.Draw(x, y, spaceCell) //nolint:errcheck + for x < rect.X()+rect.Width() { + d.SetCell(x, y, nil) //nolint:errcheck x++ } @@ -117,15 +118,15 @@ func setContent( // handleSgr handles Select Graphic Rendition (SGR) escape sequences. func handleSgr(p *ansi.Parser, pen *Style) { - if p.ParamsLen == 0 { + params := p.Params() + if len(params) == 0 { pen.Reset() return } - params := p.Params[:p.ParamsLen] for i := 0; i < len(params); i++ { - r := ansi.Param(params[i]) - param, hasMore := r.Param(), r.HasMore() // Are there more subparameters i.e. separated by ":"? + r := ansi.Parameter(params[i]) + param, hasMore := r.Param(0), r.HasMore() // Are there more subparameters i.e. separated by ":"? switch param { case 0: // Reset pen.Reset() @@ -137,20 +138,20 @@ func handleSgr(p *ansi.Parser, pen *Style) { pen.Italic(true) case 4: // Underline if hasMore { // Only accept subparameters i.e. separated by ":" - nextParam := ansi.Param(params[i+1]).Param() + nextParam := params[i+1].Param(0) switch nextParam { case 0: // No Underline - pen.UnderlineStyle(NoUnderline) + pen.UnderlineStyle(vt.NoUnderline) case 1: // Single Underline - pen.UnderlineStyle(SingleUnderline) + pen.UnderlineStyle(vt.SingleUnderline) case 2: // Double Underline - pen.UnderlineStyle(DoubleUnderline) + pen.UnderlineStyle(vt.DoubleUnderline) case 3: // Curly Underline - pen.UnderlineStyle(CurlyUnderline) + pen.UnderlineStyle(vt.CurlyUnderline) case 4: // Dotted Underline - pen.UnderlineStyle(DottedUnderline) + pen.UnderlineStyle(vt.DottedUnderline) case 5: // Dashed Underline - pen.UnderlineStyle(DashedUnderline) + pen.UnderlineStyle(vt.DashedUnderline) } } else { // Single Underline @@ -212,7 +213,7 @@ func handleSgr(p *ansi.Parser, pen *Style) { // handleHyperlinks handles hyperlink escape sequences. func handleHyperlinks(p *ansi.Parser, link *Link) { - params := bytes.Split(p.Data[:p.DataLen], []byte{';'}) + params := bytes.Split(p.Data(), []byte{';'}) if len(params) != 3 { return } diff --git a/cellbuf/style.go b/cellbuf/style.go index 8366e104..0e32833f 100644 --- a/cellbuf/style.go +++ b/cellbuf/style.go @@ -2,378 +2,14 @@ package cellbuf import ( "github.com/charmbracelet/colorprofile" - "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/vt" ) -// AttrMask is a bitmask for text attributes that can change the look of text. -// These attributes can be combined to create different styles. -type AttrMask uint8 - -// These are the available text attributes that can be combined to create -// different styles. -const ( - BoldAttr AttrMask = 1 << iota - FaintAttr - ItalicAttr - SlowBlinkAttr - RapidBlinkAttr - ReverseAttr - ConcealAttr - StrikethroughAttr - - ResetAttr AttrMask = 0 -) - -var attrMaskNames = map[AttrMask]string{ - BoldAttr: "BoldAttr", - FaintAttr: "FaintAttr", - ItalicAttr: "ItalicAttr", - SlowBlinkAttr: "SlowBlinkAttr", - RapidBlinkAttr: "RapidBlinkAttr", - ReverseAttr: "ReverseAttr", - ConcealAttr: "ConcealAttr", - StrikethroughAttr: "StrikethroughAttr", - ResetAttr: "ResetAttr", -} - -// UnderlineStyle is the style of underline to use for text. -type UnderlineStyle uint8 - -// These are the available underline styles. -const ( - NoUnderline UnderlineStyle = iota - SingleUnderline - DoubleUnderline - CurlyUnderline - DottedUnderline - DashedUnderline -) - -var underlineStyleNames = map[UnderlineStyle]string{ - NoUnderline: "NoUnderline", - SingleUnderline: "SingleUnderline", - DoubleUnderline: "DoubleUnderline", - CurlyUnderline: "CurlyUnderline", - DottedUnderline: "DottedUnderline", - DashedUnderline: "DashedUnderline", -} - -// String returns a string representation of the underline style. -func (u UnderlineStyle) String() string { - return underlineStyleNames[u] -} - // Style represents the Style of a cell. -type Style struct { - Fg ansi.Color - Bg ansi.Color - Ul ansi.Color - Attrs AttrMask - UlStyle UnderlineStyle -} - -// Sequence returns the ANSI sequence that sets the style. -func (s Style) Sequence() string { - if s.Empty() { - return ansi.ResetStyle - } - - var b ansi.Style - - if s.Attrs != 0 { - if s.Attrs&BoldAttr != 0 { - b = b.Bold() - } - if s.Attrs&FaintAttr != 0 { - b = b.Faint() - } - if s.Attrs&ItalicAttr != 0 { - b = b.Italic() - } - if s.Attrs&SlowBlinkAttr != 0 { - b = b.SlowBlink() - } - if s.Attrs&RapidBlinkAttr != 0 { - b = b.RapidBlink() - } - if s.Attrs&ReverseAttr != 0 { - b = b.Reverse() - } - if s.Attrs&ConcealAttr != 0 { - b = b.Conceal() - } - if s.Attrs&StrikethroughAttr != 0 { - b = b.Strikethrough() - } - } - if s.UlStyle != NoUnderline { - switch s.UlStyle { - case SingleUnderline: - b = b.Underline() - case DoubleUnderline: - b = b.DoubleUnderline() - case CurlyUnderline: - b = b.CurlyUnderline() - case DottedUnderline: - b = b.DottedUnderline() - case DashedUnderline: - b = b.DashedUnderline() - } - } - if s.Fg != nil { - b = b.ForegroundColor(s.Fg) - } - if s.Bg != nil { - b = b.BackgroundColor(s.Bg) - } - if s.Ul != nil { - b = b.UnderlineColor(s.Ul) - } - - return b.String() -} - -// DiffSequence returns the ANSI sequence that sets the style as a diff from -// another style. -func (s Style) DiffSequence(o Style) string { - if o.Empty() { - return s.Sequence() - } - - var b ansi.Style - - if !colorEqual(s.Fg, o.Fg) { - b = b.ForegroundColor(s.Fg) - } - - if !colorEqual(s.Bg, o.Bg) { - b = b.BackgroundColor(s.Bg) - } - - if !colorEqual(s.Ul, o.Ul) { - b = b.UnderlineColor(s.Ul) - } - - var ( - noBlink bool - isNormal bool - ) - - if s.Attrs != o.Attrs { - if s.Attrs&BoldAttr != o.Attrs&BoldAttr { - if s.Attrs&BoldAttr != 0 { - b = b.Bold() - } else if !isNormal { - isNormal = true - b = b.NormalIntensity() - } - } - if s.Attrs&FaintAttr != o.Attrs&FaintAttr { - if s.Attrs&FaintAttr != 0 { - b = b.Faint() - } else if !isNormal { - isNormal = true - b = b.NormalIntensity() - } - } - if s.Attrs&ItalicAttr != o.Attrs&ItalicAttr { - if s.Attrs&ItalicAttr != 0 { - b = b.Italic() - } else { - b = b.NoItalic() - } - } - if s.Attrs&SlowBlinkAttr != o.Attrs&SlowBlinkAttr { - if s.Attrs&SlowBlinkAttr != 0 { - b = b.SlowBlink() - } else if !noBlink { - b = b.NoBlink() - } - } - if s.Attrs&RapidBlinkAttr != o.Attrs&RapidBlinkAttr { - if s.Attrs&RapidBlinkAttr != 0 { - b = b.RapidBlink() - } else if !noBlink { - b = b.NoBlink() - } - } - if s.Attrs&ReverseAttr != o.Attrs&ReverseAttr { - if s.Attrs&ReverseAttr != 0 { - b = b.Reverse() - } else { - b = b.NoReverse() - } - } - if s.Attrs&ConcealAttr != o.Attrs&ConcealAttr { - if s.Attrs&ConcealAttr != 0 { - b = b.Conceal() - } else { - b = b.NoConceal() - } - } - if s.Attrs&StrikethroughAttr != o.Attrs&StrikethroughAttr { - if s.Attrs&StrikethroughAttr != 0 { - b = b.Strikethrough() - } else { - b = b.NoStrikethrough() - } - } - } - - return b.String() -} - -// Equal returns true if the style is equal to the other style. -func (s Style) Equal(o Style) bool { - return colorEqual(s.Fg, o.Fg) && - colorEqual(s.Bg, o.Bg) && - colorEqual(s.Ul, o.Ul) && - s.Attrs == o.Attrs && - s.UlStyle == o.UlStyle -} - -func colorEqual(c, o ansi.Color) bool { - if c == nil && o == nil { - return true - } - if c == nil || o == nil { - return false - } - cr, cg, cb, ca := c.RGBA() - or, og, ob, oa := o.RGBA() - return cr == or && cg == og && cb == ob && ca == oa -} - -// Bold sets the bold attribute. -func (s *Style) Bold(v bool) *Style { - if v { - s.Attrs |= BoldAttr - } else { - s.Attrs &^= BoldAttr - } - return s -} - -// Faint sets the faint attribute. -func (s *Style) Faint(v bool) *Style { - if v { - s.Attrs |= FaintAttr - } else { - s.Attrs &^= FaintAttr - } - return s -} - -// Italic sets the italic attribute. -func (s *Style) Italic(v bool) *Style { - if v { - s.Attrs |= ItalicAttr - } else { - s.Attrs &^= ItalicAttr - } - return s -} - -// SlowBlink sets the slow blink attribute. -func (s *Style) SlowBlink(v bool) *Style { - if v { - s.Attrs |= SlowBlinkAttr - } else { - s.Attrs &^= SlowBlinkAttr - } - return s -} - -// RapidBlink sets the rapid blink attribute. -func (s *Style) RapidBlink(v bool) *Style { - if v { - s.Attrs |= RapidBlinkAttr - } else { - s.Attrs &^= RapidBlinkAttr - } - return s -} - -// Reverse sets the reverse attribute. -func (s *Style) Reverse(v bool) *Style { - if v { - s.Attrs |= ReverseAttr - } else { - s.Attrs &^= ReverseAttr - } - return s -} - -// Conceal sets the conceal attribute. -func (s *Style) Conceal(v bool) *Style { - if v { - s.Attrs |= ConcealAttr - } else { - s.Attrs &^= ConcealAttr - } - return s -} - -// Strikethrough sets the strikethrough attribute. -func (s *Style) Strikethrough(v bool) *Style { - if v { - s.Attrs |= StrikethroughAttr - } else { - s.Attrs &^= StrikethroughAttr - } - return s -} - -// UnderlineStyle sets the underline style. -func (s *Style) UnderlineStyle(style UnderlineStyle) *Style { - s.UlStyle = style - return s -} - -// Underline sets the underline attribute. -// This is a syntactic sugar for [UnderlineStyle]. -func (s *Style) Underline(v bool) *Style { - if v { - return s.UnderlineStyle(SingleUnderline) - } - return s.UnderlineStyle(NoUnderline) -} - -// Foreground sets the foreground color. -func (s *Style) Foreground(c ansi.Color) *Style { - s.Fg = c - return s -} - -// Background sets the background color. -func (s *Style) Background(c ansi.Color) *Style { - s.Bg = c - return s -} - -// UnderlineColor sets the underline color. -func (s *Style) UnderlineColor(c ansi.Color) *Style { - s.Ul = c - return s -} - -// Reset resets the style to default. -func (s *Style) Reset() *Style { - s.Fg = nil - s.Bg = nil - s.Ul = nil - s.Attrs = ResetAttr - s.UlStyle = NoUnderline - return s -} - -// Empty returns true if the style is empty. -func (s *Style) Empty() bool { - return s.Fg == nil && s.Bg == nil && s.Ul == nil && s.Attrs == ResetAttr && s.UlStyle == NoUnderline -} +type Style = vt.Style // Convert converts a style to respect the given color profile. -func (s Style) Convert(p colorprofile.Profile) Style { +func ConvertStyle(s Style, p colorprofile.Profile) Style { switch p { case colorprofile.TrueColor: return s diff --git a/cellbuf/utils.go b/cellbuf/utils.go index 41f9e0ec..39afcc7e 100644 --- a/cellbuf/utils.go +++ b/cellbuf/utils.go @@ -12,22 +12,22 @@ func Height(s string) int { return strings.Count(s, "\n") + 1 } -func readColor(idxp *int, params []int) (c ansi.Color) { +func readColor(idxp *int, params []ansi.Parameter) (c ansi.Color) { i := *idxp paramsLen := len(params) if i > paramsLen-1 { return } // Note: we accept both main and subparams here - switch param := ansi.Param(params[i+1]); param { + switch params[i+1].Param(0) { case 2: // RGB if i > paramsLen-4 { return } c = color.RGBA{ - R: uint8(ansi.Param(params[i+2])), //nolint:gosec - G: uint8(ansi.Param(params[i+3])), //nolint:gosec - B: uint8(ansi.Param(params[i+4])), //nolint:gosec + R: uint8(params[i+2].Param(0)), //nolint:gosec + G: uint8(params[i+3].Param(0)), //nolint:gosec + B: uint8(params[i+4].Param(0)), //nolint:gosec A: 0xff, } *idxp += 4 @@ -35,7 +35,7 @@ func readColor(idxp *int, params []int) (c ansi.Color) { if i > paramsLen-2 { return } - c = ansi.ExtendedColor(ansi.Param(params[i+2])) //nolint:gosec + c = ansi.ExtendedColor(params[i+2].Param(0)) //nolint:gosec *idxp += 2 } return diff --git a/examples/JetBrainsMono-Regular.ttf b/examples/JetBrainsMono-Regular.ttf new file mode 100644 index 00000000..527c644e --- /dev/null +++ b/examples/JetBrainsMono-Regular.ttf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f376439c75ab33392eb7a2f5ec999809493b378728d723327655c9fcb45cea9 +size 171876 diff --git a/examples/cellbuf/main.go b/examples/cellbuf/main.go index c19e8ec1..10495e48 100644 --- a/examples/cellbuf/main.go +++ b/examples/cellbuf/main.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/x/cellbuf" "github.com/charmbracelet/x/input" "github.com/charmbracelet/x/term" + "github.com/charmbracelet/x/vt" ) func main() { @@ -32,19 +33,18 @@ func main() { os.Stdout.WriteString(ansi.EnableAltScreenBuffer + ansi.EnableMouseCellMotion + ansi.EnableMouseSgrExt) defer os.Stdout.WriteString(ansi.DisableMouseSgrExt + ansi.DisableMouseCellMotion + ansi.DisableAltScreenBuffer) - var buf cellbuf.Buffer var style cellbuf.Style + buf := vt.NewBuffer(w, h) style.Reverse(true) x, y := (w/2)-8, h/2 - buf.Resize(w, h) - reset(&buf, x, y) + reset(buf, x, y) if runtime.GOOS != "windows" { // Listen for resize events go listenForResize(func() { - updateWinsize(&buf) - reset(&buf, x, y) + updateWinsize(buf) + reset(buf, x, y) }) } @@ -57,7 +57,7 @@ func main() { for _, ev := range evs { switch ev := ev.(type) { case input.WindowSizeEvent: - updateWinsize(&buf) + updateWinsize(buf) case input.MouseClickEvent: x, y = ev.X, ev.Y case input.KeyPressEvent: @@ -76,17 +76,18 @@ func main() { } } - reset(&buf, x, y) + reset(buf, x, y) } } -func reset(buf *cellbuf.Buffer, x, y int) { - buf.Fill(cellbuf.Cell{Content: "你", Width: 2}, nil) - buf.Paint(0, "\x1b[7m !Hello, world! \x1b[m", &cellbuf.Rectangle{X: x, Y: y, Width: 16, Height: 1}) - os.Stdout.WriteString(ansi.SetCursorPosition(1, 1) + buf.Render()) +func reset(buf cellbuf.Buffer, x, y int) { + cellbuf.Fill(buf, vt.NewCell('你')) + rect := cellbuf.Rect(x, y, 16, 1) + cellbuf.Paint(buf, cellbuf.WcWidth, "\x1b[7m !Hello, world! \x1b[m", &rect) + os.Stdout.WriteString(ansi.SetCursorPosition(1, 1) + cellbuf.Render(buf)) } -func updateWinsize(buf *cellbuf.Buffer) (w, h int) { +func updateWinsize(buf cellbuf.Resizable) (w, h int) { w, h, _ = term.GetSize(os.Stdout.Fd()) buf.Resize(w, h) return diff --git a/examples/go.mod b/examples/go.mod index 52f9f975..7186b4dc 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -6,10 +6,8 @@ require ( github.com/charmbracelet/x/ansi v0.4.5 github.com/charmbracelet/x/cellbuf v0.0.6-0.20241106170917-eb0997d7d743 github.com/charmbracelet/x/input v0.2.0 - github.com/charmbracelet/x/termios v0.1.0 github.com/charmbracelet/x/vt v0.0.0-20241119170456-6066f8aa557d github.com/creack/pty v1.1.24 - golang.org/x/image v0.22.0 ) require ( @@ -24,6 +22,6 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.27.0 + golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect ) diff --git a/examples/go.sum b/examples/go.sum index 015dd585..831838cc 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -8,8 +8,6 @@ github.com/charmbracelet/x/input v0.2.0 h1:1Sv+y/flcqUfUH2PXNIDKDIdT2G8smOnGOgaw github.com/charmbracelet/x/input v0.2.0/go.mod h1:KUSFIS6uQymtnr5lHVSOK9j8RvwTD4YHnWnzJUYnd/M= github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= -github.com/charmbracelet/x/termios v0.1.0 h1:y4rjAHeFksBAfGbkRDmVinMg7x7DELIGAFbdNvxg97k= -github.com/charmbracelet/x/termios v0.1.0/go.mod h1:H/EVv/KRnrYjz+fCYa9bsKdqF3S8ouDK0AZEbG7r+/U= github.com/charmbracelet/x/vt v0.0.0-20241119170456-6066f8aa557d h1:2SCDh93gnBiFHson6DgATBwQbJnSGvASLRVEekudl+o= github.com/charmbracelet/x/vt v0.0.0-20241119170456-6066f8aa557d/go.mod h1:+CYC0tzYqYMtIryA0lcGQgCUaAiRLaS7Rxi9R+PFii8= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 h1:D5OO0lVavz7A+Swdhp62F9gbkibxmz9B2hZ/jVdMPf0= @@ -27,8 +25,6 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/image v0.22.0 h1:UtK5yLUzilVrkjMAZAZ34DXGpASN8i8pj8g+O+yd10g= -golang.org/x/image v0.22.0/go.mod h1:9hPFhljd4zZ1GNSIZJ49sqbp45GKK9t6w+iXvGqZUz4= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/examples/htop/main.go b/examples/htop/main.go deleted file mode 100644 index e01b9355..00000000 --- a/examples/htop/main.go +++ /dev/null @@ -1,124 +0,0 @@ -package main - -import ( - "fmt" - "image" - "image/color" - "image/draw" - "image/png" - "io" - "log" - "os" - "os/exec" - "sync/atomic" - "time" - - "github.com/charmbracelet/x/termios" - "github.com/charmbracelet/x/vt" - "github.com/creack/pty" - "golang.org/x/image/font" - "golang.org/x/image/font/basicfont" - "golang.org/x/image/math/fixed" - "golang.org/x/sys/unix" -) - -const ( - fontWidth = 7 - fontHeight = 13 -) - -func drawChar(img *image.RGBA, x, y int, fg color.Color, text string) { - point := fixed.Point26_6{fixed.Int26_6(x * 64), fixed.Int26_6(y * 64)} - if fg == nil { - fg = color.White - } - d := &font.Drawer{ - Dst: img, - Src: image.NewUniform(fg), - Face: basicfont.Face7x13, - Dot: point, - } - d.DrawString(text) -} - -var counter int32 = 1 - -func putImage(vt *vt.Terminal) { - rows, cols := vt.Height(), vt.Width() - img := image.NewRGBA(image.Rect(0, 0, cols*fontWidth, rows*fontHeight)) - draw.Draw(img, img.Bounds(), &image.Uniform{color.Black}, image.ZP, draw.Src) - - for row := 0; row < rows; row++ { - for col := 0; col < cols; col++ { - cell, ok := vt.At(col, row) - if !ok { - log.Printf("failed to get at %d %d", row, col) - } - txt := cell.Content - if len(txt) > 0 && txt[0] != 0 { - fg, bg := cell.Style.Fg, cell.Style.Bg - if bg == nil { - bg = color.Black - } - - // Draw background - x0, y0 := (col+1)*fontWidth, ((row+1)*fontHeight)-11 - x1, y1 := x0+fontWidth, y0+fontHeight - draw.Draw(img, image.Rect(x0, y0, x1, y1), &image.Uniform{bg}, image.ZP, draw.Over) - - drawChar(img, (col+1)*fontWidth, (row+1)*fontHeight, fg, txt) - } - } - } - - f, err := os.Create(fmt.Sprintf("output%d.png", counter)) - if err != nil { - log.Fatal(err) - } - - defer f.Close() - err = png.Encode(f, img) - if err != nil { - log.Fatal(err) - } - - atomic.AddInt32(&counter, 1) -} - -const ( - width = 165 - height = 25 -) - -func main() { - vt := vt.NewTerminal(width, height) - cmd := exec.Command("htop") - - go func() { - for { - time.Sleep(1 * time.Second) - putImage(vt) - } - }() - - go func() { - time.Sleep(5 * time.Second) - cmd.Process.Kill() - }() - - ptm, err := pty.Start(cmd) - if err != nil { - log.Fatal(err) - } - - if err := termios.SetWinsize(int(ptm.Fd()), &unix.Winsize{Row: uint16(height), Col: uint16(width)}); err != nil { - log.Fatal(err) - } - - go io.Copy(ptm, vt) - go io.Copy(vt, ptm) - - if err := cmd.Wait(); err != nil { - log.Fatal(err) - } -} diff --git a/examples/parserlog/main.go b/examples/parserlog/main.go index 585efcdf..1638aac9 100644 --- a/examples/parserlog/main.go +++ b/examples/parserlog/main.go @@ -7,7 +7,6 @@ import ( "os" "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/ansi/parser" ) func main() { @@ -17,7 +16,6 @@ func main() { } var str string - parser := ansi.NewParser(parser.MaxParamsSize, 0) dispatcher := func(s ansi.Sequence) { if _, ok := s.(ansi.Rune); !ok && str != "" { fmt.Printf("[Print] %s\n", str) @@ -26,73 +24,15 @@ func main() { switch s := s.(type) { case ansi.Rune: str += string(s) - case ansi.ControlCode: - fmt.Printf("[ControlCode] %q\n", s) - case ansi.EscSequence: - fmt.Print("[EscSequence] ") - fmt.Printf("Cmd=%c ", s.Command()) - if intermed := s.Intermediate(); intermed != 0 { - fmt.Printf("Inter=%c", intermed) - } - fmt.Println() - case ansi.CsiSequence: - fmt.Print("[CsiSequence] ") - fmt.Printf("Cmd=%q ", s.Command()) - if marker := s.Marker(); marker != 0 { - fmt.Printf("Marker=%q, ", marker) - } - if intermed := s.Intermediate(); intermed != 0 { - fmt.Printf("Intermed=%q, ", intermed) - } - for i := 0; i < s.Len(); i++ { - if i == 0 { - fmt.Printf("Params=[") - } - fmt.Printf("%+v", s.Subparams(i)) - if i != s.Len()-1 { - fmt.Print(", ") - } - if i == s.Len()-1 { - fmt.Print("]") - } - } - fmt.Println() - case ansi.OscSequence: - fmt.Print("[OscSequence] ") - fmt.Printf("Cmd=%d ", s.Command()) - fmt.Printf("Params=%+v\n", s.Params()) - case ansi.DcsSequence: - fmt.Print("[DcsSequence] ") - fmt.Printf("Cmd=%q ", s.Command()) - if marker := s.Marker(); marker != 0 { - fmt.Printf("Marker=%q, ", marker) - } - if intermed := s.Intermediate(); intermed != 0 { - fmt.Printf("Intermed=%q, ", intermed) - } - for i := 0; i < s.Len(); i++ { - if i == 0 { - fmt.Printf("Params=[") - } - fmt.Printf("%+v", s.Subparams(i)) - if i != s.Len()-1 { - fmt.Print(", ") - } - if i == s.Len()-1 { - fmt.Print("] ") - } - } - fmt.Printf("Data=%q\n", s.Data) - case ansi.SosSequence: - fmt.Printf("[SosSequence] Data=%q\n", s.Data) - case ansi.PmSequence: - fmt.Printf("[PmSequence] Data=%q\n", s.Data) - case ansi.ApcSequence: - fmt.Printf("[ApcSequence] Data=%q\n", s.Data) + default: + fmt.Printf("[%T] %q\n", s, s) } } - parser.Parse(dispatcher, bts) + parser := ansi.NewParser(dispatcher) + for _, b := range bts { + parser.Advance(b) + } if str != "" { fmt.Printf("[Print] %s\n", str) } diff --git a/examples/parserlog2/main.go b/examples/parserlog2/main.go index 8c59b8e8..99eaa2c2 100644 --- a/examples/parserlog2/main.go +++ b/examples/parserlog2/main.go @@ -8,7 +8,6 @@ import ( "os" "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/ansi/parser" ) func main() { @@ -18,15 +17,15 @@ func main() { } var state byte - p := ansi.NewParser(32, 1024) + p := ansi.NewParser(nil) for len(input) > 0 { seq, width, n, newState := ansi.DecodeSequence(input, state, p) switch { case ansi.HasOscPrefix(seq): - fmt.Printf("OSC sequence: %q, cmd: %d, data: %q", seq, p.Cmd, p.Data[:p.DataLen]) + fmt.Printf("OSC sequence: %q, cmd: %d, data: %q", seq, p.Cmd(), p.Data()) fmt.Println() case ansi.HasDcsPrefix(seq): - c := ansi.Cmd(p.Cmd) + c := p.Cmd() intermed, marker, cmd := c.Intermediate(), c.Marker(), c.Command() fmt.Printf("DCS sequence: %q,", seq) if intermed != 0 { @@ -40,18 +39,18 @@ func main() { } fmt.Print(" params: [") var more bool - for i := 0; i < p.ParamsLen; i++ { - r := ansi.Param(p.Params[i]) - param, hasMore := r.Param(), r.HasMore() + params := p.Params() + for i, r := range params { + param, hasMore := r.Param(-1), r.HasMore() if more != hasMore { fmt.Print("[") } - if param == parser.MissingParam { + if param == -1 { fmt.Print("MISSING") } else { fmt.Printf("%d", param) } - if i != p.ParamsLen-1 { + if i != len(params)-1 { fmt.Print(", ") } if more != hasMore { @@ -59,20 +58,20 @@ func main() { } more = hasMore } - fmt.Printf("], data: %q", p.Data[:p.DataLen]) + fmt.Printf("], data: %q", p.Data()) fmt.Println() case ansi.HasSosPrefix(seq): - fmt.Printf("SOS sequence: %q, data: %q", seq, p.Data[:p.DataLen]) + fmt.Printf("SOS sequence: %q, data: %q", seq, p.Data()) fmt.Println() case ansi.HasPmPrefix(seq): - fmt.Printf("PM sequence: %q, data: %q", seq, p.Data[:p.DataLen]) + fmt.Printf("PM sequence: %q, data: %q", seq, p.Data()) fmt.Println() case ansi.HasApcPrefix(seq): - fmt.Printf("APC sequence: %q, data: %q", seq, p.Data[:p.DataLen]) + fmt.Printf("APC sequence: %q, data: %q", seq, p.Data()) fmt.Println() case ansi.HasCsiPrefix(seq): - c := ansi.Cmd(p.Cmd) + c := p.Cmd() intermed, marker, cmd := c.Intermediate(), c.Marker(), c.Command() fmt.Printf("CSI sequence: %q,", seq) if intermed != 0 { @@ -86,13 +85,13 @@ func main() { } fmt.Print(" params: [") var more bool - for i := 0; i < p.ParamsLen; i++ { - r := ansi.Param(p.Params[i]) - param, hasMore := r.Param(), r.HasMore() + params := p.Params() + for i, r := range params { + param, hasMore := r.Param(-1), r.HasMore() if hasMore && more != hasMore { fmt.Print("[") } - if param == parser.MissingParam { + if param == -1 { fmt.Print("MISSING") } else { fmt.Printf("%d", param) @@ -100,7 +99,7 @@ func main() { if !hasMore && more != hasMore { fmt.Print("]") } - if i != p.ParamsLen-1 { + if i != len(params)-1 { fmt.Print(", ") } more = hasMore @@ -110,7 +109,7 @@ func main() { case ansi.HasEscPrefix(seq): if !bytes.Equal(seq, []byte{ansi.ESC}) { - c := ansi.Cmd(p.Cmd) + c := p.Cmd() intermed, cmd := c.Intermediate(), c.Command() fmt.Printf("ESC sequence: %q", seq) if intermed != 0 { diff --git a/examples/vtimage/main.go b/examples/vtimage/main.go deleted file mode 100644 index cef3e839..00000000 --- a/examples/vtimage/main.go +++ /dev/null @@ -1,68 +0,0 @@ -package main - -import ( - "fmt" - "image" - "image/color" - "image/draw" - "image/png" - "log" - "os" - - "github.com/charmbracelet/x/vt" - "golang.org/x/image/font" - "golang.org/x/image/font/basicfont" - "golang.org/x/image/math/fixed" -) - -func drawChar(img *image.RGBA, x, y int, c color.Color, text string) { - point := fixed.Point26_6{fixed.Int26_6(x * 64), fixed.Int26_6(y * 64)} - if c == nil { - c = color.White - } - d := &font.Drawer{ - Dst: img, - Src: image.NewUniform(c), - Face: basicfont.Face7x13, - Dot: point, - } - d.DrawString(text) -} - -func main() { - vt := vt.NewTerminal(100, 25) - - for i := 0; i < 5; i++ { - _, err := fmt.Fprintf(vt, "\033[32mHello \033[%dmGolang\033[0m\r\n", 32+i) - if err != nil { - log.Fatal(err) - } - } - - rows, cols := vt.Height(), vt.Width() - img := image.NewRGBA(image.Rect(0, 0, cols*7, rows*13)) - draw.Draw(img, img.Bounds(), &image.Uniform{color.Black}, image.ZP, draw.Src) - - for row := 0; row < rows; row++ { - for col := 0; col < cols; col++ { - cell, ok := vt.At(col, row) - if !ok { - log.Printf("failed to get at %d %d", row, col) - } - txt := cell.Content - // fmt.Println(cell.Style.Fg()) - if len(txt) > 0 && txt[0] != 0 { - drawChar(img, (col+1)*7, (row+1)*13, cell.Style.Fg, txt) - } - } - } - f, err := os.Create("output.png") - if err != nil { - log.Fatal(err) - } - defer f.Close() - err = png.Encode(f, img) - if err != nil { - log.Fatal(err) - } -} diff --git a/go.work.sum b/go.work.sum index 36329e68..cc8f0a81 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,4 @@ +github.com/charmbracelet/colorprofile v0.1.6/go.mod h1:3EMXDxwRDJl0c17eJ1jX99MhtlP9OxE/9Qw0C5lvyUg= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= diff --git a/vt/buffer.go b/vt/buffer.go new file mode 100644 index 00000000..3dfac5df --- /dev/null +++ b/vt/buffer.go @@ -0,0 +1,413 @@ +package vt + +import ( + "github.com/charmbracelet/x/wcwidth" + "github.com/rivo/uniseg" +) + +// NewCell returns a new cell. This is a convenience function that initializes a +// new cell with the given content. The cell's width is determined by the +// content using [wcwidth.RuneWidth]. +func NewCell(r rune) *Cell { + return &Cell{Content: string(r), Width: wcwidth.RuneWidth(r)} +} + +// NewGraphemeCell returns a new cell. This is a convenience function that +// initializes a new cell with the given content. The cell's width is determined +// by the content using [uniseg.FirstGraphemeClusterInString]. +// This is used when the content is a grapheme cluster i.e. a sequence of runes +// that form a single visual unit. +// This will only return the first grapheme cluster in the string. If the +// string is empty, it will return an empty cell with a width of 0. +func NewGraphemeCell(s string) *Cell { + c, _, w, _ := uniseg.FirstGraphemeClusterInString(s, -1) + return &Cell{Content: c, Width: w} +} + +var blankCell = Cell{ + Content: " ", + Width: 1, +} + +// Line represents a line in the terminal. +// A nil cell represents an blank cell, a cell with a space character and a +// width of 1. +// If a cell has no content and a width of 0, it is a placeholder for a wide +// cell. +type Line []*Cell + +// Width returns the width of the line. +func (l Line) Width() int { + return len(l) +} + +// Buffer is a 2D grid of cells representing a screen or terminal. +type Buffer struct { + lines []Line +} + +// NewBuffer creates a new buffer with the given width and height. +// This is a convenience function that initializes a new buffer and resizes it. +func NewBuffer(width int, height int) *Buffer { + b := new(Buffer) + b.Resize(width, height) + return b +} + +// Cell implements Screen. +func (b *Buffer) Cell(x int, y int) *Cell { + if y < 0 || y >= len(b.lines) { + return nil + } + if x < 0 || x >= b.lines[y].Width() { + return nil + } + + c := b.lines[y][x] + if c == nil { + newCell := blankCell + return &newCell + } + + return c +} + +// Draw implements Screen. +func (b *Buffer) Draw(x int, y int, c Cell) bool { + return b.SetCell(x, y, &c) +} + +// maxCellWidth is the maximum width a terminal cell can get. +const maxCellWidth = 4 + +// SetCell sets the cell at the given x, y position. +func (b *Buffer) SetCell(x, y int, c *Cell) bool { + return b.setCell(x, y, c, true) +} + +// setCell sets the cell at the given x, y position. This will always clone and +// allocates a new cell if c is not nil. +func (b *Buffer) setCell(x, y int, c *Cell, clone bool) bool { + if y < 0 || y >= len(b.lines) { + return false + } + width := b.lines[y].Width() + if x < 0 || x >= width { + return false + } + + // When a wide cell is partially overwritten, we need + // to fill the rest of the cell with space cells to + // avoid rendering issues. + prev := b.Cell(x, y) + if prev != nil && prev.Width > 1 { + // Writing to the first wide cell + for j := 0; j < prev.Width && x+j < b.lines[y].Width(); j++ { + newCell := *prev + newCell.Content = " " + newCell.Width = 1 + b.lines[y][x+j] = &newCell + } + } else if prev != nil && prev.Width == 0 { + // Writing to wide cell placeholders + for j := 1; j < maxCellWidth && x-j >= 0; j++ { + wide := b.Cell(x-j, y) + if wide != nil && wide.Width > 1 { + for k := 0; k < wide.Width; k++ { + newCell := *wide + newCell.Content = " " + newCell.Width = 1 + b.lines[y][x-j+k] = &newCell + } + break + } + } + } + + if clone && c != nil { + // Clone the cell if not nil. + newCell := *c + c = &newCell + } + + if c != nil && x+c.Width > width { + // If the cell is too wide, we write blanks with the same style. + for i := 0; i < c.Width && x+i < width; i++ { + newCell := *c + newCell.Content = " " + newCell.Width = 1 + b.lines[y][x+i] = &newCell + } + } else { + b.lines[y][x] = c + + // Mark wide cells with an empty cell zero width + // We set the wide cell down below + if c != nil && c.Width > 1 { + for j := 1; j < c.Width && x+j < b.lines[y].Width(); j++ { + var wide Cell + b.lines[y][x+j] = &wide + } + } + } + + return true +} + +// Height implements Screen. +func (b *Buffer) Height() int { + return len(b.lines) +} + +// Width implements Screen. +func (b *Buffer) Width() int { + if len(b.lines) == 0 { + return 0 + } + return b.lines[0].Width() +} + +// Bounds returns the bounds of the buffer. +func (b *Buffer) Bounds() Rectangle { + return Rect(0, 0, b.Width(), b.Height()) +} + +// Resize resizes the buffer to the given width and height. +func (b *Buffer) Resize(width int, height int) { + if width == 0 || height == 0 { + b.lines = nil + return + } + + if width > b.Width() { + line := make(Line, width-b.Width()) + for i := range b.lines { + b.lines[i] = append(b.lines[i], line...) + } + } else if width < b.Width() { + for i := range b.lines { + b.lines[i] = b.lines[i][:width] + } + } + + if height > len(b.lines) { + for i := len(b.lines); i < height; i++ { + b.lines = append(b.lines, make(Line, width)) + } + } else if height < len(b.lines) { + b.lines = b.lines[:height] + } +} + +// fill fills the buffer with the given cell and rectangle. +func (b *Buffer) fill(c *Cell, rect Rectangle) { + cellWidth := 1 + if c != nil { + cellWidth = c.Width + } + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x += cellWidth { + b.setCell(x, y, c, true) //nolint:errcheck + } + } +} + +// Fill fills the buffer with the given cell and rectangle. +func (b *Buffer) Fill(c *Cell, rects ...Rectangle) { + if len(rects) == 0 { + b.fill(c, b.Bounds()) + return + } + for _, rect := range rects { + b.fill(c, rect) + } +} + +// Clear clears the buffer with space cells and rectangle. +func (b *Buffer) Clear(rects ...Rectangle) { + if len(rects) == 0 { + b.fill(nil, b.Bounds()) + return + } + for _, rect := range rects { + b.fill(nil, rect) + } +} + +// InsertLine inserts n lines at the given line position, with the given +// optional cell, within the specified rectangles. If no rectangles are +// specified, it inserts lines in the entire buffer. Only cells within the +// rectangle's horizontal bounds are affected. Lines are pushed out of the +// rectangle bounds and lost. This follows terminal [ansi.IL] behavior. +func (b *Buffer) InsertLine(y, n int, c *Cell, rects ...Rectangle) { + if len(rects) == 0 { + b.insertLineInRect(y, n, c, b.Bounds()) + } + for _, rect := range rects { + b.insertLineInRect(y, n, c, rect) + } +} + +// insertLineInRect inserts new lines at the given line position, with the +// given optional cell, within the rectangle bounds. Only cells within the +// rectangle's horizontal bounds are affected. Lines are pushed out of the +// rectangle bounds and lost. This follows terminal [ansi.IL] behavior. +func (b *Buffer) insertLineInRect(y, n int, c *Cell, rect Rectangle) { + if n <= 0 || y < rect.Min.Y || y >= rect.Max.Y || y >= b.Height() { + return + } + + // Limit number of lines to insert to available space + if y+n > rect.Max.Y { + n = rect.Max.Y - y + } + + // Move existing lines down within the bounds + for i := rect.Max.Y - 1; i >= y+n; i-- { + for x := rect.Min.X; x < rect.Max.X; x++ { + // We don't need to clone c here because we're just moving lines down. + // b.lines[i][x] = b.lines[i-n][x] + b.setCell(x, i, b.lines[i-n][x], false) + } + } + + // Clear the newly inserted lines within bounds + for i := y; i < y+n; i++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + b.setCell(x, i, c, true) + } + } +} + +// deleteLineInRect deletes lines at the given line position, with the given +// optional cell, within the rectangle bounds. Only cells within the +// rectangle's bounds are affected. Lines are shifted up within the bounds and +// new blank lines are created at the bottom. This follows terminal [ansi.DL] +// behavior. +func (b *Buffer) deleteLineInRect(y, n int, c *Cell, rect Rectangle) { + if n <= 0 || y < rect.Min.Y || y >= rect.Max.Y || y >= b.Height() { + return + } + + // Limit deletion count to available space in scroll region + if n > rect.Max.Y-y { + n = rect.Max.Y - y + } + + // Shift cells up within the bounds + for dst := y; dst < rect.Max.Y-n; dst++ { + src := dst + n + for x := rect.Min.X; x < rect.Max.X; x++ { + // We don't need to clone c here because we're just moving cells up. + // b.lines[dst][x] = b.lines[src][x] + b.setCell(x, dst, b.lines[src][x], false) + } + } + + // Fill the bottom n lines with blank cells + for i := rect.Max.Y - n; i < rect.Max.Y; i++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + b.setCell(x, i, c, true) + } + } +} + +// DeleteLine deletes n lines at the given line position, with the given +// optional cell, within the specified rectangles. If no rectangles are +// specified, it deletes lines in the entire buffer. +func (b *Buffer) DeleteLine(y, n int, c *Cell, rects ...Rectangle) { + if len(rects) == 0 { + b.deleteLineInRect(y, n, c, b.Bounds()) + return + } + for _, rect := range rects { + b.deleteLineInRect(y, n, c, rect) + } +} + +// InsertCell inserts new cells at the given position, with the given optional +// cell, within the specified rectangles. If no rectangles are specified, it +// inserts cells in the entire buffer. This follows terminal [ansi.ICH] +// behavior. +func (b *Buffer) InsertCell(x, y, n int, c *Cell, rects ...Rectangle) { + if len(rects) == 0 { + b.insertCellInRect(x, y, n, c, b.Bounds()) + return + } + for _, rect := range rects { + b.insertCellInRect(x, y, n, c, rect) + } +} + +// insertCellInRect inserts new cells at the given position, with the given +// optional cell, within the rectangle bounds. Only cells within the +// rectangle's bounds are affected, following terminal [ansi.ICH] behavior. +func (b *Buffer) insertCellInRect(x, y, n int, c *Cell, rect Rectangle) { + if n <= 0 || y < rect.Min.Y || y >= rect.Max.Y || y >= b.Height() || + x < rect.Min.X || x >= rect.Max.X || x >= b.Width() { + return + } + + // Limit number of cells to insert to available space + if x+n > rect.Max.X { + n = rect.Max.X - x + } + + // Move existing cells within rectangle bounds to the right + for i := rect.Max.X - 1; i >= x+n && i-n >= rect.Min.X; i-- { + // We don't need to clone c here because we're just moving cells to the + // right. + // b.lines[y][i] = b.lines[y][i-n] + b.setCell(i, y, b.lines[y][i-n], false) + } + + // Clear the newly inserted cells within rectangle bounds + for i := x; i < x+n && i < rect.Max.X; i++ { + b.setCell(i, y, c, true) + } +} + +// DeleteCell deletes cells at the given position, with the given optional +// cell, within the specified rectangles. If no rectangles are specified, it +// deletes cells in the entire buffer. This follows terminal [ansi.DCH] +// behavior. +func (b *Buffer) DeleteCell(x, y, n int, c *Cell, rects ...Rectangle) { + if len(rects) == 0 { + b.deleteCellInRect(x, y, n, c, b.Bounds()) + return + } + for _, rect := range rects { + b.deleteCellInRect(x, y, n, c, rect) + } +} + +// deleteCellInRect deletes cells at the given position, with the given +// optional cell, within the rectangle bounds. Only cells within the +// rectangle's bounds are affected, following terminal [ansi.DCH] behavior. +func (b *Buffer) deleteCellInRect(x, y, n int, c *Cell, rect Rectangle) { + if n <= 0 || y < rect.Min.Y || y >= rect.Max.Y || y >= b.Height() || + x < rect.Min.X || x >= rect.Max.X || x >= b.Width() { + return + } + + // Calculate how many positions we can actually delete + remainingCells := rect.Max.X - x + if n > remainingCells { + n = remainingCells + } + + // Shift the remaining cells to the left + for i := x; i < rect.Max.X-n; i++ { + if i+n < rect.Max.X { + // We don't need to clone c here because we're just moving cells to + // the left. + // b.lines[y][i] = b.lines[y][i+n] + b.setCell(i, y, b.lines[y][i+n], false) + } + } + + // Fill the vacated positions with the given cell + for i := rect.Max.X - n; i < rect.Max.X; i++ { + b.setCell(i, y, c, true) + } +} diff --git a/vt/buffer_test.go b/vt/buffer_test.go new file mode 100644 index 00000000..1bda601a --- /dev/null +++ b/vt/buffer_test.go @@ -0,0 +1,259 @@ +package vt + +import ( + "fmt" + "strings" + "testing" +) + +// TODO: Use golden files for these tests + +func TestBuffer_new(t *testing.T) { + t.Parallel() + + b := NewBuffer(10, 5) + if b == nil { + t.Error("expected buffer, got nil") + } + if b.Width() != 10 { + t.Errorf("expected width %d, got %d", 10, b.Width()) + } + if b.Height() != 5 { + t.Errorf("expected height %d, got %d", 5, b.Height()) + } +} + +func TestBuffer_setCell(t *testing.T) { + t.Parallel() + + b := NewBuffer(10, 5) + if !b.SetCell(0, 0, NewCell('a')) { + t.Error("expected SetCell to return true") + } + if cell := b.Cell(0, 0); cell == nil || cell.Content != "a" { + t.Errorf("expected cell at 0,0 to be 'a', got %v", cell) + } + + // Single rune emoji + if !b.SetCell(1, 0, NewCell('👍')) { + t.Error("expected SetCell to return true") + } + if cell := b.Cell(1, 0); cell == nil || cell.Content != "👍" || cell.Width != 2 { + t.Errorf("expected cell at 1,0 to be '👍', got %v", cell) + } + if cell := b.Cell(2, 0); cell == nil || cell.Content != "" || cell.Width != 0 { + t.Errorf("expected cell at 2,0 to be empty, got %v", cell) + } + + // Wide rune character + if !b.SetCell(3, 0, NewCell('あ')) { + t.Error("expected SetCell to return true") + } + if cell := b.Cell(3, 0); cell == nil || cell.Content != "あ" || cell.Width != 2 { + t.Errorf("expected cell at 3,0 to be 'あ', got %v", cell) + } + + // Overwrite a wide cell with a single rune + if !b.SetCell(3, 0, NewCell('b')) { + t.Error("expected SetCell to return true") + } + if cell := b.Cell(3, 0); cell == nil || cell.Content != "b" || cell.Width != 1 { + t.Errorf("expected cell at 3,0 to be 'b', got %v", cell) + } + if cell := b.Cell(4, 0); cell == nil || cell.Content != " " || cell.Width != 1 { + t.Errorf("expected cell at 4,0 to be blank, got %v", cell) + } + + // Overwrite a wide cell placeholder with a single rune + if !b.SetCell(3, 0, NewCell('あ')) { + t.Error("expected SetCell to return true") + } + if !b.SetCell(4, 0, NewCell('c')) { + t.Error("expected SetCell to return true") + } + if cell := b.Cell(3, 0); cell == nil || cell.Content != " " || cell.Width != 1 { + t.Errorf("expected cell at 3,0 to be 'あ', got %v", cell) + } + if cell := b.Cell(4, 0); cell == nil || cell.Content != "c" || cell.Width != 1 { + t.Errorf("expected cell at 4,0 to be 'c', got %v", cell) + } +} + +func TestBuffer_resize(t *testing.T) { + b := NewBuffer(10, 5) + b.SetCell(0, 0, NewCell('a')) + b.SetCell(1, 0, NewCell('b')) + b.SetCell(2, 0, NewCell('c')) + if b.Width() != 10 { + t.Errorf("expected width %d, got %d", 10, b.Width()) + } + if b.Height() != 5 { + t.Errorf("expected height %d, got %d", 5, b.Height()) + } + + b.Resize(5, 3) + if b.Width() != 5 { + t.Errorf("expected width %d, got %d", 5, b.Width()) + } + if b.Height() != 3 { + t.Errorf("expected height %d, got %d", 3, b.Height()) + } +} + +func TestBuffer_fill(t *testing.T) { + b := NewBuffer(10, 5) + b.Fill(NewCell('a')) + for y := 0; y < b.Height(); y++ { + for x := 0; x < b.Width(); x++ { + if cell := b.Cell(x, y); cell == nil || cell.Content != "a" || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be 'a', got %v", x, y, cell) + } + } + } +} + +func TestBuffer_clear(t *testing.T) { + b := NewBuffer(10, 5) + b.Fill(NewCell('a')) + b.Clear() + for y := 0; y < b.Height(); y++ { + for x := 0; x < b.Width(); x++ { + if cell := b.Cell(x, y); cell == nil || cell.Content != " " || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be blank, got %v", x, y, cell) + } + } + } +} + +func TestBuffer_fillClearRect(t *testing.T) { + b := NewBuffer(10, 5) + b.Fill(NewCell('a')) + r := Rect(1, 1, 3, 3) + b.Clear(r) + for y := 0; y < b.Height(); y++ { + for x := 0; x < b.Width(); x++ { + pt := Pos(x, y) + if r.Contains(pt) { + if cell := b.Cell(x, y); cell == nil || cell.Content != " " || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be blank, got %v", x, y, cell) + } + } else { + if cell := b.Cell(x, y); cell == nil || cell.Content != "a" || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be 'a', got %v", x, y, cell) + } + } + } + } +} + +func TestBuffer_insertLine(t *testing.T) { + b := NewBuffer(10, 5) + b.Fill(NewCell('a')) + b.InsertLine(1, 1, nil) + for y := 0; y < b.Height(); y++ { + for x := 0; x < b.Width(); x++ { + if y == 1 { + if cell := b.Cell(x, y); cell == nil || cell.Content != " " || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be blank, got %v", x, y, cell) + } + } else { + if cell := b.Cell(x, y); cell == nil || cell.Content != "a" || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be 'a', got %v", x, y, cell) + } + } + } + } + + t.Log("\n" + renderBuffer(b)) +} + +func TestBuffer_insertLineInRect(t *testing.T) { + b := NewBuffer(10, 5) + b.Fill(NewCell('a')) + r := Rect(1, 1, 3, 3) + n := 2 // The number of lines to insert + b.InsertLine(1, n, nil, r) // Insert n lines at y=1 within the rectangle r + for y := 0; y < b.Height(); y++ { + for x := 0; x < b.Width(); x++ { + pt := Pos(x, y) + if r.Contains(pt) && y >= 1 && y < 1+n { + if cell := b.Cell(x, y); cell == nil || cell.Content != " " || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be blank, got %v", x, y, cell) + } + } else { + if cell := b.Cell(x, y); cell == nil || cell.Content != "a" || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be 'a', got %v", x, y, cell) + } + } + } + } + + t.Log("\n" + renderBuffer(b)) +} + +func TestBuffer_deleteLine(t *testing.T) { + b := NewBuffer(10, 5) + b.Fill(NewCell('a')) + b.Fill(NewCell('b'), Rect(0, 1, 10, 1)) + t.Log("\n" + renderBuffer(b)) + + b.DeleteLine(1, 1, nil) + if b.Height() != 5 { + t.Error("expected height to be less than 5") + } + for y := 0; y < b.Height(); y++ { + for x := 0; x < b.Width(); x++ { + if y == b.Height()-1 { + if cell := b.Cell(x, y); cell == nil || cell.Content != " " || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be blank, got %v", x, y, cell) + } + } else { + if cell := b.Cell(x, y); cell == nil || cell.Content != "a" || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be 'a', got %v", x, y, cell) + } + } + } + } + + t.Log("\n" + renderBuffer(b)) +} + +func TestBuffer_deleteLineInRect(t *testing.T) { + b := NewBuffer(10, 5) + b.Fill(NewCell('a')) + t.Log("\n" + renderBuffer(b)) + r := Rect(1, 1, 3, 3) + n := 2 // The number of lines to delete + b.DeleteLine(1, n, nil, r) // Delete n lines at y=1 within the rectangle r + t.Log("\n" + renderBuffer(b)) + for y := r.Max.Y - 1; y < r.Height(); y++ { + for x := 0; x < b.Width(); x++ { + pt := Pos(x, y) + if r.Contains(pt) && y >= 1 && y < 1+n { + if cell := b.Cell(x, y); cell == nil || cell.Content != " " || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be blank, got %v", x, y, cell) + } + } else { + if cell := b.Cell(x, y); cell == nil || cell.Content != "a" || cell.Width != 1 { + t.Errorf("expected cell at %d,%d to be 'a', got %v", x, y, cell) + } + } + } + } +} + +func renderBuffer(b *Buffer) string { + var out strings.Builder + for y := 0; y < b.Height(); y++ { + var line string + for x := 0; x < b.Width(); x++ { + cell := b.Cell(x, y) + if cell == nil { + cell = NewCell(' ') + } + line += cell.Content + } + out.WriteString(fmt.Sprintf("%q\n", line)) + } + return out.String() +} diff --git a/vt/c0.go b/vt/c0.go deleted file mode 100644 index 8edc9461..00000000 --- a/vt/c0.go +++ /dev/null @@ -1,28 +0,0 @@ -package vt - -import "github.com/charmbracelet/x/ansi" - -// handleControl handles a control character. -func (t *Terminal) handleControl(r rune) { - switch r { - case ansi.BEL: // BEL - Bell - if t.Bell != nil { - t.Bell() - } - case ansi.BS: // BS - Backspace - if t.scr.cur.Pos.X > 0 { - t.scr.cur.Pos.X-- - } - case ansi.HT: // HT - Horizontal Tab - t.scr.cur.Pos.X = t.scr.tabstops.Next(t.scr.cur.Pos.X) - case ansi.LF, ansi.FF, ansi.VT: - // LF - Line Feed - // FF - Form Feed - // VT - Vertical Tab - if t.scr.cur.Pos.Y < t.scr.Height()-1 { - t.scr.cur.Pos.Y++ - } - case ansi.CR: // CR - Carriage Return - t.scr.cur.Pos.X = 0 - } -} diff --git a/vt/callbacks.go b/vt/callbacks.go new file mode 100644 index 00000000..1eb6b042 --- /dev/null +++ b/vt/callbacks.go @@ -0,0 +1,36 @@ +package vt + +// Callbacks represents a set of callbacks for a terminal. +type Callbacks struct { + // Bell callback. When set, this function is called when a bell character is + // received. + Bell func() + + // Damage callback. When set, this function is called when a cell is damaged + // or changed. + Damage func(Damage) + + // Title callback. When set, this function is called when the terminal title + // changes. + Title func(string) + + // IconName callback. When set, this function is called when the terminal + // icon name changes. + IconName func(string) + + // AltScreen callback. When set, this function is called when the alternate + // screen is activated or deactivated. + AltScreen func(bool) + + // CursorPosition callback. When set, this function is called when the cursor + // position changes. + CursorPosition func(old, new Position) //nolint:predeclared + + // CursorVisibility callback. When set, this function is called when the + // cursor visibility changes. + CursorVisibility func(visible bool) + + // CursorStyle callback. When set, this function is called when the cursor + // style changes. + CursorStyle func(style CursorStyle, blink bool) +} diff --git a/vt/cc.go b/vt/cc.go new file mode 100644 index 00000000..a595e5ec --- /dev/null +++ b/vt/cc.go @@ -0,0 +1,86 @@ +package vt + +import ( + "github.com/charmbracelet/x/ansi" +) + +// handleControl handles a control character. +func (t *Terminal) handleControl(r ansi.ControlCode) { + switch r { + case ansi.NUL: // Null [ansi.NUL] + // Ignored + case ansi.BEL: // Bell [ansi.BEL] + if t.Callbacks.Bell != nil { + t.Callbacks.Bell() + } + case ansi.BS: // Backspace [ansi.BS] + // This acts like [ansi.CUB] + t.moveCursor(-1, 0) + case ansi.HT: // Horizontal Tab [ansi.HT] + t.nextTab(1) + case ansi.VT: // Vertical Tab [ansi.VT] + fallthrough + case ansi.FF: // Form Feed [ansi.FF] + fallthrough + case ansi.LF: // Line Feed [ansi.LF] + t.linefeed() + case ansi.CR: // Carriage Return [ansi.CR] + t.carriageReturn() + case ansi.HTS: // Horizontal Tab Set [ansi.HTS] + t.horizontalTabSet() + case ansi.RI: // Reverse Index [ansi.RI] + t.reverseIndex() + case ansi.SO: // Shift Out [ansi.SO] + t.gl = 1 + case ansi.SI: // Shift In [ansi.SI] + t.gl = 0 + case ansi.IND: // Index [ansi.IND] + t.index() + case ansi.SS2: // Single Shift 2 [ansi.SS2] + t.gsingle = 2 + case ansi.SS3: // Single Shift 3 [ansi.SS3] + t.gsingle = 3 + default: + t.logf("unhandled control: %q", r) + } +} + +// linefeed is the same as [index], except that it respects [ansi.LNM] mode. +func (t *Terminal) linefeed() { + t.index() + if t.isModeSet(ansi.LineFeedNewLineMode) { + t.carriageReturn() + } +} + +// index moves the cursor down one line, scrolling up if necessary. This +// always resets the phantom state i.e. pending wrap state. +func (t *Terminal) index() { + x, y := t.scr.CursorPosition() + scroll := t.scr.ScrollRegion() + // TODO: Handle scrollback whenever we add it. + if y == scroll.Max.Y-1 && x >= scroll.Min.X && x < scroll.Max.X { + t.scr.ScrollUp(1) + } else if y < scroll.Max.Y-1 || !scroll.Contains(Pos(x, y)) { + t.scr.moveCursor(0, 1) + } + t.atPhantom = false +} + +// horizontalTabSet sets a horizontal tab stop at the current cursor position. +func (t *Terminal) horizontalTabSet() { + x, _ := t.scr.CursorPosition() + t.tabstops.Set(x) +} + +// reverseIndex moves the cursor up one line, or scrolling down. This does not +// reset the phantom state i.e. pending wrap state. +func (t *Terminal) reverseIndex() { + x, y := t.scr.CursorPosition() + scroll := t.scr.ScrollRegion() + if y == scroll.Min.Y && x >= scroll.Min.X && x < scroll.Max.X { + t.scr.ScrollDown(1) + } else { + t.scr.moveCursor(0, -1) + } +} diff --git a/vt/cell.go b/vt/cell.go new file mode 100644 index 00000000..78253aab --- /dev/null +++ b/vt/cell.go @@ -0,0 +1,440 @@ +package vt + +import ( + "github.com/charmbracelet/x/ansi" +) + +// Cell represents a single cell in the terminal screen. +type Cell struct { + // The style of the cell. Nil style means no style. Zero value prints a + // reset sequence. + Style Style + + // Link is the hyperlink of the cell. + Link Link + + // Content is the string representation of the cell as a grapheme cluster. + Content string + + // Width is the mono-space width of the grapheme cluster. + Width int +} + +// Equal returns whether the cell is equal to the other cell. +func (c Cell) Equal(o *Cell) bool { + return o != nil && + c.Width == o.Width && + c.Content == o.Content && + c.Style.Equal(o.Style) && + c.Link.Equal(o.Link) +} + +// Empty returns whether the cell is empty. +func (c Cell) Empty() bool { + return c.Content == "" && + c.Width == 0 && + c.Style.Empty() && + c.Link.Empty() +} + +// Reset resets the cell to the default state zero value. +func (c *Cell) Reset() { + c.Content = "" + c.Width = 0 + c.Style.Reset() + c.Link.Reset() +} + +// Link represents a hyperlink in the terminal screen. +type Link struct { + URL string + URLID string +} + +// String returns a string representation of the hyperlink. +func (h Link) String() string { + return h.URL +} + +// Reset resets the hyperlink to the default state zero value. +func (h *Link) Reset() { + h.URL = "" + h.URLID = "" +} + +// Equal returns whether the hyperlink is equal to the other hyperlink. +func (h Link) Equal(o Link) bool { + return h == o +} + +// Empty returns whether the hyperlink is empty. +func (h Link) Empty() bool { + return h.URL == "" && h.URLID == "" +} + +// AttrMask is a bitmask for text attributes that can change the look of text. +// These attributes can be combined to create different styles. +type AttrMask uint8 + +// These are the available text attributes that can be combined to create +// different styles. +const ( + BoldAttr AttrMask = 1 << iota + FaintAttr + ItalicAttr + SlowBlinkAttr + RapidBlinkAttr + ReverseAttr + ConcealAttr + StrikethroughAttr + + ResetAttr AttrMask = 0 +) + +var attrMaskNames = map[AttrMask]string{ + BoldAttr: "BoldAttr", + FaintAttr: "FaintAttr", + ItalicAttr: "ItalicAttr", + SlowBlinkAttr: "SlowBlinkAttr", + RapidBlinkAttr: "RapidBlinkAttr", + ReverseAttr: "ReverseAttr", + ConcealAttr: "ConcealAttr", + StrikethroughAttr: "StrikethroughAttr", + ResetAttr: "ResetAttr", +} + +// UnderlineStyle is the style of underline to use for text. +type UnderlineStyle uint8 + +// These are the available underline styles. +const ( + NoUnderline UnderlineStyle = iota + SingleUnderline + DoubleUnderline + CurlyUnderline + DottedUnderline + DashedUnderline +) + +var underlineStyleNames = map[UnderlineStyle]string{ + NoUnderline: "NoUnderline", + SingleUnderline: "SingleUnderline", + DoubleUnderline: "DoubleUnderline", + CurlyUnderline: "CurlyUnderline", + DottedUnderline: "DottedUnderline", + DashedUnderline: "DashedUnderline", +} + +// String returns a string representation of the underline style. +func (u UnderlineStyle) String() string { + return underlineStyleNames[u] +} + +// Style represents the Style of a cell. +type Style struct { + Fg ansi.Color + Bg ansi.Color + Ul ansi.Color + Attrs AttrMask + UlStyle UnderlineStyle +} + +// Sequence returns the ANSI sequence that sets the style. +func (s Style) Sequence() string { + if s.Empty() { + return ansi.ResetStyle + } + + var b ansi.Style + + if s.Attrs != 0 { + if s.Attrs&BoldAttr != 0 { + b = b.Bold() + } + if s.Attrs&FaintAttr != 0 { + b = b.Faint() + } + if s.Attrs&ItalicAttr != 0 { + b = b.Italic() + } + if s.Attrs&SlowBlinkAttr != 0 { + b = b.SlowBlink() + } + if s.Attrs&RapidBlinkAttr != 0 { + b = b.RapidBlink() + } + if s.Attrs&ReverseAttr != 0 { + b = b.Reverse() + } + if s.Attrs&ConcealAttr != 0 { + b = b.Conceal() + } + if s.Attrs&StrikethroughAttr != 0 { + b = b.Strikethrough() + } + } + if s.UlStyle != NoUnderline { + switch s.UlStyle { + case SingleUnderline: + b = b.Underline() + case DoubleUnderline: + b = b.DoubleUnderline() + case CurlyUnderline: + b = b.CurlyUnderline() + case DottedUnderline: + b = b.DottedUnderline() + case DashedUnderline: + b = b.DashedUnderline() + } + } + if s.Fg != nil { + b = b.ForegroundColor(s.Fg) + } + if s.Bg != nil { + b = b.BackgroundColor(s.Bg) + } + if s.Ul != nil { + b = b.UnderlineColor(s.Ul) + } + + return b.String() +} + +// DiffSequence returns the ANSI sequence that sets the style as a diff from +// another style. +func (s Style) DiffSequence(o Style) string { + if o.Empty() { + return s.Sequence() + } + + var b ansi.Style + + if !colorEqual(s.Fg, o.Fg) { + b = b.ForegroundColor(s.Fg) + } + + if !colorEqual(s.Bg, o.Bg) { + b = b.BackgroundColor(s.Bg) + } + + if !colorEqual(s.Ul, o.Ul) { + b = b.UnderlineColor(s.Ul) + } + + var ( + noBlink bool + isNormal bool + ) + + if s.Attrs != o.Attrs { + if s.Attrs&BoldAttr != o.Attrs&BoldAttr { + if s.Attrs&BoldAttr != 0 { + b = b.Bold() + } else if !isNormal { + isNormal = true + b = b.NormalIntensity() + } + } + if s.Attrs&FaintAttr != o.Attrs&FaintAttr { + if s.Attrs&FaintAttr != 0 { + b = b.Faint() + } else if !isNormal { + isNormal = true + b = b.NormalIntensity() + } + } + if s.Attrs&ItalicAttr != o.Attrs&ItalicAttr { + if s.Attrs&ItalicAttr != 0 { + b = b.Italic() + } else { + b = b.NoItalic() + } + } + if s.Attrs&SlowBlinkAttr != o.Attrs&SlowBlinkAttr { + if s.Attrs&SlowBlinkAttr != 0 { + b = b.SlowBlink() + } else if !noBlink { + b = b.NoBlink() + } + } + if s.Attrs&RapidBlinkAttr != o.Attrs&RapidBlinkAttr { + if s.Attrs&RapidBlinkAttr != 0 { + b = b.RapidBlink() + } else if !noBlink { + b = b.NoBlink() + } + } + if s.Attrs&ReverseAttr != o.Attrs&ReverseAttr { + if s.Attrs&ReverseAttr != 0 { + b = b.Reverse() + } else { + b = b.NoReverse() + } + } + if s.Attrs&ConcealAttr != o.Attrs&ConcealAttr { + if s.Attrs&ConcealAttr != 0 { + b = b.Conceal() + } else { + b = b.NoConceal() + } + } + if s.Attrs&StrikethroughAttr != o.Attrs&StrikethroughAttr { + if s.Attrs&StrikethroughAttr != 0 { + b = b.Strikethrough() + } else { + b = b.NoStrikethrough() + } + } + } + + return b.String() +} + +// Equal returns true if the style is equal to the other style. +func (s Style) Equal(o Style) bool { + return colorEqual(s.Fg, o.Fg) && + colorEqual(s.Bg, o.Bg) && + colorEqual(s.Ul, o.Ul) && + s.Attrs == o.Attrs && + s.UlStyle == o.UlStyle +} + +func colorEqual(c, o ansi.Color) bool { + if c == nil && o == nil { + return true + } + if c == nil || o == nil { + return false + } + cr, cg, cb, ca := c.RGBA() + or, og, ob, oa := o.RGBA() + return cr == or && cg == og && cb == ob && ca == oa +} + +// Bold sets the bold attribute. +func (s *Style) Bold(v bool) *Style { + if v { + s.Attrs |= BoldAttr + } else { + s.Attrs &^= BoldAttr + } + return s +} + +// Faint sets the faint attribute. +func (s *Style) Faint(v bool) *Style { + if v { + s.Attrs |= FaintAttr + } else { + s.Attrs &^= FaintAttr + } + return s +} + +// Italic sets the italic attribute. +func (s *Style) Italic(v bool) *Style { + if v { + s.Attrs |= ItalicAttr + } else { + s.Attrs &^= ItalicAttr + } + return s +} + +// SlowBlink sets the slow blink attribute. +func (s *Style) SlowBlink(v bool) *Style { + if v { + s.Attrs |= SlowBlinkAttr + } else { + s.Attrs &^= SlowBlinkAttr + } + return s +} + +// RapidBlink sets the rapid blink attribute. +func (s *Style) RapidBlink(v bool) *Style { + if v { + s.Attrs |= RapidBlinkAttr + } else { + s.Attrs &^= RapidBlinkAttr + } + return s +} + +// Reverse sets the reverse attribute. +func (s *Style) Reverse(v bool) *Style { + if v { + s.Attrs |= ReverseAttr + } else { + s.Attrs &^= ReverseAttr + } + return s +} + +// Conceal sets the conceal attribute. +func (s *Style) Conceal(v bool) *Style { + if v { + s.Attrs |= ConcealAttr + } else { + s.Attrs &^= ConcealAttr + } + return s +} + +// Strikethrough sets the strikethrough attribute. +func (s *Style) Strikethrough(v bool) *Style { + if v { + s.Attrs |= StrikethroughAttr + } else { + s.Attrs &^= StrikethroughAttr + } + return s +} + +// UnderlineStyle sets the underline style. +func (s *Style) UnderlineStyle(style UnderlineStyle) *Style { + s.UlStyle = style + return s +} + +// Underline sets the underline attribute. +// This is a syntactic sugar for [UnderlineStyle]. +func (s *Style) Underline(v bool) *Style { + if v { + return s.UnderlineStyle(SingleUnderline) + } + return s.UnderlineStyle(NoUnderline) +} + +// Foreground sets the foreground color. +func (s *Style) Foreground(c ansi.Color) *Style { + s.Fg = c + return s +} + +// Background sets the background color. +func (s *Style) Background(c ansi.Color) *Style { + s.Bg = c + return s +} + +// UnderlineColor sets the underline color. +func (s *Style) UnderlineColor(c ansi.Color) *Style { + s.Ul = c + return s +} + +// Reset resets the style to default. +func (s *Style) Reset() *Style { + s.Fg = nil + s.Bg = nil + s.Ul = nil + s.Attrs = ResetAttr + s.UlStyle = NoUnderline + return s +} + +// Empty returns true if the style is empty. +func (s *Style) Empty() bool { + return s.Fg == nil && s.Bg == nil && s.Ul == nil && s.Attrs == ResetAttr && s.UlStyle == NoUnderline +} diff --git a/vt/charset.go b/vt/charset.go new file mode 100644 index 00000000..c437ba0c --- /dev/null +++ b/vt/charset.go @@ -0,0 +1,45 @@ +package vt + +// CharSet represents a character set designator. +// This can be used to select a character set for G0 or G1 and others. +type CharSet map[byte]string + +// Character sets. +var ( + UK = CharSet{ + '$': "£", // U+00A3 + } + SpecialDrawing = CharSet{ + '`': "◆", // U+25C6 + 'a': "▒", // U+2592 + 'b': "␉", // U+2409 + 'c': "␌", // U+240C + 'd': "␍", // U+240D + 'e': "␊", // U+240A + 'f': "°", // U+00B0 + 'g': "±", // U+00B1 + 'h': "␤", // U+2424 + 'i': "␋", // U+240B + 'j': "┘", // U+2518 + 'k': "┐", // U+2510 + 'l': "┌", // U+250C + 'm': "└", // U+2514 + 'n': "┼", // U+253C + 'o': "⎺", // U+23BA + 'p': "⎻", // U+23BB + 'q': "─", // U+2500 + 'r': "⎼", // U+23BC + 's': "⎽", // U+23BD + 't': "├", // U+251C + 'u': "┤", // U+2524 + 'v': "┴", // U+2534 + 'w': "┬", // U+252C + 'x': "│", // U+2502 + 'y': "⩽", // U+2A7D + 'z': "⩾", // U+2A7E + '{': "π", // U+03C0 + '|': "≠", // U+2260 + '}': "£", // U+00A3 + '~': "·", // U+00B7 + } +) diff --git a/vt/csi.go b/vt/csi.go index 23f41969..707565f3 100644 --- a/vt/csi.go +++ b/vt/csi.go @@ -1,346 +1,134 @@ package vt import ( - "image/color" - "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/ansi/parser" - "github.com/charmbracelet/x/cellbuf" ) -var spaceCell = cellbuf.Cell{ - Content: " ", - Width: 1, -} - // handleCsi handles a CSI escape sequences. -func (t *Terminal) handleCsi(seq []byte) { - // params := t.parser.Params[:t.parser.ParamsLen] - cmd := t.parser.Cmd - switch cmd { // cursor - case 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'X', 'd', 'e': +func (t *Terminal) handleCsi(seq ansi.CsiSequence) { + switch cmd := t.parser.Cmd(); cmd { // cursor + case 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'Z', 'a', 'b', 'd', 'e', 'f', '`': t.handleCursor() - case 'm': // SGR - Select Graphic Rendition + case 'm': // Select Graphic Rendition [ansi.SGR] t.handleSgr() - case 'J': + case 'J', 'L', 'M', 'X', 'r', 's': t.handleScreen() - case 'K', 'L', 'M', 'S', 'T': + case 'K', 'S', 'T': t.handleLine() - case 'l', 'h', 'l' | '?'< 0 { - count = ansi.Param(t.parser.Params[0]).Param() - } - - scr := t.scr - cur := scr.Cursor() - w, h := scr.Width(), scr.Height() - _, y := cur.Pos.X, cur.Pos.Y - - cmd := ansi.Cmd(t.parser.Cmd) - switch cmd.Command() { - case 'J': - switch count { - case 0: // Erase screen below (including cursor) - t.scr.Clear(&cellbuf.Rectangle{X: 0, Y: y, Width: w, Height: h - y}) - case 1: // Erase screen above (including cursor) - t.scr.Clear(&cellbuf.Rectangle{X: 0, Y: 0, Width: w, Height: y + 1}) - case 2: // erase screen - fallthrough - case 3: // erase display - // TODO: Scrollback buffer support? - t.scr.Clear(&cellbuf.Rectangle{X: 0, Y: 0, Width: w, Height: h}) + case ansi.Cmd(0, ' ', 'q'): // Set Cursor Style [ansi.DECSCUSR] + style := 1 + if param, ok := t.parser.Param(0, 0); ok { + style = param + } + t.scr.setCursorStyle(CursorStyle((style/2)+1), style%2 == 1) + case 'g': // Tab Clear [ansi.TBC] + var value int + if param, ok := t.parser.Param(0, 0); ok { + value = param } - } -} -func (t *Terminal) handleLine() { - var count int - if t.parser.ParamsLen > 0 { - count = ansi.Param(t.parser.Params[0]).Param() - } + switch value { + case 0: + x, _ := t.scr.CursorPosition() + t.tabstops.Reset(x) + case 3: + t.tabstops.Clear() + } + case '@': // Insert Character [ansi.ICH] + n, _ := t.parser.Param(0, 1) + if n == 0 { + n = 1 + } - cmd := ansi.Cmd(t.parser.Cmd) - switch cmd.Command() { - case 'K': - // NOTE: Erase Line (EL) is a bit confusing. Erasing the line erases - // the characters on the line while applying the cursor pen style - // like background color and so on. The cursor position is not changed. - cur := t.scr.Cursor() - x, y := cur.Pos.X, cur.Pos.Y - w := t.scr.Width() - switch count { - case 0: // Erase from cursor to end of line - c := spaceCell - c.Style = t.scr.cur.Pen - t.scr.Fill(c, &cellbuf.Rectangle{X: x, Y: y, Width: w - x, Height: 1}) - case 1: // Erase from start of line to cursor - c := spaceCell - c.Style = t.scr.cur.Pen - t.scr.Fill(c, &cellbuf.Rectangle{X: 0, Y: y, Width: x + 1, Height: 1}) - case 2: // Erase entire line - c := spaceCell - c.Style = t.scr.cur.Pen - t.scr.Fill(c, &cellbuf.Rectangle{X: 0, Y: y, Width: w, Height: 1}) + t.scr.InsertCell(n) + case 'P': // Delete Character [ansi.DCH] + n, _ := t.parser.Param(0, 1) + if n == 0 { + n = 1 } - case 'L': // TODO: insert n blank lines - case 'M': // TODO: delete n lines - case 'S': // TODO: scroll up n lines - case 'T': // TODO: scroll down n lines - } -} -func (t *Terminal) handleCursor() { - p := t.parser - width, height := t.scr.Width(), t.scr.Height() - cmd := ansi.Cmd(p.Cmd) - n := 1 - if p.ParamsLen > 0 { - n = p.Params[0] - } + t.scr.DeleteCell(n) - x, y := t.scr.cur.Pos.X, t.scr.cur.Pos.Y - switch cmd.Command() { - case 'A': - // CUU - Cursor Up - y = max(0, y-n) - case 'B': - // CUD - Cursor Down - y = min(height-1, y+n) - case 'C': - // CUF - Cursor Forward - x = min(width-1, x+n) - case 'D': - // CUB - Cursor Back - x = max(0, x-n) - case 'E': - // CNL - Cursor Next Line - y = min(height-1, y+n) - x = 0 - case 'F': - // CPL - Cursor Previous Line - y = max(0, y-n) - x = 0 - case 'G': - // CHA - Cursor Character Absolute - x = min(width-1, max(0, n-1)) - case 'H', 'f': - // CUP - Cursor Position - // HVP - Horizontal and Vertical Position - if p.ParamsLen >= 2 { - row, col := ansi.Param(p.Params[0]).Param(), ansi.Param(p.Params[1]).Param() - y = min(height-1, max(0, row-1)) - x = min(width-1, max(0, col-1)) - } else { - x, y = 0, 0 + case 'c': // Primary Device Attributes [ansi.DA1] + n, _ := t.parser.Param(0, 0) + if n != 0 { + break } - case 'I': - // CHT - Cursor Forward Tabulation - for i := 0; i < n; i++ { - x = t.scr.tabstops.Next(x) + + // Do we fully support VT220? + t.buf.WriteString(ansi.PrimaryDeviceAttributes( + 62, // VT220 + 1, // 132 columns + 6, // Selective Erase + 22, // ANSI color + )) + + case ansi.Cmd('>', 0, 'c'): // Secondary Device Attributes [ansi.DA2] + n, _ := t.parser.Param(0, 0) + if n != 0 { + break } - case 'X': - // ECH - Erase Character - c := spaceCell - c.Style = t.scr.cur.Pen - t.scr.Fill(c, &cellbuf.Rectangle{X: x, Y: y, Width: n, Height: 1}) - x = min(width-1, x+n) - case 'd': - // VPA - Vertical Line Position Absolute - y = min(height-1, max(0, n-1)) - case 'e': - // VPR - Vertical Line Position Relative - y = min(height-1, max(0, y+n)) - } - t.scr.moveCursor(x, y) -} -// handleSgr handles SGR escape sequences. -// handleSgr handles Select Graphic Rendition (SGR) escape sequences. -func (t *Terminal) handleSgr() { - p, pen := t.parser, &t.scr.cur.Pen - if p.ParamsLen == 0 { - pen.Reset() - return - } + // Do we fully support VT220? + t.buf.WriteString(ansi.SecondaryDeviceAttributes( + 1, // VT220 + 10, // Version 1.0 + 0, // ROM Cartridge is always zero + )) + + case 'n': // Device Status Report [ansi.DSR] + n, ok := t.parser.Param(0, 1) + if !ok || n == 0 { + break + } - params := p.Params[:p.ParamsLen] - for i := 0; i < len(params); i++ { - r := ansi.Param(params[i]) - param, hasMore := r.Param(), r.HasMore() // Are there more subparameters i.e. separated by ":"? - switch param { - case 0: // Reset - pen.Reset() - case 1: // Bold - pen.Bold(true) - case 2: // Dim/Faint - pen.Faint(true) - case 3: // Italic - pen.Italic(true) - case 4: // Underline - if hasMore { // Only accept subparameters i.e. separated by ":" - nextParam := ansi.Param(params[i+1]).Param() - switch nextParam { - case 0: // No Underline - pen.UnderlineStyle(cellbuf.NoUnderline) - case 1: // Single Underline - pen.UnderlineStyle(cellbuf.SingleUnderline) - case 2: // Double Underline - pen.UnderlineStyle(cellbuf.DoubleUnderline) - case 3: // Curly Underline - pen.UnderlineStyle(cellbuf.CurlyUnderline) - case 4: // Dotted Underline - pen.UnderlineStyle(cellbuf.DottedUnderline) - case 5: // Dashed Underline - pen.UnderlineStyle(cellbuf.DashedUnderline) - } - } else { - // Single Underline - pen.Underline(true) - } - case 5: // Slow Blink - pen.SlowBlink(true) - case 6: // Rapid Blink - pen.RapidBlink(true) - case 7: // Reverse - pen.Reverse(true) - case 8: // Conceal - pen.Conceal(true) - case 9: // Crossed-out/Strikethrough - pen.Strikethrough(true) - case 22: // Normal Intensity (not bold or faint) - pen.Bold(false).Faint(false) - case 23: // Not italic, not Fraktur - pen.Italic(false) - case 24: // Not underlined - pen.Underline(false) - case 25: // Blink off - pen.SlowBlink(false).RapidBlink(false) - case 27: // Positive (not reverse) - pen.Reverse(false) - case 28: // Reveal - pen.Conceal(false) - case 29: // Not crossed out - pen.Strikethrough(false) - case 30, 31, 32, 33, 34, 35, 36, 37: // Set foreground - pen.Foreground(ansi.Black + ansi.BasicColor(param-30)) //nolint:gosec - case 38: // Set foreground 256 or truecolor - if c := readColor(&i, params); c != nil { - pen.Foreground(c) - } - case 39: // Default foreground - pen.Foreground(nil) - case 40, 41, 42, 43, 44, 45, 46, 47: // Set background - pen.Background(ansi.Black + ansi.BasicColor(param-40)) //nolint:gosec - case 48: // Set background 256 or truecolor - if c := readColor(&i, params); c != nil { - pen.Background(c) - } - case 49: // Default Background - pen.Background(nil) - case 58: // Set underline color - if c := readColor(&i, params); c != nil { - pen.UnderlineColor(c) - } - case 59: // Default underline color - pen.UnderlineColor(nil) - case 90, 91, 92, 93, 94, 95, 96, 97: // Set bright foreground - pen.Foreground(ansi.BrightBlack + ansi.BasicColor(param-90)) //nolint:gosec - case 100, 101, 102, 103, 104, 105, 106, 107: // Set bright background - pen.Background(ansi.BrightBlack + ansi.BasicColor(param-100)) //nolint:gosec + switch n { + case 5: // Operating Status + // We're always ready ;) + // See: https://vt100.net/docs/vt510-rm/DSR-OS.html + t.buf.WriteString(ansi.DeviceStatusReport(ansi.DECStatus(0))) + case 6: // Cursor Position Report [ansi.CPR] + x, y := t.scr.CursorPosition() + t.buf.WriteString(ansi.CursorPositionReport(x+1, y+1)) } - } -} -func readColor(idxp *int, params []int) (c ansi.Color) { - i := *idxp - paramsLen := len(params) - if i > paramsLen-1 { - return - } - // Note: we accept both main and subparams here - switch param := ansi.Param(params[i+1]); param { - case 2: // RGB - if i > paramsLen-4 { - return + case ansi.Cmd('?', 0, 'n'): // Device Status Report (DEC) [ansi.DSR] + n, ok := t.parser.Param(0, 1) + if !ok || n == 0 { + break } - c = color.RGBA{ - R: uint8(ansi.Param(params[i+2])), //nolint:gosec - G: uint8(ansi.Param(params[i+3])), //nolint:gosec - B: uint8(ansi.Param(params[i+4])), //nolint:gosec - A: 0xff, + + switch n { + case 6: // Extended Cursor Position Report [ansi.DECXCPR] + x, y := t.scr.CursorPosition() + t.buf.WriteString(ansi.ExtendedCursorPositionReport(x+1, y+1, 0)) // We don't support page numbers } - *idxp += 4 - case 5: // 256 colors - if i > paramsLen-2 { - return + + case ansi.Cmd(0, '$', 'p'): // Request Mode [ansi.DECRQM] + fallthrough + case ansi.Cmd('?', '$', 'p'): // Request Mode (DEC) [ansi.DECRQM] + n, ok := t.parser.Param(0, 0) + if !ok || n == 0 { + break } - c = ansi.ExtendedColor(ansi.Param(params[i+2])) //nolint:gosec - *idxp += 2 - } - return -} -func max(a, b int) int { - if a > b { - return a - } - return b -} + var mode ansi.Mode = ansi.ANSIMode(n) + if cmd.Marker() == '?' { + mode = ansi.DECMode(n) + } -func min(a, b int) int { - if a > b { - return b - } - return a -} + setting := t.modes[mode] + t.buf.WriteString(ansi.ReportMode(mode, setting)) -func clamp(v, low, high int) int { - if high < low { - low, high = high, low + default: + t.logf("unhandled CSI: %q", seq) } - return min(high, max(low, v)) } diff --git a/vt/csi_cursor.go b/vt/csi_cursor.go new file mode 100644 index 00000000..4705df33 --- /dev/null +++ b/vt/csi_cursor.go @@ -0,0 +1,166 @@ +package vt + +import ( + "github.com/charmbracelet/x/ansi" +) + +func (t *Terminal) handleCursor() { + width, height := t.Width(), t.Height() + n := 1 + if param, ok := t.parser.Param(0, 1); ok && param > 0 { + n = param + } + + x, y := t.scr.CursorPosition() + switch t.parser.Cmd() { + case 'A': // Cursor Up [ansi.CUU] + t.moveCursor(0, -n) + case 'B': // Cursor Down [ansi.CUD] + t.moveCursor(0, n) + case 'C': // Cursor Forward [ansi.CUF] + t.moveCursor(n, 0) + case 'D': // Cursor Backward [ansi.CUB] + t.moveCursor(-n, 0) + case 'E': // Cursor Next Line [ansi.CNL] + t.moveCursor(0, n) + t.carriageReturn() + case 'F': // Cursor Previous Line [ansi.CPL] + t.moveCursor(0, -n) + t.carriageReturn() + case 'G': // Cursor Horizontal Absolute [ansi.CHA] + t.setCursor(n-1, y) + case 'H': // Cursor Position [ansi.CUP] + row, _ := t.parser.Param(0, 1) + col, _ := t.parser.Param(1, 1) + y = min(height-1, row-1) + x = min(width-1, col-1) + t.setCursorPosition(x, y) + case 'I': // Cursor Horizontal Tabulation [ansi.CHT] + t.nextTab(n) + case 'Z': // Cursor Backward Tabulation [ansi.CBT] + t.prevTab(n) + case '`': // Horizontal Position Absolute [ansi.HPA] + t.setCursorPosition(min(width-1, n-1), y) + case 'a': // Horizontal Position Relative [ansi.HPR] + t.setCursorPosition(min(width-1, x+n), y) + case 'b': // Repeat Previous Character [ansi.REP] + t.repeatPreviousCharacter(n) + case 'e': + // Vertical Position Relative [ansi.VPR] + t.setCursorPosition(x, min(height-1, y+n)) + case 'f': + // Horizontal and Vertical Position [ansi.HVP] + row, _ := t.parser.Param(0, 1) + col, _ := t.parser.Param(1, 1) + y = min(height-1, row-1) + x = min(width-1, col-1) + t.setCursor(x, y) + case 'd': + // Vertical Position Absolute [ansi.VPA] + t.setCursorPosition(x, min(height-1, n-1)) + } +} + +// nextTab moves the cursor to the next tab stop n times. This respects the +// horizontal scrolling region. This performs the same function as [ansi.CHT]. +func (t *Terminal) nextTab(n int) { + x, y := t.scr.CursorPosition() + scroll := t.scr.ScrollRegion() + for i := 0; i < n; i++ { + ts := t.tabstops.Next(x) + if ts < x { + break + } + x = ts + } + + if x >= scroll.Max.X { + x = min(scroll.Max.X-1, t.Width()-1) + } + + // NOTE: We use t.scr.setCursor here because we don't want to reset the + // phantom state. + t.scr.setCursor(x, y, false) +} + +// prevTab moves the cursor to the previous tab stop n times. This respects the +// horizontal scrolling region when origin mode is set. If the cursor would +// move past the leftmost valid column, the cursor remains at the leftmost +// valid column and the operation completes. +func (t *Terminal) prevTab(n int) { + x, _ := t.scr.CursorPosition() + leftmargin := 0 + scroll := t.scr.ScrollRegion() + if t.isModeSet(ansi.DECOM) { + leftmargin = scroll.Min.X + } + + for i := 0; i < n; i++ { + ts := t.tabstops.Prev(x) + if ts > x { + break + } + x = ts + } + + if x < leftmargin { + x = leftmargin + } + + // NOTE: We use t.scr.setCursorX here because we don't want to reset the + // phantom state. + t.scr.setCursorX(x, false) +} + +// moveCursor moves the cursor by the given x and y deltas. If the cursor +// is at phantom, the state will reset and the cursor is back in the screen. +func (t *Terminal) moveCursor(dx, dy int) { + t.scr.moveCursor(dx, dy) + t.atPhantom = false +} + +// setCursor sets the cursor position. This resets the phantom state. +func (t *Terminal) setCursor(x, y int) { + t.scr.setCursor(x, y, false) + t.atPhantom = false +} + +// setCursorPosition sets the cursor position. This respects [ansi.DECOM], +// Origin Mode. This performs the same function as [ansi.CUP]. +func (t *Terminal) setCursorPosition(x, y int) { + mode, ok := t.modes[ansi.DECOM] + margins := ok && mode.IsSet() + t.scr.setCursor(x, y, margins) + t.atPhantom = false +} + +// carriageReturn moves the cursor to the leftmost column. If [ansi.DECOM] is +// set, the cursor is set to the left margin. If not, and the cursor is on or +// to the right of the left margin, the cursor is set to the left margin. +// Otherwise, the cursor is set to the leftmost column of the screen. +// This performs the same function as [ansi.CR]. +func (t *Terminal) carriageReturn() { + mode, ok := t.modes[ansi.DECOM] + margins := ok && mode.IsSet() + x, y := t.scr.CursorPosition() + if margins { + t.scr.setCursor(0, y, true) + } else if region := t.scr.ScrollRegion(); region.Contains(Pos(x, y)) { + t.scr.setCursor(region.Min.X, y, false) + } else { + t.scr.setCursor(0, y, false) + } + t.atPhantom = false +} + +// repeatPreviousCharacter repeats the previous character n times. This is +// equivalent to typing the same character n times. This performs the same as +// [ansi.REP]. +func (t *Terminal) repeatPreviousCharacter(n int) { + if t.lastChar == nil { + return + } + for i := 0; i < n; i++ { + t.handleUtf8(t.lastChar) + } +} diff --git a/vt/csi_mode.go b/vt/csi_mode.go new file mode 100644 index 00000000..959a1de9 --- /dev/null +++ b/vt/csi_mode.go @@ -0,0 +1,91 @@ +package vt + +import ( + "github.com/charmbracelet/x/ansi" +) + +func (t *Terminal) handleMode() { + cmd := t.parser.Cmd() + for _, p := range t.parser.Params() { + param := p.Param(-1) + if param == -1 { + // Missing parameter, ignore + continue + } + + var mode ansi.Mode = ansi.ANSIMode(param) + if cmd.Marker() == '?' { + mode = ansi.DECMode(param) + } + + setting := t.modes[mode] + if setting == ansi.ModePermanentlyReset || setting == ansi.ModePermanentlySet { + // Permanently set modes are ignored. + continue + } + + setting = ansi.ModeReset + if cmd.Command() == 'h' { + setting = ansi.ModeSet + } + + t.setMode(mode, setting) + } +} + +// setAltScreenMode sets the alternate screen mode. +func (t *Terminal) setAltScreenMode(on bool) { + if on { + t.scr = &t.scrs[1] + t.scrs[1].cur = t.scrs[0].cur + t.scr.Clear() + t.setCursor(0, 0) + } else { + t.scr = &t.scrs[0] + } + if t.Callbacks.AltScreen != nil { + t.Callbacks.AltScreen(on) + } +} + +// saveCursor saves the cursor position. +func (t *Terminal) saveCursor() { + t.scr.SaveCursor() +} + +// restoreCursor restores the cursor position. +func (t *Terminal) restoreCursor() { + t.scr.RestoreCursor() +} + +// setMode sets the mode to the given value. +func (t *Terminal) setMode(mode ansi.Mode, setting ansi.ModeSetting) { + t.logf("setting mode %T(%v) to %v", mode, mode, setting) + t.modes[mode] = setting + switch mode { + case ansi.TextCursorEnableMode: + t.scr.setCursorHidden(!setting.IsSet()) + case ansi.AltScreenMode: + t.setAltScreenMode(setting.IsSet()) + case ansi.SaveCursorMode: + if setting.IsSet() { + t.saveCursor() + } else { + t.restoreCursor() + } + case ansi.AltScreenSaveCursorMode: // Alternate Screen Save Cursor (1047 & 1048) + // Save primary screen cursor position + // Switch to alternate screen + // Doesn't support scrollback + if setting.IsSet() { + t.saveCursor() + } + t.setAltScreenMode(setting.IsSet()) + } +} + +// isModeSet returns true if the mode is set. +func (t *Terminal) isModeSet(mode ansi.Mode) bool { + m, ok := t.modes[mode] + return ok && m.IsSet() +} diff --git a/vt/csi_screen.go b/vt/csi_screen.go new file mode 100644 index 00000000..252a9502 --- /dev/null +++ b/vt/csi_screen.go @@ -0,0 +1,155 @@ +package vt + +import ( + "github.com/charmbracelet/x/ansi" +) + +func (t *Terminal) handleScreen() { + width, height := t.Width(), t.Height() + _, y := t.scr.CursorPosition() + + switch t.parser.Cmd() { + case 'J': // Erase in Display [ansi.ED] + count, _ := t.parser.Param(0, 0) + switch count { + case 0: // Erase screen below (including cursor) + rect := Rect(0, y, width, height-y) + t.scr.Fill(t.scr.blankCell(), rect) + case 1: // Erase screen above (including cursor) + rect := Rect(0, 0, width, y+1) + t.scr.Fill(t.scr.blankCell(), rect) + case 2: // erase screen + fallthrough + case 3: // erase display + // TODO: Scrollback buffer support? + t.scr.Clear() + } + case 'L': // Insert Line [ansi.IL] + n, _ := t.parser.Param(0, 1) + if n == 0 { + n = 1 + } + if t.scr.InsertLine(n) { + // Move the cursor to the left margin. + t.scr.setCursorX(0, true) + } + + case 'M': // Delete Line [ansi.DL] + n, _ := t.parser.Param(0, 1) + if n == 0 { + n = 1 + } + if t.scr.DeleteLine(n) { + // If the line was deleted successfully, move the cursor to the + // left. + // Move the cursor to the left margin. + t.scr.setCursorX(0, true) + } + + case 'X': // Erase Character [ansi.ECH] + // It clears character attributes as well but not colors. + n, _ := t.parser.Param(0, 1) + if n == 0 { + n = 1 + } + t.eraseCharacter(n) + + case 'r': // Set Top and Bottom Margins [ansi.DECSTBM] + top, _ := t.parser.Param(0, 1) + if top < 1 { + top = 1 + } + + bottom, _ := t.parser.Param(1, height) + if bottom < 1 { + bottom = height + } + + if top >= bottom { + break + } + + // Rect is [x, y) which means y is exclusive. So the top margin + // is the top of the screen minus one. + t.scr.setVerticalMargins(top-1, bottom) + + // Move the cursor to the top-left of the screen or scroll region + // depending on [ansi.DECOM]. + t.setCursorPosition(0, 0) + + case 's': // Set Left and Right Margins [ansi.DECSLRM] + // These conflict with each other. When [ansi.DECSLRM] is set, the we + // set the left and right margins. Otherwise, we save the cursor + // position. + + if t.isModeSet(ansi.LeftRightMarginMode) { + // Set Left Right Margins [ansi.DECSLRM] + left, _ := t.parser.Param(0, 1) + if left < 1 { + left = 1 + } + + right, _ := t.parser.Param(1, width) + if right < 1 { + right = width + } + + if left >= right { + break + } + + t.scr.setHorizontalMargins(left-1, right) + + // Move the cursor to the top-left of the screen or scroll region + // depending on [ansi.DECOM]. + t.setCursorPosition(0, 0) + } else { + // Save Current Cursor Position [ansi.SCOSC] + t.scr.SaveCursor() + } + } +} + +func (t *Terminal) handleLine() { + switch t.parser.Cmd() { + case 'K': // Erase in Line [ansi.EL] + // NOTE: Erase Line (EL) erases all character attributes but not cell + // bg color. + count, _ := t.parser.Param(0, 0) + x, y := t.scr.CursorPosition() + w := t.scr.Width() + + switch count { + case 0: // Erase from cursor to end of line + t.eraseCharacter(w - x) + case 1: // Erase from start of line to cursor + rect := Rect(0, y, x+1, 1) + t.scr.Fill(t.scr.blankCell(), rect) + case 2: // Erase entire line + rect := Rect(0, y, w, 1) + t.scr.Fill(t.scr.blankCell(), rect) + } + case 'S': // Scroll Up [ansi.SU] + n, _ := t.parser.Param(0, 1) + if n == 0 { + n = 1 + } + t.scr.ScrollUp(n) + case 'T': // Scroll Down [ansi.SD] + n, _ := t.parser.Param(0, 1) + if n == 0 { + n = 1 + } + t.scr.ScrollDown(n) + } +} + +// eraseCharacter erases n characters starting from the cursor position. It +// does not move the cursor. This is equivalent to [ansi.ECH]. +func (t *Terminal) eraseCharacter(n int) { + x, y := t.scr.CursorPosition() + rect := Rect(x, y, n, 1) + t.scr.Fill(t.scr.blankCell(), rect) + t.atPhantom = false + // ECH does not move the cursor. +} diff --git a/vt/csi_sgr.go b/vt/csi_sgr.go new file mode 100644 index 00000000..6f6cb08b --- /dev/null +++ b/vt/csi_sgr.go @@ -0,0 +1,137 @@ +package vt + +import ( + "image/color" + + "github.com/charmbracelet/x/ansi" +) + +// handleSgr handles SGR escape sequences. +// handleSgr handles Select Graphic Rendition (SGR) escape sequences. +func (t *Terminal) handleSgr() { + pen := &t.scr.cur.Pen + params := t.parser.Params() + if len(params) == 0 { + pen.Reset() + return + } + + for i := 0; i < len(params); i++ { + r := params[i] + param, hasMore := r.Param(0), r.HasMore() // Are there more subparameters i.e. separated by ":"? + switch param { + case 0: // Reset + pen.Reset() + case 1: // Bold + pen.Bold(true) + case 2: // Dim/Faint + pen.Faint(true) + case 3: // Italic + pen.Italic(true) + case 4: // Underline + if hasMore { // Only accept subparameters i.e. separated by ":" + nextParam := params[i+1].Param(0) + switch nextParam { + case 0: // No Underline + pen.UnderlineStyle(NoUnderline) + case 1: // Single Underline + pen.UnderlineStyle(SingleUnderline) + case 2: // Double Underline + pen.UnderlineStyle(DoubleUnderline) + case 3: // Curly Underline + pen.UnderlineStyle(CurlyUnderline) + case 4: // Dotted Underline + pen.UnderlineStyle(DottedUnderline) + case 5: // Dashed Underline + pen.UnderlineStyle(DashedUnderline) + } + } else { + // Single Underline + pen.Underline(true) + } + case 5: // Slow Blink + pen.SlowBlink(true) + case 6: // Rapid Blink + pen.RapidBlink(true) + case 7: // Reverse + pen.Reverse(true) + case 8: // Conceal + pen.Conceal(true) + case 9: // Crossed-out/Strikethrough + pen.Strikethrough(true) + case 22: // Normal Intensity (not bold or faint) + pen.Bold(false).Faint(false) + case 23: // Not italic, not Fraktur + pen.Italic(false) + case 24: // Not underlined + pen.Underline(false) + case 25: // Blink off + pen.SlowBlink(false).RapidBlink(false) + case 27: // Positive (not reverse) + pen.Reverse(false) + case 28: // Reveal + pen.Conceal(false) + case 29: // Not crossed out + pen.Strikethrough(false) + case 30, 31, 32, 33, 34, 35, 36, 37: // Set foreground + col := t.IndexedColor(param - 30) + pen.Foreground(col) //nolint:gosec + case 38: // Set foreground 256 or truecolor + if c := t.readColor(&i, params); c != nil { + pen.Foreground(c) + } + case 39: // Default foreground + pen.Foreground(nil) + case 40, 41, 42, 43, 44, 45, 46, 47: // Set background + col := t.IndexedColor(param - 40) + pen.Background(col) //nolint:gosec + case 48: // Set background 256 or truecolor + if c := t.readColor(&i, params); c != nil { + pen.Background(c) + } + case 49: // Default Background + pen.Background(nil) + case 58: // Set underline color + if c := t.readColor(&i, params); c != nil { + pen.UnderlineColor(c) + } + case 59: // Default underline color + pen.UnderlineColor(nil) + case 90, 91, 92, 93, 94, 95, 96, 97: // Set bright foreground + col := t.IndexedColor(param - 90 + 8) // Bright colors start at 8 + pen.Foreground(col) //nolint:gosec + case 100, 101, 102, 103, 104, 105, 106, 107: // Set bright background + col := t.IndexedColor(param - 100 + 8) // Bright colors start at 8 + pen.Background(col) //nolint:gosec + } + } +} + +func (t *Terminal) readColor(idxp *int, params []ansi.Parameter) (c ansi.Color) { + i := *idxp + paramsLen := len(params) + if i > paramsLen-1 { + return + } + // Note: we accept both main and subparams here + switch param := params[i+1].Param(0); param { + case 2: // RGB + if i > paramsLen-4 { + return + } + c = color.RGBA{ + R: uint8(params[i+2].Param(0)), //nolint:gosec + G: uint8(params[i+3].Param(0)), //nolint:gosec + B: uint8(params[i+4].Param(0)), //nolint:gosec + A: 0xff, + } + *idxp += 4 + case 5: // 256 colors + if i > paramsLen-2 { + return + } + c = t.IndexedColor(params[i+2].Param(0)) + *idxp += 2 + } + return +} diff --git a/vt/cursor.go b/vt/cursor.go index f89217f0..e80110fc 100644 --- a/vt/cursor.go +++ b/vt/cursor.go @@ -1,13 +1,22 @@ package vt -import ( - "image" +// CursorStyle represents a cursor style. +type CursorStyle int + +// Cursor styles. +const ( + CursorBlock CursorStyle = iota + CursorUnderline + CursorBar ) // Cursor represents a cursor in a terminal. type Cursor struct { - Pen Style - Pos image.Point - Style int - Visible bool + Pen Style + + Position + + Style CursorStyle + Steady bool // Not blinking + Hidden bool } diff --git a/vt/damage.go b/vt/damage.go new file mode 100644 index 00000000..6e3bf4d3 --- /dev/null +++ b/vt/damage.go @@ -0,0 +1,69 @@ +package vt + +// Damage represents a damaged area. +type Damage interface { + // Bounds returns the bounds of the damaged area. + Bounds() Rectangle +} + +// CellDamage represents a damaged cell. +type CellDamage struct { + X, Y int + Width int +} + +// Bounds returns the bounds of the damaged area. +func (d CellDamage) Bounds() Rectangle { + return Rect(d.X, d.Y, d.Width, 1) +} + +// RectDamage represents a damaged rectangle. +type RectDamage Rectangle + +// Bounds returns the bounds of the damaged area. +func (d RectDamage) Bounds() Rectangle { + return Rectangle(d) +} + +// X returns the x-coordinate of the damaged area. +func (d RectDamage) X() int { + return Rectangle(d).X() +} + +// Y returns the y-coordinate of the damaged area. +func (d RectDamage) Y() int { + return Rectangle(d).Y() +} + +// Width returns the width of the damaged area. +func (d RectDamage) Width() int { + return Rectangle(d).Width() +} + +// Height returns the height of the damaged area. +func (d RectDamage) Height() int { + return Rectangle(d).Height() +} + +// ScreenDamage represents a damaged screen. +type ScreenDamage struct { + Width, Height int +} + +// Bounds returns the bounds of the damaged area. +func (d ScreenDamage) Bounds() Rectangle { + return Rect(0, 0, d.Width, d.Height) +} + +// MoveDamage represents a moved area. +// The area is moved from the source to the destination. +type MoveDamage struct { + Src, Dst Rectangle +} + +// ScrollDamage represents a scrolled area. +// The area is scrolled by the given deltas. +type ScrollDamage struct { + Rectangle + Dx, Dy int +} diff --git a/vt/dcs.go b/vt/dcs.go index ff751894..3151047a 100644 --- a/vt/dcs.go +++ b/vt/dcs.go @@ -1,5 +1,8 @@ package vt +import "github.com/charmbracelet/x/ansi" + // handleDcs handles a DCS escape sequence. -func (t *Terminal) handleDcs(seq []byte) { +func (t *Terminal) handleDcs(seq ansi.DcsSequence) { + t.logf("unhandled DCS: %q", seq) } diff --git a/vt/esc.go b/vt/esc.go index ef378383..af247bca 100644 --- a/vt/esc.go +++ b/vt/esc.go @@ -1,12 +1,69 @@ package vt +import ( + "github.com/charmbracelet/x/ansi" +) + // handleEsc handles an escape sequence. -func (t *Terminal) handleEsc(seq []byte) { - cmd := t.parser.Cmd - switch cmd { - case '7': // DECSC - Save Cursor +func (t *Terminal) handleEsc(seq ansi.EscSequence) { + switch cmd := t.parser.Cmd(); cmd { + case 'H': // Horizontal Tab Set [ansi.HTS] + t.horizontalTabSet() + case 'M': // Reverse Index [ansi.RI] + t.reverseIndex() + case '=': // Keypad Application Mode [ansi.DECKPAM] + t.setMode(ansi.NumericKeypadMode, ansi.ModeSet) + case '>': // Keypad Numeric Mode [ansi.DECKPNM] + t.setMode(ansi.NumericKeypadMode, ansi.ModeReset) + case '7': // Save Cursor [ansi.DECSC] t.scr.SaveCursor() - case '8': // DECRC - Restore Cursor + case '8': // Restore Cursor [ansi.DECRC] t.scr.RestoreCursor() + case 'c': // Reset Initial State [ansi.RIS] + t.fullReset() + case 'D': // Index [ansi.Index] + t.index() + case '~': // Locking Shift 1 Right [ansi.LS1R] + t.gr = 1 + case 'n': // Locking Shift G2 [ansi.LS2] + t.gl = 2 + case '}': // Locking Shift 2 Right [ansi.LS2R] + t.gr = 2 + case 'o': // Locking Shift G3 [ansi.LS3] + t.gl = 3 + case '|': // Locking Shift 3 Right [ansi.LS3R] + t.gr = 3 + default: + switch inter := cmd.Intermediate(); inter { + case '(', ')', '*', '+': // Select Character Set [ansi.SCS] + set := inter - '(' + switch cmd.Command() { + case 'A': // UK Character Set + t.charsets[set] = UK + case 'B': // USASCII Character Set + t.charsets[set] = nil // USASCII is the default + case '0': // Special Drawing Character Set + t.charsets[set] = SpecialDrawing + default: + t.logf("unknown character set: %q", seq) + } + default: + t.logf("unhandled ESC: %q", seq) + } } } + +// fullReset performs a full terminal reset as in [ansi.RIS]. +func (t *Terminal) fullReset() { + t.scrs[0].Reset() + t.scrs[1].Reset() + t.resetTabStops() + + // TODO: Do we reset all modes here? Investigate. + t.resetModes() + + t.gl, t.gr = 0, 1 + t.gsingle = 0 + t.charsets = [4]CharSet{} + t.atPhantom = false +} diff --git a/vt/focus.go b/vt/focus.go new file mode 100644 index 00000000..eed440da --- /dev/null +++ b/vt/focus.go @@ -0,0 +1,27 @@ +package vt + +import ( + "github.com/charmbracelet/x/ansi" +) + +// Focus sends the terminal a focus event if focus events mode is enabled. +// This is the opposite of [Blur]. +func (t *Terminal) Focus() { + t.focus(true) +} + +// Blur sends the terminal a blur event if focus events mode is enabled. +// This is the opposite of [Focus]. +func (t *Terminal) Blur() { + t.focus(false) +} + +func (t *Terminal) focus(focus bool) { + if mode, ok := t.modes[ansi.FocusEventMode]; ok && mode.IsSet() { + if focus { + t.buf.WriteString(ansi.Focus) + } else { + t.buf.WriteString(ansi.Blur) + } + } +} diff --git a/vt/geom.go b/vt/geom.go new file mode 100644 index 00000000..21421440 --- /dev/null +++ b/vt/geom.go @@ -0,0 +1,68 @@ +package vt + +import ( + "image" +) + +// Position represents an x, y position. +type Position image.Point + +// Point returns the position as an image.Point. +func (p Position) Point() image.Point { + return image.Point(p) +} + +// String returns a string representation of the position. +func (p Position) String() string { + return image.Point(p).String() +} + +// Pos is a shorthand for Position{X: x, Y: y}. +func Pos(x, y int) Position { + return Position{X: x, Y: y} +} + +// Rectange represents a rectangle. +type Rectangle image.Rectangle + +// String returns a string representation of the rectangle. +func (r Rectangle) String() string { + return image.Rectangle(r).String() +} + +// Bounds returns the rectangle as an image.Rectangle. +func (r Rectangle) Bounds() image.Rectangle { + return image.Rectangle(r).Bounds() +} + +// Contains reports whether the rectangle contains the given point. +func (r Rectangle) Contains(p Position) bool { + return image.Point(p).In(r.Bounds()) +} + +// Width returns the width of the rectangle. +func (r Rectangle) Width() int { + return image.Rectangle(r).Dx() +} + +// Height returns the height of the rectangle. +func (r Rectangle) Height() int { + return image.Rectangle(r).Dy() +} + +// X returns the starting x position of the rectangle. +// This is equivalent to Min.X. +func (r Rectangle) X() int { + return r.Min.X +} + +// Y returns the starting y position of the rectangle. +// This is equivalent to Min.Y. +func (r Rectangle) Y() int { + return r.Min.Y +} + +// Rect is a shorthand for Rectangle. +func Rect(x, y, w, h int) Rectangle { + return Rectangle{Min: image.Point{X: x, Y: y}, Max: image.Point{X: x + w, Y: y + h}} +} diff --git a/vt/go.mod b/vt/go.mod index 5914825c..7ff8f901 100644 --- a/vt/go.mod +++ b/vt/go.mod @@ -4,16 +4,9 @@ go 1.19 require ( github.com/charmbracelet/x/ansi v0.4.5 - github.com/charmbracelet/x/cellbuf v0.0.6-0.20241106170917-eb0997d7d743 + github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 + github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/rivo/uniseg v0.4.7 ) -require ( - github.com/charmbracelet/colorprofile v0.1.6 // indirect - github.com/charmbracelet/x/term v0.2.0 // indirect - github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sys v0.24.0 // indirect - golang.org/x/text v0.20.0 // indirect -) +require golang.org/x/text v0.20.0 // indirect diff --git a/vt/go.sum b/vt/go.sum index 513a537e..595f7503 100644 --- a/vt/go.sum +++ b/vt/go.sum @@ -1,21 +1,10 @@ -github.com/charmbracelet/colorprofile v0.1.6 h1:nMMqCns0c0DfCwNGdagBh6SxutFqkltSxxKk5S9kt+Y= -github.com/charmbracelet/colorprofile v0.1.6/go.mod h1:3EMXDxwRDJl0c17eJ1jX99MhtlP9OxE/9Qw0C5lvyUg= github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= -github.com/charmbracelet/x/cellbuf v0.0.6-0.20241106170917-eb0997d7d743 h1:iKVWAITVASXoMQGoesfzRP81cAkr/GAR3TkOYN2n3WU= -github.com/charmbracelet/x/cellbuf v0.0.6-0.20241106170917-eb0997d7d743/go.mod h1:FaDNlvrPSNCEnih536+xi5f8iqxRQfDA20TVTs30CPU= -github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0= -github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91 h1:D5OO0lVavz7A+Swdhp62F9gbkibxmz9B2hZ/jVdMPf0= github.com/charmbracelet/x/wcwidth v0.0.0-20241011142426-46044092ad91/go.mod h1:Ey8PFmYwH+/td9bpiEx07Fdx9ZVkxfIjWXxBluxF4Nw= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= -github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= diff --git a/vt/key.go b/vt/key.go new file mode 100644 index 00000000..93ba6ce0 --- /dev/null +++ b/vt/key.go @@ -0,0 +1,460 @@ +package vt + +import ( + "unicode" + + "github.com/charmbracelet/x/ansi" +) + +// KeyMod represents a key modifier. +type KeyMod int + +// Modifier keys. +const ( + ModShift KeyMod = 1 << iota + ModAlt + ModCtrl + ModMeta +) + +// Key represents a key press event. +type Key struct { + Code rune + Mod KeyMod +} + +// SendKey returns the default key map. +func (t *Terminal) SendKey(k Key) { + var seq string + + ack := t.isModeSet(ansi.CursorKeysMode) // Application cursor keys mode + akk := t.isModeSet(ansi.NumericKeypadMode) // Application keypad keys mode + + switch k { + // Control keys + case Key{Code: KeySpace, Mod: ModCtrl}: + seq = "\x00" + case Key{Code: 'a', Mod: ModCtrl}: + seq = "\x01" + case Key{Code: 'b', Mod: ModCtrl}: + seq = "\x02" + case Key{Code: 'c', Mod: ModCtrl}: + seq = "\x03" + case Key{Code: 'd', Mod: ModCtrl}: + seq = "\x04" + case Key{Code: 'e', Mod: ModCtrl}: + seq = "\x05" + case Key{Code: 'f', Mod: ModCtrl}: + seq = "\x06" + case Key{Code: 'g', Mod: ModCtrl}: + seq = "\x07" + case Key{Code: 'h', Mod: ModCtrl}: + seq = "\x08" + case Key{Code: 'j', Mod: ModCtrl}: + seq = "\x0a" + case Key{Code: 'k', Mod: ModCtrl}: + seq = "\x0b" + case Key{Code: 'l', Mod: ModCtrl}: + seq = "\x0c" + case Key{Code: 'n', Mod: ModCtrl}: + seq = "\x0e" + case Key{Code: 'o', Mod: ModCtrl}: + seq = "\x0f" + case Key{Code: 'p', Mod: ModCtrl}: + seq = "\x10" + case Key{Code: 'q', Mod: ModCtrl}: + seq = "\x11" + case Key{Code: 'r', Mod: ModCtrl}: + seq = "\x12" + case Key{Code: 's', Mod: ModCtrl}: + seq = "\x13" + case Key{Code: 't', Mod: ModCtrl}: + seq = "\x14" + case Key{Code: 'u', Mod: ModCtrl}: + seq = "\x15" + case Key{Code: 'v', Mod: ModCtrl}: + seq = "\x16" + case Key{Code: 'w', Mod: ModCtrl}: + seq = "\x17" + case Key{Code: 'x', Mod: ModCtrl}: + seq = "\x18" + case Key{Code: 'y', Mod: ModCtrl}: + seq = "\x19" + case Key{Code: 'z', Mod: ModCtrl}: + seq = "\x1a" + case Key{Code: '\\', Mod: ModCtrl}: + seq = "\x1c" + case Key{Code: ']', Mod: ModCtrl}: + seq = "\x1d" + case Key{Code: '^', Mod: ModCtrl}: + seq = "\x1e" + case Key{Code: '_', Mod: ModCtrl}: + seq = "\x1f" + + case Key{Code: KeyEnter}: + seq = "\r" + case Key{Code: KeyTab}: + seq = "\t" + case Key{Code: KeyBackspace}: + seq = "\x7f" + case Key{Code: KeyEscape}: + seq = "\x1b" + + case Key{Code: KeyUp}: + if ack { + seq = "\x1bOA" + } else { + seq = "\x1b[A" + } + case Key{Code: KeyDown}: + if ack { + seq = "\x1bOB" + } else { + seq = "\x1b[B" + } + case Key{Code: KeyRight}: + if ack { + seq = "\x1bOC" + } else { + seq = "\x1b[C" + } + case Key{Code: KeyLeft}: + if ack { + seq = "\x1bOD" + } else { + seq = "\x1b[D" + } + + case Key{Code: KeyInsert}: + seq = "\x1b[2~" + case Key{Code: KeyDelete}: + seq = "\x1b[3~" + case Key{Code: KeyHome}: + seq = "\x1b[H" + case Key{Code: KeyEnd}: + seq = "\x1b[F" + case Key{Code: KeyPgUp}: + seq = "\x1b[5~" + case Key{Code: KeyPgDown}: + seq = "\x1b[6~" + + case Key{Code: KeyF1}: + seq = "\x1bOP" + case Key{Code: KeyF2}: + seq = "\x1bOQ" + case Key{Code: KeyF3}: + seq = "\x1bOR" + case Key{Code: KeyF4}: + seq = "\x1bOS" + case Key{Code: KeyF5}: + seq = "\x1b[15~" + case Key{Code: KeyF6}: + seq = "\x1b[17~" + case Key{Code: KeyF7}: + seq = "\x1b[18~" + case Key{Code: KeyF8}: + seq = "\x1b[19~" + case Key{Code: KeyF9}: + seq = "\x1b[20~" + case Key{Code: KeyF10}: + seq = "\x1b[21~" + case Key{Code: KeyF11}: + seq = "\x1b[23~" + case Key{Code: KeyF12}: + seq = "\x1b[24~" + + case Key{Code: KeyKp0}: + if akk { + seq = "\x1bOp" + } else { + seq = "0" + } + case Key{Code: KeyKp1}: + if akk { + seq = "\x1bOq" + } else { + seq = "1" + } + case Key{Code: KeyKp2}: + if akk { + seq = "\x1bOr" + } else { + seq = "2" + } + case Key{Code: KeyKp3}: + if akk { + seq = "\x1bOs" + } else { + seq = "3" + } + case Key{Code: KeyKp4}: + if akk { + seq = "\x1bOt" + } else { + seq = "4" + } + case Key{Code: KeyKp5}: + if akk { + seq = "\x1bOu" + } else { + seq = "5" + } + case Key{Code: KeyKp6}: + if akk { + seq = "\x1bOv" + } else { + seq = "6" + } + case Key{Code: KeyKp7}: + if akk { + seq = "\x1bOw" + } else { + seq = "7" + } + case Key{Code: KeyKp8}: + if akk { + seq = "\x1bOx" + } else { + seq = "8" + } + case Key{Code: KeyKp9}: + if akk { + seq = "\x1bOy" + } else { + seq = "9" + } + case Key{Code: KeyKpEnter}: + if akk { + seq = "\x1bOM" + } else { + seq = "\r" + } + case Key{Code: KeyKpEqual}: + if akk { + seq = "\x1bOX" + } else { + seq = "=" + } + case Key{Code: KeyKpMultiply}: + if akk { + seq = "\x1bOj" + } else { + seq = "*" + } + case Key{Code: KeyKpPlus}: + if akk { + seq = "\x1bOk" + } else { + seq = "+" + } + case Key{Code: KeyKpComma}: + if akk { + seq = "\x1bOl" + } else { + seq = "," + } + case Key{Code: KeyKpMinus}: + if akk { + seq = "\x1bOm" + } else { + seq = "-" + } + case Key{Code: KeyKpDecimal}: + if akk { + seq = "\x1bOn" + } else { + seq = "." + } + + case Key{Code: KeyTab, Mod: ModShift}: + seq = "\x1b[Z" + } + + if k.Mod&ModAlt != 0 { + // Handle alt-modified keys + seq = "\x1b" + seq + } + + t.buf.WriteString(seq) //nolint:errcheck +} + +const ( + // KeyExtended is a special key code used to signify that a key event + // contains multiple runes. + KeyExtended = unicode.MaxRune + 1 +) + +// Special key symbols. +const ( + + // Special keys + + KeyUp rune = KeyExtended + iota + 1 + KeyDown + KeyRight + KeyLeft + KeyBegin + KeyFind + KeyInsert + KeyDelete + KeySelect + KeyPgUp + KeyPgDown + KeyHome + KeyEnd + + // Keypad keys + + KeyKpEnter + KeyKpEqual + KeyKpMultiply + KeyKpPlus + KeyKpComma + KeyKpMinus + KeyKpDecimal + KeyKpDivide + KeyKp0 + KeyKp1 + KeyKp2 + KeyKp3 + KeyKp4 + KeyKp5 + KeyKp6 + KeyKp7 + KeyKp8 + KeyKp9 + + // The following are keys defined in the Kitty keyboard protocol. + // TODO: Investigate the names of these keys + KeyKpSep + KeyKpUp + KeyKpDown + KeyKpLeft + KeyKpRight + KeyKpPgUp + KeyKpPgDown + KeyKpHome + KeyKpEnd + KeyKpInsert + KeyKpDelete + KeyKpBegin + + // Function keys + + KeyF1 + KeyF2 + KeyF3 + KeyF4 + KeyF5 + KeyF6 + KeyF7 + KeyF8 + KeyF9 + KeyF10 + KeyF11 + KeyF12 + KeyF13 + KeyF14 + KeyF15 + KeyF16 + KeyF17 + KeyF18 + KeyF19 + KeyF20 + KeyF21 + KeyF22 + KeyF23 + KeyF24 + KeyF25 + KeyF26 + KeyF27 + KeyF28 + KeyF29 + KeyF30 + KeyF31 + KeyF32 + KeyF33 + KeyF34 + KeyF35 + KeyF36 + KeyF37 + KeyF38 + KeyF39 + KeyF40 + KeyF41 + KeyF42 + KeyF43 + KeyF44 + KeyF45 + KeyF46 + KeyF47 + KeyF48 + KeyF49 + KeyF50 + KeyF51 + KeyF52 + KeyF53 + KeyF54 + KeyF55 + KeyF56 + KeyF57 + KeyF58 + KeyF59 + KeyF60 + KeyF61 + KeyF62 + KeyF63 + + // The following are keys defined in the Kitty keyboard protocol. + // TODO: Investigate the names of these keys + + KeyCapsLock + KeyScrollLock + KeyNumLock + KeyPrintScreen + KeyPause + KeyMenu + + KeyMediaPlay + KeyMediaPause + KeyMediaPlayPause + KeyMediaReverse + KeyMediaStop + KeyMediaFastForward + KeyMediaRewind + KeyMediaNext + KeyMediaPrev + KeyMediaRecord + + KeyLowerVol + KeyRaiseVol + KeyMute + + KeyLeftShift + KeyLeftAlt + KeyLeftCtrl + KeyLeftSuper + KeyLeftHyper + KeyLeftMeta + KeyRightShift + KeyRightAlt + KeyRightCtrl + KeyRightSuper + KeyRightHyper + KeyRightMeta + KeyIsoLevel3Shift + KeyIsoLevel5Shift + + // Special names in C0 + + KeyBackspace = rune(ansi.DEL) + KeyTab = rune(ansi.HT) + KeyEnter = rune(ansi.CR) + KeyReturn = KeyEnter + KeyEscape = rune(ansi.ESC) + KeyEsc = KeyEscape + + // Special names in G0 + + KeySpace = rune(ansi.SP) +) diff --git a/vt/mode.go b/vt/mode.go index 24aaa90b..b211091e 100644 --- a/vt/mode.go +++ b/vt/mode.go @@ -1,23 +1,33 @@ package vt -// ModeSetting represents a mode setting. -type ModeSetting int +import "github.com/charmbracelet/x/ansi" -// ModeSetting constants. -const ( - ModeNotRecognized ModeSetting = iota - ModeSet - ModeReset - ModePermanentlySet - ModePermanentlyReset -) +// resetModes resets all modes to their default values. +func (t *Terminal) resetModes() { + t.modes = map[ansi.Mode]ansi.ModeSetting{ + // Recognized modes and their default values. + ansi.CursorKeysMode: ansi.ModeReset, + ansi.OriginMode: ansi.ModeReset, + ansi.AutoWrapMode: ansi.ModeSet, + ansi.X10MouseMode: ansi.ModeReset, + ansi.LineFeedNewLineMode: ansi.ModeReset, + ansi.TextCursorEnableMode: ansi.ModeSet, + ansi.NumericKeypadMode: ansi.ModeReset, + ansi.LeftRightMarginMode: ansi.ModeReset, + ansi.NormalMouseMode: ansi.ModeReset, + ansi.HighlightMouseMode: ansi.ModeReset, + ansi.ButtonEventMouseMode: ansi.ModeReset, + ansi.AnyEventMouseMode: ansi.ModeReset, + ansi.FocusEventMode: ansi.ModeReset, + ansi.SgrExtMouseMode: ansi.ModeReset, + ansi.AltScreenMode: ansi.ModeReset, + ansi.SaveCursorMode: ansi.ModeReset, + ansi.AltScreenSaveCursorMode: ansi.ModeReset, + ansi.BracketedPasteMode: ansi.ModeReset, + } -// IsSet returns true if the mode is set or permanently set. -func (m ModeSetting) IsSet() bool { - return m == ModeSet || m == ModePermanentlySet -} - -// IsReset returns true if the mode is reset or permanently reset. -func (m ModeSetting) IsReset() bool { - return m == ModeReset || m == ModePermanentlyReset + // Set mode effects. + for mode, setting := range t.modes { + t.setMode(mode, setting) + } } diff --git a/vt/mouse.go b/vt/mouse.go new file mode 100644 index 00000000..c873fa25 --- /dev/null +++ b/vt/mouse.go @@ -0,0 +1,193 @@ +package vt + +import ( + "github.com/charmbracelet/x/ansi" +) + +// MouseButton represents the button that was pressed during a mouse message. +type MouseButton byte + +// Mouse event buttons +// +// This is based on X11 mouse button codes. +// +// 1 = left button +// 2 = middle button (pressing the scroll wheel) +// 3 = right button +// 4 = turn scroll wheel up +// 5 = turn scroll wheel down +// 6 = push scroll wheel left +// 7 = push scroll wheel right +// 8 = 4th button (aka browser backward button) +// 9 = 5th button (aka browser forward button) +// 10 +// 11 +// +// Other buttons are not supported. +const ( + MouseNone MouseButton = iota + MouseLeft + MouseMiddle + MouseRight + MouseWheelUp + MouseWheelDown + MouseWheelLeft + MouseWheelRight + MouseBackward + MouseForward + MouseExtra1 + MouseExtra2 +) + +// Mouse represents a mouse event. +type Mouse interface { + Mouse() mouse +} + +// mouse represents a mouse message. Use [Mouse] to represent all mouse +// messages. +// +// The X and Y coordinates are zero-based, with (0,0) being the upper left +// corner of the terminal. +type mouse struct { + X, Y int + Button MouseButton + Mod KeyMod +} + +// MouseClick represents a mouse click event. +type MouseClick mouse + +// Mouse returns the mouse event. +func (m MouseClick) Mouse() mouse { + return mouse(m) +} + +// MouseRelease represents a mouse release event. +type MouseRelease mouse + +// Mouse returns the mouse event. +func (m MouseRelease) Mouse() mouse { + return mouse(m) +} + +// MouseWheel represents a mouse wheel event. +type MouseWheel mouse + +// Mouse returns the mouse event. +func (m MouseWheel) Mouse() mouse { + return mouse(m) +} + +// MouseMotion represents a mouse motion event. +type MouseMotion mouse + +// Mouse returns the mouse event. +func (m MouseMotion) Mouse() mouse { + return mouse(m) +} + +// SendMouse sends a mouse event to the terminal. +// TODO: Support [Utf8ExtMouseMode], [UrxvtExtMouseMode], and +// [SgrPixelExtMouseMode]. +func (t *Terminal) SendMouse(m Mouse) { + var ( + enc ansi.Mode + mode ansi.Mode + ) + + for _, m := range []ansi.DECMode{ + ansi.X10MouseMode, // Button press + ansi.NormalMouseMode, // Button press/release + ansi.HighlightMouseMode, // Button press/release/hilight + ansi.ButtonEventMouseMode, // Button press/release/cell motion + ansi.AnyEventMouseMode, // Button press/release/all motion + } { + if t.isModeSet(m) { + mode = m + } + } + + if mode == nil { + return + } + + for _, e := range []ansi.DECMode{ + // ansi.Utf8ExtMouseMode, + ansi.SgrExtMouseMode, + // ansi.UrxvtExtMouseMode, + // ansi.SgrPixelExtMouseMode, + } { + if t.isModeSet(e) { + enc = e + } + } + + // mouse bit shifts + const ( + bitShift = 0b0000_0100 + bitAlt = 0b0000_1000 + bitCtrl = 0b0001_0000 + bitMotion = 0b0010_0000 + bitWheel = 0b0100_0000 + bitAdd = 0b1000_0000 // additional buttons 8-11 + + bitsMask = 0b0000_0011 + ) + + var b byte + var release bool + if _, ok := m.(MouseRelease); ok { + release = true + } + + // Encode button + mouse := m.Mouse() + if release && enc == nil { + // X10 mouse encoding reports release as a b == 3 + b = bitsMask + } else if mouse.Button >= MouseLeft && mouse.Button <= MouseRight { + b = byte(mouse.Button) - byte(MouseLeft) + } else if mouse.Button >= MouseWheelUp && mouse.Button <= MouseWheelRight { + b = byte(mouse.Button) - byte(MouseWheelUp) + b |= bitWheel + } else if mouse.Button >= MouseBackward && mouse.Button <= MouseExtra2 { + b = byte(mouse.Button) - byte(MouseBackward) + b |= bitAdd + } + + switch m.(type) { + case MouseMotion: + switch { + case mouse.Button == MouseNone && mode == ansi.AnyEventMouseMode: + b = bitsMask + fallthrough + case mouse.Button > MouseNone && mode == ansi.ButtonEventMouseMode: + b |= bitMotion + default: + // No motion events + return + } + } + + // Encode modifiers + if mouse.Mod&ModShift != 0 { + b |= bitShift + } + if mouse.Mod&ModAlt != 0 { + b |= bitAlt + } + if mouse.Mod&ModCtrl != 0 { + b |= bitCtrl + } + + switch enc { + // TODO: Support [ansi.HighlightMouseMode]. + // TODO: Support [ansi.Utf8ExtMouseMode], [ansi.UrxvtExtMouseMode], and + // [ansi.SgrPixelExtMouseMode]. + case nil: // X10 mouse encoding + t.buf.WriteString(ansi.MouseX10(b, mouse.X, mouse.Y)) + case ansi.SgrExtMouseMode: // SGR mouse encoding + t.buf.WriteString(ansi.MouseSgr(b, mouse.X, mouse.Y, release)) + } +} diff --git a/vt/options.go b/vt/options.go new file mode 100644 index 00000000..2da554b2 --- /dev/null +++ b/vt/options.go @@ -0,0 +1,29 @@ +package vt + +// Logger represents a logger interface. +type Logger interface { + Printf(format string, v ...interface{}) +} + +// Option is a terminal option. +type Option func(*Terminal) + +// WithLogger returns an [Option] that sets the terminal's logger. +// The logger is used for debugging and logging. +// By default, the terminal does not log anything. +// +// Example: +// +// vterm := vt.NewTerminal(80, 24, vt.WithLogger(log.Default())) +func WithLogger(logger Logger) Option { + return func(t *Terminal) { + t.logger = logger + } +} + +// logf logs a formatted message if the terminal has a logger. +func (t *Terminal) logf(format string, v ...interface{}) { + if t.logger != nil { + t.logger.Printf(format, v...) + } +} diff --git a/vt/osc.go b/vt/osc.go index 3096010b..1c6be47f 100644 --- a/vt/osc.go +++ b/vt/osc.go @@ -1,17 +1,153 @@ package vt +import ( + "bytes" + "image/color" + "strconv" + "strings" + + "github.com/charmbracelet/x/ansi" + "github.com/lucasb-eyer/go-colorful" +) + // handleOsc handles an OSC escape sequence. -func (t *Terminal) handleOsc([]byte) { - cmd := t.parser.Cmd - switch cmd { - case 0: // Set window title and icon name - name := string(t.parser.Data[:t.parser.DataLen]) - t.iconName, t.title = name, name - case 1: // Set icon name - name := string(t.parser.Data[:t.parser.DataLen]) - t.iconName = name - case 2: // Set window title - name := string(t.parser.Data[:t.parser.DataLen]) - t.title = name +func (t *Terminal) handleOsc(seq ansi.OscSequence) { + switch cmd := t.parser.Cmd(); cmd { + case 0, 1, 2: + parts := bytes.Split(t.parser.Data(), []byte{';'}) + if len(parts) != 2 { + // Invalid, ignore + return + } + switch cmd { + case 0: // Set window title and icon name + name := string(parts[1]) + t.iconName, t.title = name, name + if t.Callbacks.Title != nil { + t.Callbacks.Title(name) + } + if t.Callbacks.IconName != nil { + t.Callbacks.IconName(name) + } + case 1: // Set icon name + name := string(parts[1]) + t.iconName = name + if t.Callbacks.IconName != nil { + t.Callbacks.IconName(name) + } + case 2: // Set window title + name := string(parts[1]) + t.title = name + if t.Callbacks.Title != nil { + t.Callbacks.Title(name) + } + } + case 10, 11, 12, 110, 111, 112: + var setCol func(color.Color) + var col color.Color + + parts := bytes.Split(t.parser.Data(), []byte{';'}) + if len(parts) == 0 { + // Invalid, ignore + return + } + + switch cmd { + case 10, 11, 12: + if len(parts) != 2 { + // Invalid, ignore + return + } + + var enc func(color.Color) string + if s := string(parts[1]); s == "?" { + switch cmd { + case 10: + enc = ansi.SetForegroundColor + col = t.ForegroundColor() + case 11: + enc = ansi.SetBackgroundColor + col = t.BackgroundColor() + case 12: + enc = ansi.SetCursorColor + col = t.CursorColor() + } + + if enc != nil && col != nil { + t.buf.WriteString(enc(ansi.XRGBColorizer{Color: col})) + } + } else { + col := xParseColor(string(parts[1])) + if col == nil { + return + } + } + case 110: + col = defaultFg + case 111: + col = defaultBg + case 112: + col = defaultCur + } + + switch cmd { + case 10, 110: // Set/Reset foreground color + setCol = t.SetForegroundColor + case 11, 111: // Set/Reset background color + setCol = t.SetBackgroundColor + case 12, 112: // Set/Reset cursor color + setCol = t.SetCursorColor + } + + setCol(col) + default: + t.logf("unhandled OSC: %s", seq) + } +} + +type shiftable interface { + ~uint | ~uint16 | ~uint32 | ~uint64 +} + +func shift[T shiftable](x T) T { + if x > 0xff { + x >>= 8 + } + return x +} + +func xParseColor(s string) color.Color { + switch { + case strings.HasPrefix(s, "#"): + c, err := colorful.Hex(s) + if err != nil { + return nil + } + + return c + case strings.HasPrefix(s, "rgb:"): + parts := strings.Split(s[4:], "/") + if len(parts) != 3 { + return nil + } + + r, _ := strconv.ParseUint(parts[0], 16, 32) + g, _ := strconv.ParseUint(parts[1], 16, 32) + b, _ := strconv.ParseUint(parts[2], 16, 32) + + return color.RGBA{uint8(shift(r)), uint8(shift(g)), uint8(shift(b)), 255} //nolint:gosec + case strings.HasPrefix(s, "rgba:"): + parts := strings.Split(s[5:], "/") + if len(parts) != 4 { + return nil + } + + r, _ := strconv.ParseUint(parts[0], 16, 32) + g, _ := strconv.ParseUint(parts[1], 16, 32) + b, _ := strconv.ParseUint(parts[2], 16, 32) + a, _ := strconv.ParseUint(parts[3], 16, 32) + + return color.RGBA{uint8(shift(r)), uint8(shift(g)), uint8(shift(b)), uint8(shift(a))} //nolint:gosec } + return nil } diff --git a/vt/screen.go b/vt/screen.go index eb029ca5..24e10299 100644 --- a/vt/screen.go +++ b/vt/screen.go @@ -1,96 +1,405 @@ package vt -import "github.com/charmbracelet/x/cellbuf" +import ( + "sync" +) // Screen represents a virtual terminal screen. type Screen struct { + // cb is the callbacks struct to use. + cb *Callbacks // The buffer of the screen. - buf cellbuf.Buffer + buf Buffer // The cur of the screen. cur, saved Cursor - - // tabstop is the list of tab stops. - tabstops TabStops + // scroll is the scroll region. + scroll Rectangle + // mutex for the screen. + mu sync.RWMutex } -var _ cellbuf.Screen = &Screen{} - // NewScreen creates a new screen. func NewScreen(w, h int) *Screen { s := new(Screen) - s.buf.Resize(w, h) - s.tabstops = DefaultTabStops(w) + s.Resize(w, h) return s } -// Cell implements cellbuf.Screen. -func (s *Screen) Cell(x int, y int) (cellbuf.Cell, bool) { +// Reset resets the screen. +// It clears the screen, sets the cursor to the top left corner, reset the +// cursor styles, and resets the scroll region. +func (s *Screen) Reset() { + s.mu.Lock() + s.buf.Clear() + s.cur = Cursor{} + s.saved = Cursor{} + s.scroll = s.buf.Bounds() + s.mu.Unlock() +} + +// Bounds returns the bounds of the screen. +func (s *Screen) Bounds() Rectangle { + s.mu.RLock() + defer s.mu.RUnlock() + return s.buf.Bounds() +} + +// Cell returns the cell at the given x, y position. +func (s *Screen) Cell(x int, y int) *Cell { + s.mu.RLock() + defer s.mu.RUnlock() return s.buf.Cell(x, y) } -// Draw implements cellbuf.Screen. -func (s *Screen) Draw(x int, y int, c cellbuf.Cell) bool { - return s.buf.Draw(x, y, c) +// SetCell sets the cell at the given x, y position. +// It returns true if the cell was set successfully. +func (s *Screen) SetCell(x, y int, c *Cell) bool { + s.mu.Lock() + defer s.mu.Unlock() + v := s.buf.SetCell(x, y, c) + if v && s.cb.Damage != nil { + width := 1 + if c != nil { + width = c.Width + } + s.cb.Damage(CellDamage{x, y, width}) + } + return v } -// Height implements cellbuf.Grid. +// Height returns the height of the screen. func (s *Screen) Height() int { + s.mu.RLock() + defer s.mu.RUnlock() return s.buf.Height() } -// Resize implements cellbuf.Grid. +// Resize resizes the screen. func (s *Screen) Resize(width int, height int) { + s.mu.Lock() s.buf.Resize(width, height) - s.tabstops = DefaultTabStops(width) + s.scroll = s.buf.Bounds() + if s.cb != nil && s.cb.Damage != nil { + s.cb.Damage(ScreenDamage{width, height}) + } + s.mu.Unlock() } -// Width implements cellbuf.Grid. +// Width returns the width of the screen. func (s *Screen) Width() int { + s.mu.RLock() + defer s.mu.RUnlock() return s.buf.Width() } // Clear clears the screen or part of it. -func (s *Screen) Clear(rect *cellbuf.Rectangle) { - s.buf.Clear(rect) +func (s *Screen) Clear(rects ...Rectangle) { + s.mu.Lock() + s.buf.Clear(rects...) + if s.cb.Damage != nil { + for _, r := range rects { + s.cb.Damage(RectDamage(r)) + } + } + s.mu.Unlock() } // Fill fills the screen or part of it. -func (s *Screen) Fill(c cellbuf.Cell, rect *cellbuf.Rectangle) { - s.buf.Fill(c, rect) +func (s *Screen) Fill(c *Cell, rects ...Rectangle) { + s.mu.Lock() + defer s.mu.Unlock() + s.buf.Fill(c, rects...) + if s.cb.Damage != nil { + for _, r := range rects { + s.cb.Damage(RectDamage(r)) + } + } +} + +// setHorizontalMargins sets the horizontal margins. +func (s *Screen) setHorizontalMargins(left, right int) { + s.mu.Lock() + s.scroll.Min.X = left + s.scroll.Max.X = right + s.mu.Unlock() +} + +// setVerticalMargins sets the vertical margins. +func (s *Screen) setVerticalMargins(top, bottom int) { + s.mu.Lock() + s.scroll.Min.Y = top + s.scroll.Max.Y = bottom + s.mu.Unlock() +} + +// setCursorX sets the cursor X position. If margins is true, the cursor is +// only set if it is within the scroll margins. +func (s *Screen) setCursorX(x int, margins bool) { + s.setCursor(x, s.cur.Y, margins) +} + +// setCursorY sets the cursor Y position. If margins is true, the cursor is +// only set if it is within the scroll margins. +func (s *Screen) setCursorY(y int, margins bool) { + s.setCursor(s.cur.X, y, margins) } -// Pos returns the cursor position. -func (s *Screen) Pos() (int, int) { - return s.cur.Pos.X, s.cur.Pos.Y +// setCursor sets the cursor position. If margins is true, the cursor is only +// set if it is within the scroll margins. This follows how [ansi.CUP] works. +func (s *Screen) setCursor(x, y int, margins bool) { + s.mu.Lock() + old := s.cur.Position + if !margins { + y = clamp(y, 0, s.buf.Height()-1) + x = clamp(x, 0, s.buf.Width()-1) + } else { + y = clamp(s.scroll.Min.Y+y, s.scroll.Min.Y, s.scroll.Max.Y-1) + x = clamp(s.scroll.Min.X+x, s.scroll.Min.X, s.scroll.Max.X-1) + } + s.cur.X, s.cur.Y = x, y + s.mu.Unlock() + if s.cb.CursorPosition != nil && (old.X != x || old.Y != y) { + s.cb.CursorPosition(old, Pos(x, y)) + } +} + +// moveCursor moves the cursor by the given x and y deltas. If the cursor +// position is inside the scroll region, it is bounded by the scroll region. +// Otherwise, it is bounded by the screen bounds. +// This follows how [ansi.CUU], [ansi.CUD], [ansi.CUF], [ansi.CUB], [ansi.CNL], +// [ansi.CPL]. +func (s *Screen) moveCursor(dx, dy int) { + s.mu.Lock() + scroll := s.scroll + old := s.cur.Position + if old.X < scroll.Min.X { + scroll.Min.X = 0 + } + if old.X >= scroll.Max.X { + scroll.Max.X = s.buf.Width() + } + + pt := Pos(s.cur.X+dx, s.cur.Y+dy) + + var x, y int + if scroll.Contains(old) { + y = clamp(pt.Y, scroll.Min.Y, scroll.Max.Y-1) + x = clamp(pt.X, scroll.Min.X, scroll.Max.X-1) + } else { + y = clamp(pt.Y, 0, s.buf.Height()-1) + x = clamp(pt.X, 0, s.buf.Width()-1) + } + + s.cur.X, s.cur.Y = x, y + s.mu.Unlock() + if s.cb.CursorPosition != nil && (old.X != x || old.Y != y) { + s.cb.CursorPosition(old, Pos(x, y)) + } } // Cursor returns the cursor. func (s *Screen) Cursor() Cursor { + s.mu.RLock() + defer s.mu.RUnlock() return s.cur } +// CursorPosition returns the cursor position. +func (s *Screen) CursorPosition() (x, y int) { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cur.X, s.cur.Y +} + +// ScrollRegion returns the scroll region. +func (s *Screen) ScrollRegion() Rectangle { + s.mu.RLock() + defer s.mu.RUnlock() + return s.scroll +} + // SaveCursor saves the cursor. func (s *Screen) SaveCursor() { + s.mu.Lock() s.saved = s.cur + s.mu.Unlock() } // RestoreCursor restores the cursor. func (s *Screen) RestoreCursor() { + s.mu.Lock() + old := s.cur.Position s.cur = s.saved + s.mu.Unlock() + if s.cb.CursorPosition != nil && (old.X != s.cur.X || old.Y != s.cur.Y) { + s.cb.CursorPosition(old, s.cur.Position) + } +} + +// setCursorHidden sets the cursor hidden. +func (s *Screen) setCursorHidden(hidden bool) { + s.mu.Lock() + wasHidden := s.cur.Hidden + s.cur.Hidden = hidden + s.mu.Unlock() + if s.cb.CursorVisibility != nil && wasHidden != hidden { + s.cb.CursorVisibility(!hidden) + } +} + +// setCursorStyle sets the cursor style. +func (s *Screen) setCursorStyle(style CursorStyle, blink bool) { + s.mu.Lock() + s.cur.Style = style + s.cur.Steady = !blink + s.mu.Unlock() + if s.cb.CursorStyle != nil { + s.cb.CursorStyle(style, !blink) + } +} + +// cursorPen returns the cursor pen. +func (s *Screen) cursorPen() Style { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cur.Pen } // ShowCursor shows the cursor. func (s *Screen) ShowCursor() { - s.cur.Visible = true + s.setCursorHidden(false) } // HideCursor hides the cursor. func (s *Screen) HideCursor() { - s.cur.Visible = false + s.setCursorHidden(true) +} + +// InsertCell inserts n blank characters at the cursor position pushing out +// cells to the right and out of the screen. +func (s *Screen) InsertCell(n int) { + if n <= 0 { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + x, y := s.cur.X, s.cur.Y + + s.buf.InsertCell(x, y, n, s.blankCell(), s.scroll) + if s.cb.Damage != nil { + s.cb.Damage(RectDamage(Rect(x, y, s.scroll.Width()-x, 1))) + } +} + +// DeleteCell deletes n cells at the cursor position moving cells to the left. +// This has no effect if the cursor is outside the scroll region. +func (s *Screen) DeleteCell(n int) { + if n <= 0 { + return + } + + s.mu.Lock() + defer s.mu.Unlock() + x, y := s.cur.X, s.cur.Y + + s.buf.DeleteCell(x, y, n, s.blankCell(), s.scroll) + if s.cb.Damage != nil { + s.cb.Damage(RectDamage(Rect(x, y, s.scroll.Width()-x, 1))) + } +} + +// ScrollUp scrolls the content up n lines within the given region. Lines +// scrolled past the top margin are lost. This is equivalent to [ansi.SU] which +// moves the cursor to the top margin and performs a [ansi.DL] operation. +func (s *Screen) ScrollUp(n int) { + x, y := s.CursorPosition() + s.setCursor(s.cur.X, 0, true) + s.DeleteLine(n) + s.setCursor(x, y, false) +} + +// ScrollDown scrolls the content down n lines within the given region. Lines +// scrolled past the bottom margin are lost. This is equivalent to [ansi.SD] +// which moves the cursor to top margin and performs a [ansi.IL] operation. +func (s *Screen) ScrollDown(n int) { + x, y := s.CursorPosition() + s.setCursor(s.cur.X, 0, true) + s.InsertLine(n) + s.setCursor(x, y, false) } -// moveCursor moves the cursor. -func (s *Screen) moveCursor(x, y int) { - s.cur.Pos.X = x - s.cur.Pos.Y = y +// InsertLine inserts n blank lines at the cursor position Y coordinate. +// Only operates if cursor is within scroll region. Lines below cursor Y +// are moved down, with those past bottom margin being discarded. +// It returns true if the operation was successful. +func (s *Screen) InsertLine(n int) bool { + if n <= 0 { + return false + } + + s.mu.Lock() + defer s.mu.Unlock() + x, y := s.cur.X, s.cur.Y + + // Only operate if cursor Y is within scroll region + if y < s.scroll.Min.Y || y >= s.scroll.Max.Y || + x < s.scroll.Min.X || x >= s.scroll.Max.X { + return false + } + + s.buf.InsertLine(y, n, s.blankCell(), s.scroll) + if s.cb.Damage != nil { + rect := s.scroll + rect.Min.Y = y + rect.Max.Y += n + s.cb.Damage(RectDamage(rect)) + } + + return true +} + +// DeleteLine deletes n lines at the cursor position Y coordinate. +// Only operates if cursor is within scroll region. Lines below cursor Y +// are moved up, with blank lines inserted at the bottom of scroll region. +// It returns true if the operation was successful. +func (s *Screen) DeleteLine(n int) bool { + if n <= 0 { + return false + } + + s.mu.Lock() + defer s.mu.Unlock() + scroll := s.scroll + x, y := s.cur.X, s.cur.Y + + // Only operate if cursor Y is within scroll region + if y < scroll.Min.Y || y >= scroll.Max.Y || + x < scroll.Min.X || x >= scroll.Max.X { + return false + } + + s.buf.DeleteLine(y, n, s.blankCell(), scroll) + if s.cb.Damage != nil { + rect := scroll + rect.Min.Y = y + rect.Max.Y += n + s.cb.Damage(RectDamage(rect)) + } + + return true +} + +// blankCell returns the cursor blank cell with the background color set to the +// current pen background color. If the pen background color is nil, the return +// value is nil. +func (s *Screen) blankCell() (c *Cell) { + if s.cur.Pen.Bg == nil { + return + } + + c = new(Cell) + *c = blankCell + c.Style.Bg = s.cur.Pen.Bg + return } diff --git a/vt/tabstop.go b/vt/tabstop.go index 60caa26e..cec5bbdc 100644 --- a/vt/tabstop.go +++ b/vt/tabstop.go @@ -4,39 +4,116 @@ package vt const DefaultTabInterval = 8 // TabStops represents horizontal line tab stops. -type TabStops []int +type TabStops struct { + stops []int + interval int + width int +} // NewTabStops creates a new set of tab stops from a number of columns and an // interval. -func NewTabStops(cols, interval int) TabStops { - ts := make(TabStops, 0, cols/interval) - for i := interval; i < cols; i += interval { - ts = append(ts, i) +func NewTabStops(width, interval int) *TabStops { + ts := new(TabStops) + ts.interval = interval + ts.width = width + ts.stops = make([]int, (width+(interval-1))/interval) + for x := 0; x < width; x++ { + if x%interval == 0 { + ts.Set(x) + } else { + ts.Reset(x) + } } return ts } // DefaultTabStops creates a new set of tab stops with the default interval. -func DefaultTabStops(cols int) TabStops { +func DefaultTabStops(cols int) *TabStops { return NewTabStops(cols, DefaultTabInterval) } +// IsStop returns true if the given column is a tab stop. +func (ts TabStops) IsStop(col int) bool { + mask := ts.mask(col) + i := col >> 3 + if i < 0 || i >= len(ts.stops) { + return false + } + return ts.stops[i]&mask != 0 +} + // Next returns the next tab stop after the given column. func (ts TabStops) Next(col int) int { - for _, t := range ts { - if t > col { - return t - } - } - return col + return ts.Find(col, 1) } // Prev returns the previous tab stop before the given column. func (ts TabStops) Prev(col int) int { - for i := len(ts) - 1; i >= 0; i-- { - if ts[i] < col { - return ts[i] + return ts.Find(col, -1) +} + +// Find returns the prev/next tab stop before/after the given column and delta. +// If delta is positive, it returns the next tab stop after the given column. +// If delta is negative, it returns the previous tab stop before the given column. +// If delta is zero, it returns the given column. +func (ts TabStops) Find(col, delta int) int { + if delta == 0 { + return col + } + + var prev bool + count := delta + if count < 0 { + count = -count + prev = true + } + + for count > 0 { + if !prev { + if col >= ts.width-1 { + return col + } + + col++ + } else { + if col < 1 { + return col + } + + col-- + } + + if ts.IsStop(col) { + count-- } } + return col } + +// Set adds a tab stop at the given column. +func (ts *TabStops) Set(col int) { + mask := ts.mask(col) + ts.stops[col>>3] |= mask +} + +// Reset removes the tab stop at the given column. +func (ts *TabStops) Reset(col int) { + mask := ts.mask(col) + ts.stops[col>>3] &= ^mask +} + +// Clear removes all tab stops. +func (ts *TabStops) Clear() { + ts.stops = make([]int, 0, len(ts.stops)) +} + +// mask returns the mask for the given column. +func (ts *TabStops) mask(col int) int { + return 1 << (col & (ts.interval - 1)) +} + +// resetTabStops resets the terminal tab stops to the default set. +func (t *Terminal) resetTabStops() { + t.tabstops = DefaultTabStops(t.Width()) +} diff --git a/vt/terminal.go b/vt/terminal.go index 1f964fc8..2851bdea 100644 --- a/vt/terminal.go +++ b/vt/terminal.go @@ -2,66 +2,107 @@ package vt import ( "bytes" - "unicode" - "unicode/utf8" + "image/color" + "io" + "sync" + "time" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi/parser" - "github.com/charmbracelet/x/cellbuf" -) - -type ( - // Style represents a style. - Style = cellbuf.Style - - // Link represents a hyperlink. - Link = cellbuf.Link ) // Terminal represents a virtual terminal. type Terminal struct { - // The input buffer of the terminal. - buf bytes.Buffer - - // The current focused screen. - scr *Screen + // The terminal's indexed 256 colors. + colors [256]color.Color // Both main and alt screens. scrs [2]Screen + // Character sets + charsets [4]CharSet + + // log is the logger to use. + logger Logger + + // terminal default colors. + fg, bg, cur color.Color + // Terminal modes. - modes map[ansi.ANSIMode]ModeSetting - pmodes map[ansi.DECMode]ModeSetting + modes map[ansi.Mode]ansi.ModeSetting + + // The current focused screen. + scr *Screen + + // The last written character. + lastChar ansi.Sequence // either ansi.Rune or ansi.Grapheme // The ANSI parser to use. parser *ansi.Parser + Callbacks Callbacks + // The terminal's icon name and title. iconName, title string - // Bell handler. When set, this function is called when a bell character is - // received. - Bell func() + // tabstop is the list of tab stops. + tabstops *TabStops + + // The input buffer of the terminal. + buf bytes.Buffer + + mu sync.Mutex + + // The GL and GR character set identifiers. + gl, gr int + gsingle int // temporarily select GL or GR + + // Indicates if the terminal is closed. + closed bool + + // atPhantom indicates if the cursor is out of bounds. + // When true, and a character is written, the cursor is moved to the next line. + atPhantom bool } +var ( + defaultFg = color.White + defaultBg = color.Black + defaultCur = color.White +) + // NewTerminal creates a new terminal. -func NewTerminal(w, h int) *Terminal { +func NewTerminal(w, h int, opts ...Option) *Terminal { t := new(Terminal) t.scrs[0] = *NewScreen(w, h) t.scrs[1] = *NewScreen(w, h) + t.scrs[0].cb = &t.Callbacks + t.scrs[1].cb = &t.Callbacks t.scr = &t.scrs[0] - t.parser = ansi.NewParser(parser.MaxParamsSize, 1024*4) // 4MB data buffer - t.modes = map[ansi.ANSIMode]ModeSetting{} - t.pmodes = map[ansi.DECMode]ModeSetting{ - // These modes are set by default. - ansi.AutowrapMode: ModeSet, - ansi.CursorEnableMode: ModeSet, + t.parser = ansi.NewParser(t.dispatcher) // 4MB data buffer + t.parser.SetParamsSize(parser.MaxParamsSize) + t.parser.SetDataSize(1024 * 1024 * 4) // 4MB data buffer + t.resetModes() + t.tabstops = DefaultTabStops(w) + t.fg = defaultFg + t.bg = defaultBg + t.cur = defaultCur + + for _, opt := range opts { + opt(t) } + return t } -// At returns the cell at the given position. -func (t *Terminal) At(x int, y int) (cellbuf.Cell, bool) { +// Screen returns the currently active terminal screen. +func (t *Terminal) Screen() *Screen { + return t.scr +} + +// Cell returns the current focused screen cell at the given x, y position. It returns nil if the cell +// is out of bounds. +func (t *Terminal) Cell(x, y int) *Cell { return t.scr.Cell(x, y) } @@ -75,71 +116,192 @@ func (t *Terminal) Width() int { return t.scr.Width() } +// CursorPosition returns the terminal's cursor position. +func (t *Terminal) CursorPosition() Position { + x, y := t.scr.CursorPosition() + return Pos(x, y) +} + // Resize resizes the terminal. func (t *Terminal) Resize(width int, height int) { + x, y := t.scr.CursorPosition() + if t.atPhantom { + if x < width-1 { + t.atPhantom = false + x++ + } + } + + if y < 0 { + y = 0 + } + if y >= height { + y = height - 1 + } + if x < 0 { + x = 0 + } + if x >= width { + x = width - 1 + } + t.scrs[0].Resize(width, height) t.scrs[1].Resize(width, height) + t.tabstops = DefaultTabStops(width) + + t.setCursor(x, y) } // Read reads data from the terminal input buffer. func (t *Terminal) Read(p []byte) (n int, err error) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed { + return 0, io.EOF + } + + if t.buf.Len() == 0 { + time.Sleep(10 * time.Millisecond) + return 0, nil + } + return t.buf.Read(p) } +// Close closes the terminal. +func (t *Terminal) Close() error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.closed { + return nil + } + + t.closed = true + return nil +} + +// dispatcher parses and dispatches escape sequences and operates on the terminal. +func (t *Terminal) dispatcher(seq ansi.Sequence) { + switch seq := seq.(type) { + case ansi.ApcSequence: + case ansi.PmSequence: + case ansi.SosSequence: + case ansi.DcsSequence: + t.handleDcs(seq) + case ansi.OscSequence: + t.handleOsc(seq) + case ansi.CsiSequence: + t.handleCsi(seq) + case ansi.EscSequence: + t.handleEsc(seq) + case ansi.ControlCode: + t.handleControl(seq) + case ansi.Rune: + t.handleUtf8(seq) + case ansi.Grapheme: + t.handleUtf8(seq) + } +} + // Write writes data to the terminal output buffer. func (t *Terminal) Write(p []byte) (n int, err error) { - var state byte - for len(p) > 0 { - seq, width, m, newState := ansi.DecodeSequence(p, state, t.parser) - r, rw := utf8.DecodeRune(seq) - - switch { - case ansi.HasCsiPrefix(seq): - t.handleCsi(seq) - case ansi.HasOscPrefix(seq): - t.handleOsc(seq) - case ansi.HasDcsPrefix(seq): - t.handleDcs(seq) - case ansi.HasEscPrefix(seq): - t.handleEsc(seq) - case len(seq) == 1 && unicode.IsControl(r): - t.handleControl(r) - default: - t.handleUtf8(seq, width, r, rw) - } + t.mu.Lock() + defer t.mu.Unlock() + + var i int + for i < len(p) { + t.parser.Advance(p[i]) + // TODO: Support grapheme clusters (mode 2027). + i++ + } + + return i, nil +} + +// InputPipe returns the terminal's input pipe. +// This can be used to send input to the terminal. +func (t *Terminal) InputPipe() io.Writer { + return &t.buf +} + +// Paste pastes text into the terminal. +// If bracketed paste mode is enabled, the text is bracketed with the +// appropriate escape sequences. +func (t *Terminal) Paste(text string) { + if t.isModeSet(ansi.BracketedPasteMode) { + t.buf.WriteString(ansi.BracketedPasteStart) + defer t.buf.WriteString(ansi.BracketedPasteEnd) + } - state = newState - p = p[m:] - n += m + t.buf.WriteString(text) +} + +// SendText sends text to the terminal. +func (t *Terminal) SendText(text string) { + t.buf.WriteString(text) +} - // x, y := t.Cursor().Pos.X, t.Cursor().Pos.Y - // fmt.Printf("%q: %d %d\n", seq, x, y) +// SendKeys sends multiple keys to the terminal. +func (t *Terminal) SendKeys(keys ...Key) { + for _, k := range keys { + t.SendKey(k) } +} - return +// ForegroundColor returns the terminal's foreground color. +func (t *Terminal) ForegroundColor() color.Color { + return t.fg } -// Cursor returns the cursor. -func (t *Terminal) Cursor() Cursor { - return t.scr.Cursor() +// SetForegroundColor sets the terminal's foreground color. +func (t *Terminal) SetForegroundColor(c color.Color) { + t.fg = c } -// Pos returns the cursor position. -func (t *Terminal) Pos() (int, int) { - return t.scr.Pos() +// BackgroundColor returns the terminal's background color. +func (t *Terminal) BackgroundColor() color.Color { + return t.bg } -// Title returns the terminal's title. -func (t *Terminal) Title() string { - return t.title +// SetBackgroundColor sets the terminal's background color. +func (t *Terminal) SetBackgroundColor(c color.Color) { + t.bg = c } -// IconName returns the terminal's icon name. -func (t *Terminal) IconName() string { - return t.iconName +// CursorColor returns the terminal's cursor color. +func (t *Terminal) CursorColor() color.Color { + return t.cur } -// String returns the terminal's content as a string. -func (t *Terminal) String() string { - return cellbuf.Render(t.scr) +// SetCursorColor sets the terminal's cursor color. +func (t *Terminal) SetCursorColor(c color.Color) { + t.cur = c +} + +// IndexedColor returns a terminal's indexed color. An indexed color is a color +// between 0 and 255. +func (t *Terminal) IndexedColor(i int) color.Color { + if i < 0 || i > 255 { + return nil + } + + c := t.colors[i] + if c == nil { + // Return the default color. + return ansi.ExtendedColor(i) //nolint:gosec + } + + return c +} + +// SetIndexedColor sets a terminal's indexed color. +// The index must be between 0 and 255. +func (t *Terminal) SetIndexedColor(i int, c color.Color) { + if i < 0 || i > 255 { + return + } + + t.colors[i] = c } diff --git a/vt/terminal_test.go b/vt/terminal_test.go new file mode 100644 index 00000000..07e3a442 --- /dev/null +++ b/vt/terminal_test.go @@ -0,0 +1,1823 @@ +package vt + +import ( + "testing" +) + +// testLogger wraps a testing.TB to implement the Logger interface. +type testLogger struct { + t testing.TB +} + +// Printf implements the Logger interface. +func (l *testLogger) Printf(format string, v ...interface{}) { + l.t.Logf(format, v...) +} + +// newTestTerminal creates a new test terminal. +func newTestTerminal(t testing.TB, width, height int) *Terminal { + return NewTerminal(width, height, WithLogger(&testLogger{t})) +} + +var cases = []struct { + name string + w, h int + input []string + want []string + pos Position +}{ + // Cursor Backward Tabulation [ansi.CBT] + { + name: "CBT Left Beyond First Column", + w: 10, h: 1, + input: []string{ + "\x1b[?W", // reset tab stops + "\x1b[10Z", + "A", + }, + want: []string{"A "}, + pos: Pos(1, 0), + }, + { + name: "CBT Left Starting After Tab Stop", + w: 11, h: 1, + input: []string{ + "\x1b[?W", // reset tab stops + "\x1b[1;10H", + "X", + "\x1b[Z", + "A", + }, + want: []string{" AX "}, + pos: Pos(9, 0), + }, + { + name: "CBT Left Starting on Tabstop", + w: 10, h: 1, + input: []string{ + "\x1b[?W", // reset tab stops + "\x1b[1;9H", + "X", + "\x1b[1;9H", + "\x1b[Z", + "A", + }, + want: []string{"A X "}, + pos: Pos(1, 0), + }, + { + name: "CBT Left Margin with Origin Mode", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top left + "\x1b[0J", // clear screen + "\x1b[?W", // reset tab stops + "\x1b[?6h", // origin mode + "\x1b[?69h", // left margin mode + "\x1b[3;6s", // scroll region left/right + "\x1b[1;2H", + "X", + "\x1b[Z", + "A", + }, + want: []string{" AX "}, + pos: Pos(3, 0), + }, + + // Cursor Horizontal Tabulation [ansi.CHT] + { + name: "CHT Right Beyond Last Column", + w: 10, h: 1, + input: []string{ + "\x1b[?W", // reset tab stops + "\x1b[100I", // move right 100 tab stops + "A", + }, + want: []string{" A"}, + pos: Pos(9, 0), + }, + { + name: "CHT Right From Before Tabstop", + w: 10, h: 1, + input: []string{ + "\x1b[?W", // reset tab stops + "\x1b[1;2H", // move to column 2 + "A", + "\x1b[I", // move right one tab stop + "X", + }, + want: []string{" A X "}, + pos: Pos(9, 0), + }, + { + name: "CHT Right Margin", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?W", // reset tab stops + "\x1b[?69h", // enable left/right margins + "\x1b[3;6s", // scroll region left/right + "\x1b[1;1H", // move cursor in region + "X", + "\x1b[I", // move right one tab stop + "A", + }, + want: []string{"X A "}, + pos: Pos(6, 0), + }, + + // Carriage Return [ansi.CR] + { + name: "CR Pending Wrap is Unset", + w: 10, h: 2, + input: []string{ + "\x1b[10G", // move to last column + "A", // set pending wrap state + "\r", // carriage return + "X", + }, + want: []string{ + "X A", + " ", + }, + pos: Pos(1, 0), + }, + { + name: "CR Left Margin", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?69h", // enable left/right margin mode + "\x1b[2;5s", // set left/right margin + "\x1b[4G", // move to column 4 + "A", + "\r", + "X", + }, + want: []string{" X A "}, + pos: Pos(2, 0), + }, + { + name: "CR Left of Left Margin", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?69h", // enable left/right margin mode + "\x1b[2;5s", // set left/right margin + "\x1b[4G", // move to column 4 + "A", + "\x1b[1G", + "\r", + "X", + }, + want: []string{"X A "}, + pos: Pos(1, 0), + }, + { + name: "CR Left Margin with Origin Mode", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?6h", // enable origin mode + "\x1b[?69h", // enable left/right margin mode + "\x1b[2;5s", // set left/right margin + "\x1b[4G", // move to column 4 + "A", + "\x1b[1G", + "\r", + "X", + }, + want: []string{" X A "}, + pos: Pos(2, 0), + }, + + // Cursor Backward [ansi.CUB] + { + name: "CUB Pending Wrap is Unset", + w: 10, h: 2, + input: []string{ + "\x1b[10G", // move to last column + "A", // set pending wrap state + "\x1b[D", // move back one + "XYZ", + }, + want: []string{ + " XY", + "Z ", + }, + pos: Pos(1, 1), + }, + { + name: "CUB Leftmost Boundary with Reverse Wrap Disabled", + w: 10, h: 2, + input: []string{ + "\x1b[?45l", // disable reverse wrap + "A\n", + "\x1b[10D", // back + "B", + }, + want: []string{ + "A ", + "B ", + }, + pos: Pos(1, 1), + }, + { + name: "CUB Reverse Wrap", + w: 10, h: 2, + input: []string{ + "\x1b[?7h", // enable wraparound + "\x1b[?45h", // enable reverse wrap + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[10G", // move to end of line + "AB", // write and wrap + "\x1b[D", // move back one + "X", + }, + want: []string{ + " A", + "X ", + }, + pos: Pos(1, 1), + }, + // TODO: Support Reverse Wrap (XTREVWRAP) and Extended Reverse Wrap (XTREVWRAP2) + // { + // name: "CUB Extended Reverse Wrap Single Line", + // w: 10, h: 2, + // input: []string{ + // "\x1b[?7h", // enable wraparound + // "\x1b[?1045h", // enable extended reverse wrap + // "\x1b[1;1H", // move to top-left + // "\x1b[0J", // clear screen + // "A\nB", + // "\x1b[2D", // move back two + // "X", + // }, + // want: []string{ + // "A X", + // "B ", + // }, + // pos: Pos(9, 0), + // }, + // { + // name: "CUB Extended Reverse Wrap Wraps to Bottom", + // w: 10, h: 3, + // input: []string{ + // "\x1b[?7h", // enable wraparound + // "\x1b[?1045h", // enable extended reverse wrap + // "\x1b[1;1H", // move to top-left + // "\x1b[0J", // clear screen + // "\x1b[1;3r", // set scrolling region + // "A\nB", + // "\x1b[D", // move back one + // "\x1b[10D", // move back entire width + // "\x1b[D", // move back one + // "X", + // }, + // want: []string{ + // "A ", + // "B ", + // " X", + // }, + // pos: Pos(9, 2), + // }, + // { + // name: "CUB Reverse Wrap Outside of Margins", + // w: 10, h: 3, + // input: []string{ + // "\x1b[1;1H", // move to top-left + // "\x1b[0J", // clear screen + // "\x1b[?45h", // enable reverse wrap + // "\x1b[3r", // set scroll region + // "\b", // backspace + // "X", + // }, + // want: []string{ + // " ", + // " ", + // "X ", + // }, + // pos: Pos(1, 2), + // }, + // { + // name: "CUB Reverse Wrap with Pending Wrap State", + // w: 10, h: 1, + // input: []string{ + // "\x1b[?45h", // enable reverse wrap + // "\x1b[10G", // move to end + // "\x1b[4D", // back 4 + // "ABCDE", + // "\x1b[D", // back 1 + // "X", + // }, + // want: []string{ + // " ABCDX", + // }, + // pos: Pos(9, 0), + // }, + + // Cursor Down [ansi.CUD] + { + name: "CUD Cursor Down", + w: 10, h: 3, + input: []string{ + "A", + "\x1b[2B", // cursor down 2 lines + "X", + }, + want: []string{ + "A ", + " ", + " X ", + }, + pos: Pos(2, 2), + }, + { + name: "CUD Cursor Down Above Bottom Margin", + w: 10, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\n\n\n\n", // move down 4 lines + "\x1b[1;3r", // set scrolling region + "A", + "\x1b[5B", // cursor down 5 lines + "X", + }, + want: []string{ + "A ", + " ", + " X ", + " ", + }, + pos: Pos(2, 2), + }, + { + name: "CUD Cursor Down Below Bottom Margin", + w: 10, h: 5, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\n\n\n\n\n", // move down 5 lines + "\x1b[1;3r", // set scrolling region + "A", + "\x1b[4;1H", // move below region + "\x1b[5B", // cursor down 5 lines + "X", + }, + want: []string{ + "A ", + " ", + " ", + " ", + "X ", + }, + pos: Pos(1, 4), + }, + + // Cursor Position [ansi.CUP] + { + name: "CUP Normal Usage", + w: 10, h: 2, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[2;3H", // move to row 2, col 3 + "A", + }, + want: []string{ + " ", + " A ", + }, + pos: Pos(3, 1), + }, + { + name: "CUP Off the Screen", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[500;500H", // move way off screen + "A", + }, + want: []string{ + " ", + " ", + " A", + }, + pos: Pos(9, 2), + }, + { + name: "CUP Relative to Origin", + w: 10, h: 2, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[2;3r", // scroll region top/bottom + "\x1b[?6h", // origin mode + "\x1b[1;1H", // move to top-left + "X", + }, + want: []string{ + " ", + "X ", + }, + pos: Pos(1, 1), + }, + { + name: "CUP Relative to Origin with Margins", + w: 10, h: 2, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?69h", // enable left/right margins + "\x1b[3;5s", // scroll region left/right + "\x1b[2;3r", // scroll region top/bottom + "\x1b[?6h", // origin mode + "\x1b[1;1H", // move to top-left + "X", + }, + want: []string{ + " ", + " X ", + }, + pos: Pos(3, 1), + }, + { + name: "CUP Limits with Scroll Region and Origin Mode", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?69h", // enable left/right margins + "\x1b[3;5s", // scroll region left/right + "\x1b[2;3r", // scroll region top/bottom + "\x1b[?6h", // origin mode + "\x1b[500;500H", // move way off screen + "X", + }, + want: []string{ + " ", + " ", + " X ", + }, + pos: Pos(5, 2), + }, + { + name: "CUP Pending Wrap is Unset", + w: 10, h: 1, + input: []string{ + "\x1b[10G", // move to last column + "A", // set pending wrap state + "\x1b[1;1H", + "X", + }, + want: []string{ + "X A", + }, + pos: Pos(1, 0), + }, + + // Cursor Forward [ansi.CUF] + { + name: "CUF Pending Wrap is Unset", + w: 10, h: 2, + input: []string{ + "\x1b[10G", // move to last column + "A", // set pending wrap state + "\x1b[C", // move forward one + "XYZ", + }, + want: []string{ + " X", + "YZ ", + }, + pos: Pos(2, 1), + }, + { + name: "CUF Rightmost Boundary", + w: 10, h: 1, + input: []string{ + "A", + "\x1b[500C", // forward larger than screen width + "B", + }, + want: []string{ + "A B", + }, + pos: Pos(9, 0), + }, + { + name: "CUF Left of Right Margin", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?69h", // enable left/right margins + "\x1b[3;5s", // scroll region left/right + "\x1b[1G", // move to left + "\x1b[500C", // forward larger than screen width + "X", + }, + want: []string{ + " X ", + }, + pos: Pos(5, 0), + }, + { + name: "CUF Right of Right Margin", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?69h", // enable left/right margins + "\x1b[3;5s", // scroll region left/right + "\x1b[6G", // move to right of margin + "\x1b[500C", // forward larger than screen width + "X", + }, + want: []string{ + " X", + }, + pos: Pos(9, 0), + }, + + // Cursor Up [ansi.CUU] + { + name: "CUU Normal Usage", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[3;1H", // move to row 3 + "A", + "\x1b[2A", // cursor up 2 + "X", + }, + want: []string{ + " X ", + " ", + "A ", + }, + pos: Pos(2, 0), + }, + { + name: "CUU Below Top Margin", + w: 10, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[2;4r", // set scrolling region + "\x1b[3;1H", // move to row 3 + "A", + "\x1b[5A", // cursor up 5 + "X", + }, + want: []string{ + " ", + " X ", + "A ", + " ", + }, + pos: Pos(2, 1), + }, + { + name: "CUU Above Top Margin", + w: 10, h: 5, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[3;5r", // set scrolling region + "\x1b[3;1H", // move to row 3 + "A", + "\x1b[2;1H", // move above region + "\x1b[5A", // cursor up 5 + "X", + }, + want: []string{ + "X ", + " ", + "A ", + " ", + " ", + }, + pos: Pos(1, 0), + }, + + // Delete Line [ansi.DL] + { + name: "DL Simple Delete Line", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;2H", + "\x1b[M", + }, + want: []string{ + "ABC ", + "GHI ", + " ", + }, + pos: Pos(0, 1), + }, + { + name: "DL Cursor Outside Scroll Region", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[3;4r", // scroll region top/bottom + "\x1b[2;2H", + "\x1b[M", + }, + want: []string{ + "ABC ", + "DEF ", + "GHI ", + }, + pos: Pos(1, 1), + }, + { + name: "DL With Top/Bottom Scroll Regions", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI\r\n", + "123", + "\x1b[1;3r", // scroll region top/bottom + "\x1b[2;2H", + "\x1b[M", + }, + want: []string{ + "ABC ", + "GHI ", + " ", + "123 ", + }, + pos: Pos(0, 1), + }, + { + name: "DL With Left/Right Scroll Regions", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC123\r\n", + "DEF456\r\n", + "GHI789", + "\x1b[?69h", // enable left/right margins + "\x1b[2;4s", // scroll region left/right + "\x1b[2;2H", + "\x1b[M", + }, + want: []string{ + "ABC123 ", + "DHI756 ", + "G 89 ", + }, + pos: Pos(1, 1), + }, + + // Insert Line [ansi.IL] + { + name: "IL Simple Insert Line", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;2H", + "\x1b[L", + }, + want: []string{ + "ABC ", + " ", + "DEF ", + "GHI ", + }, + pos: Pos(0, 1), + }, + { + name: "IL Cursor Outside Scroll Region", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[3;4r", // scroll region top/bottom + "\x1b[2;2H", + "\x1b[L", + }, + want: []string{ + "ABC ", + "DEF ", + "GHI ", + }, + pos: Pos(1, 1), + }, + { + name: "IL With Top/Bottom Scroll Regions", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI\r\n", + "123", + "\x1b[1;3r", // scroll region top/bottom + "\x1b[2;2H", + "\x1b[L", + }, + want: []string{ + "ABC ", + " ", + "DEF ", + "123 ", + }, + pos: Pos(0, 1), + }, + { + name: "IL With Left/Right Scroll Regions", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC123\r\n", + "DEF456\r\n", + "GHI789", + "\x1b[?69h", // enable left/right margins + "\x1b[2;4s", // scroll region left/right + "\x1b[2;2H", + "\x1b[L", + }, + want: []string{ + "ABC123 ", + "D 56 ", + "GEF489 ", + " HI7 ", + }, + pos: Pos(1, 1), + }, + + // Delete Character [ansi.DCH] + { + name: "DCH Simple Delete Character", + w: 8, h: 1, + input: []string{ + "ABC123", + "\x1b[3G", + "\x1b[2P", + }, + want: []string{"AB23 "}, + pos: Pos(2, 0), + }, + { + name: "DCH with SGR State", + w: 8, h: 1, + input: []string{ + "ABC123", + "\x1b[3G", + "\x1b[41m", + "\x1b[2P", + }, + want: []string{"AB23 "}, + pos: Pos(2, 0), + }, + { + name: "DCH Outside Left/Right Scroll Region", + w: 8, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC123", + "\x1b[?69h", // enable left/right margins + "\x1b[3;5s", // scroll region left/right + "\x1b[2G", + "\x1b[P", + }, + want: []string{"ABC123 "}, + pos: Pos(1, 0), + }, + { + name: "DCH Inside Left/Right Scroll Region", + w: 8, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC123", + "\x1b[?69h", // enable left/right margins + "\x1b[3;5s", // scroll region left/right + "\x1b[4G", + "\x1b[P", + }, + want: []string{"ABC2 3 "}, + pos: Pos(3, 0), + }, + { + name: "DCH Split Wide Character", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "A橋123", + "\x1b[3G", + "\x1b[P", + }, + want: []string{"A 123 "}, + pos: Pos(2, 0), + }, + + // Set Top and Bottom Margins [ansi.DECSTBM] + { + name: "DECSTBM Full Screen Scroll Up", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[r", // set full screen scroll region + "\x1b[T", // scroll up + }, + want: []string{ + " ", + "ABC ", + "DEF ", + "GHI ", + }, + pos: Pos(0, 0), + }, + { + name: "DECSTBM Top Only Scroll Up", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2r", // set scroll region from line 2 + "\x1b[T", // scroll up + }, + want: []string{ + "ABC ", + " ", + "DEF ", + "GHI ", + }, + pos: Pos(0, 0), + }, + { + name: "DECSTBM Top and Bottom Scroll Up", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[1;2r", // set scroll region from line 1 to 2 + "\x1b[T", // scroll up + }, + want: []string{ + " ", + "ABC ", + "GHI ", + " ", + }, + pos: Pos(0, 0), + }, + { + name: "DECSTBM Top Equal Bottom Scroll Up", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;2r", // set scroll region at line 2 only + "\x1b[T", // scroll up + }, + want: []string{ + " ", + "ABC ", + "DEF ", + "GHI ", + }, + pos: Pos(3, 2), + }, + + // Set Left/Right Margins [ansi.DECSLRM] + { + name: "DECSLRM Full Screen", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[?69h", // enable left/right margins + "\x1b[s", // scroll region left/right + "\x1b[X", + }, + want: []string{ + " BC ", + "DEF ", + "GHI ", + }, + pos: Pos(0, 0), + }, + { + name: "DECSLRM Left Only", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[?69h", // enable left/right margins + "\x1b[2s", // scroll region left/right + "\x1b[2G", // move cursor to column 2 + "\x1b[L", + }, + want: []string{ + "A ", + "DBC ", + "GEF ", + " HI ", + }, + pos: Pos(1, 0), + }, + { + name: "DECSLRM Left And Right", + w: 8, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[?69h", // enable left/right margins + "\x1b[1;2s", // scroll region left/right + "\x1b[2G", // move cursor to column 2 + "\x1b[L", + }, + want: []string{ + " C ", + "ABF ", + "DEI ", + "GH ", + }, + pos: Pos(0, 0), + }, + { + name: "DECSLRM Left Equal to Right", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[?69h", // enable left/right margins + "\x1b[2;2s", // scroll region left/right + "\x1b[X", + }, + want: []string{ + "ABC ", + "DEF ", + "GHI ", + }, + pos: Pos(3, 2), + }, + + // Erase Character [ansi.ECH] + { + name: "ECH Simple Operation", + w: 8, h: 1, + input: []string{ + "ABC", + "\x1b[1G", + "\x1b[2X", + }, + want: []string{" C "}, + pos: Pos(0, 0), + }, + { + name: "ECH Erasing Beyond Edge of Screen", + w: 8, h: 1, + input: []string{ + "\x1b[8G", + "\x1b[2D", + "ABC", + "\x1b[D", + "\x1b[10X", + }, + want: []string{" A "}, + pos: Pos(6, 0), + }, + { + name: "ECH Reset Pending Wrap State", + w: 8, h: 1, + input: []string{ + "\x1b[8G", // move to last column + "A", // set pending wrap state + "\x1b[X", // erase one char + "X", // write X + }, + want: []string{" X"}, + pos: Pos(7, 0), + }, + { + name: "ECH with SGR State", + w: 8, h: 1, + input: []string{ + "ABC", + "\x1b[1G", + "\x1b[41m", // set red background + "\x1b[2X", + }, + want: []string{" C "}, + pos: Pos(0, 0), + }, + { + name: "ECH Multi-cell Character", + w: 8, h: 1, + input: []string{ + "橋BC", + "\x1b[1G", + "\x1b[X", + "X", + }, + want: []string{"X BC "}, + pos: Pos(1, 0), + }, + { + name: "ECH Left/Right Scroll Region Ignored", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?69h", // enable left/right margins + "\x1b[1;3s", // scroll region left/right + "\x1b[4G", + "ABC", + "\x1b[1G", + "\x1b[4X", + }, + want: []string{" BC "}, + pos: Pos(0, 0), + }, + // TODO: Support DECSCA + // { + // name: "ECH Protected Attributes Ignored with DECSCA", + // w: 8, h: 1, + // input: []string{ + // "\x1bV", + // "ABC", + // "\x1b[1\"q", + // "\x1b[0\"q", + // "\x1b[1G", + // "\x1b[2X", + // }, + // want: []string{" C "}, + // pos: Pos(0, 0), + // }, + // { + // name: "ECH Protected Attributes Respected without DECSCA", + // w: 8, h: 1, + // input: []string{ + // "\x1b[1\"q", + // "ABC", + // "\x1bV", + // "\x1b[1G", + // "\x1b[2X", + // }, + // want: []string{"ABC "}, + // pos: Pos(0, 0), + // }, + + // Erase Line [ansi.EL] + { + name: "EL Simple Erase Right", + w: 8, h: 1, + input: []string{ + "ABCDE", + "\x1b[3G", + "\x1b[0K", + }, + want: []string{"AB "}, + pos: Pos(2, 0), + }, + { + name: "EL Erase Right Resets Pending Wrap", + w: 8, h: 1, + input: []string{ + "\x1b[8G", // move to last column + "A", // set pending wrap state + "\x1b[0K", // erase right + "X", + }, + want: []string{" X"}, + pos: Pos(7, 0), + }, + { + name: "EL Erase Right with SGR State", + w: 8, h: 1, + input: []string{ + "ABC", + "\x1b[2G", + "\x1b[41m", // set red background + "\x1b[0K", + }, + want: []string{"A "}, + pos: Pos(1, 0), + }, + { + name: "EL Erase Right Multi-cell Character", + w: 8, h: 1, + input: []string{ + "AB橋DE", + "\x1b[4G", + "\x1b[0K", + }, + want: []string{"AB "}, + pos: Pos(3, 0), + }, + { + name: "EL Erase Right with Left/Right Margins", + w: 10, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABCDE", + "\x1b[?69h", // enable left/right margins + "\x1b[1;3s", // scroll region left/right + "\x1b[2G", + "\x1b[0K", + }, + want: []string{"A "}, + pos: Pos(1, 0), + }, + { + name: "EL Simple Erase Left", + w: 8, h: 1, + input: []string{ + "ABCDE", + "\x1b[3G", + "\x1b[1K", + }, + want: []string{" DE "}, + pos: Pos(2, 0), + }, + { + name: "EL Erase Left with SGR State", + w: 8, h: 1, + input: []string{ + "ABC", + "\x1b[2G", + "\x1b[41m", // set red background + "\x1b[1K", + }, + want: []string{" C "}, + pos: Pos(1, 0), + }, + { + name: "EL Erase Left Multi-cell Character", + w: 8, h: 1, + input: []string{ + "AB橋DE", + "\x1b[3G", + "\x1b[1K", + }, + want: []string{" DE "}, + pos: Pos(2, 0), + }, + // TODO: Support DECSCA + // { + // name: "EL Erase Left Protected Attributes Ignored with DECSCA", + // w: 8, h: 1, + // input: []string{ + // "\x1bV", + // "ABCDE", + // "\x1b[1\"q", + // "\x1b[0\"q", + // "\x1b[2G", + // "\x1b[1K", + // }, + // want: []string{" CDE "}, + // pos: Pos(1, 0), + // }, + { + name: "EL Simple Erase Complete Line", + w: 8, h: 1, + input: []string{ + "ABCDE", + "\x1b[3G", + "\x1b[2K", + }, + want: []string{" "}, + pos: Pos(2, 0), + }, + { + name: "EL Erase Complete with SGR State", + w: 8, h: 1, + input: []string{ + "ABC", + "\x1b[2G", + "\x1b[41m", // set red background + "\x1b[2K", + }, + want: []string{" "}, + pos: Pos(1, 0), + }, + + // Index [ansi.IND] + { + name: "IND No Scroll Region Top of Screen", + w: 10, h: 2, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "A", + "\x1bD", // index + "X", + }, + want: []string{ + "A ", + " X ", + }, + pos: Pos(2, 1), + }, + { + name: "IND Bottom of Primary Screen", + w: 10, h: 2, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[2;1H", // move to bottom-left + "A", + "\x1bD", // index + "X", + }, + want: []string{ + "A ", + " X ", + }, + pos: Pos(2, 1), + }, + { + name: "IND Inside Scroll Region", + w: 10, h: 2, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[1;3r", // scroll region + "A", + "\x1bD", // index + "X", + }, + want: []string{ + "A ", + " X ", + }, + pos: Pos(2, 1), + }, + { + name: "IND Bottom of Scroll Region", + w: 10, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[1;3r", // scroll region + "\x1b[4;1H", // below scroll region + "B", + "\x1b[3;1H", // move to last row of region + "A", + "\x1bD", // index + "X", + }, + want: []string{ + " ", + "A ", + " X ", + "B ", + }, + pos: Pos(2, 2), + }, + { + name: "IND Bottom of Primary Screen with Scroll Region", + w: 10, h: 5, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[1;3r", // scroll region + "\x1b[3;1H", // move to last row of region + "A", + "\x1b[5;1H", // move to bottom-left + "\x1bD", // index + "X", + }, + want: []string{ + " ", + " ", + "A ", + " ", + "X ", + }, + pos: Pos(1, 4), + }, + { + name: "IND Outside of Left/Right Scroll Region", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?69h", // enable left/right margins + "\x1b[1;3r", // scroll region top/bottom + "\x1b[3;5s", // scroll region left/right + "\x1b[3;3H", + "A", + "\x1b[3;1H", + "\x1bD", // index + "X", + }, + want: []string{ + " ", + " ", + "X A ", + }, + pos: Pos(1, 2), + }, + { + name: "IND Inside of Left/Right Scroll Region", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "AAAAAA\r\n", + "AAAAAA\r\n", + "AAAAAA", + "\x1b[?69h", // enable left/right margins + "\x1b[1;3s", // set scroll region left/right + "\x1b[1;3r", // set scroll region top/bottom + "\x1b[3;1H", // Move to bottom left + "\x1bD", // index + }, + want: []string{ + "AAAAAA ", + "AAAAAA ", + " AAA ", + }, + pos: Pos(0, 2), + }, + + // Erase Display [ansi.ED] + { + name: "ED Simple Erase Below", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;2H", + "\x1b[0J", + }, + want: []string{ + "ABC ", + " ", + " ", + }, + pos: Pos(1, 1), + }, + { + name: "ED Erase Below with SGR State", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;2H", + "\x1b[41m", // set red background + "\x1b[0J", + }, + want: []string{ + "ABC ", + " ", + " ", + }, + pos: Pos(1, 1), + }, + { + name: "ED Erase Below with Multi-Cell Character", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "AB橋C\r\n", + "DE橋F\r\n", + "GH橋I", + "\x1b[2;4H", + "\x1b[0J", + }, + want: []string{ + "AB橋C ", + " ", + " ", + }, + pos: Pos(3, 1), + }, + { + name: "ED Simple Erase Above", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;2H", + "\x1b[1J", + }, + want: []string{ + " ", + " ", + "GHI ", + }, + pos: Pos(1, 1), + }, + { + name: "ED Simple Erase Complete", + w: 8, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;2H", + "\x1b[2J", + }, + want: []string{ + " ", + " ", + " ", + }, + pos: Pos(1, 1), + }, + + // Reverse Index [ansi.RI] + { + name: "RI No Scroll Region Top of Screen", + w: 10, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "A\r\n", + "B\r\n", + "C\r\n", + "\x1b[1;1H", // move to top-left + "\x1bM", // reverse index + "X", + }, + want: []string{ + "X ", + "A ", + "B ", + "C ", + }, + pos: Pos(1, 0), + }, + { + name: "RI No Scroll Region Not Top of Screen", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "A\r\n", + "B\r\n", + "C", + "\x1b[2;1H", + "\x1bM", // reverse index + "X", + }, + want: []string{ + "X ", + "B ", + "C ", + }, + pos: Pos(1, 0), + }, + { + name: "RI Top/Bottom Scroll Region", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "A\r\n", + "B\r\n", + "C", + "\x1b[2;3r", // scroll region + "\x1b[2;1H", + "\x1bM", // reverse index + "X", + }, + want: []string{ + "A ", + "X ", + "B ", + }, + pos: Pos(1, 1), + }, + { + name: "RI Outside of Top/Bottom Scroll Region", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "A\r\n", + "B\r\n", + "C", + "\x1b[2;3r", // scroll region + "\x1b[1;1H", + "\x1bM", // reverse index + }, + want: []string{ + "A ", + "B ", + "C ", + }, + pos: Pos(0, 0), + }, + { + name: "RI Left/Right Scroll Region", + w: 10, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[?69h", // enable left/right margins + "\x1b[2;3s", // scroll region left/right + "\x1b[1;2H", + "\x1bM", + }, + want: []string{ + "A ", + "DBC ", + "GEF ", + " HI ", + }, + pos: Pos(1, 0), + }, + { + name: "RI Outside Left/Right Scroll Region", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[?69h", // enable left/right margins + "\x1b[2;3s", // scroll region left/right + "\x1b[2;1H", + "\x1bM", + }, + want: []string{ + "ABC ", + "DEF ", + "GHI ", + }, + pos: Pos(0, 0), + }, + + // Scroll Down [ansi.SD] + { + name: "SD Outside of Top/Bottom Scroll Region", + w: 10, h: 4, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[3;4r", // scroll region top/bottom + "\x1b[2;2H", // move cursor outside region + "\x1b[T", // scroll down + }, + want: []string{ + "ABC ", + "DEF ", + " ", + "GHI ", + }, + pos: Pos(1, 1), + }, + + // Scroll Up [ansi.SU] + { + name: "SU Simple Usage", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;2H", + "\x1b[S", + }, + want: []string{ + "DEF ", + "GHI ", + " ", + }, + pos: Pos(1, 1), + }, + { + name: "SU Top/Bottom Scroll Region", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC\r\n", + "DEF\r\n", + "GHI", + "\x1b[2;3r", // scroll region top/bottom + "\x1b[1;1H", + "\x1b[S", + }, + want: []string{ + "ABC ", + "GHI ", + " ", + }, + pos: Pos(0, 0), + }, + { + name: "SU Left/Right Scroll Regions", + w: 10, h: 3, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "ABC123\r\n", + "DEF456\r\n", + "GHI789", + "\x1b[?69h", // enable left/right margins + "\x1b[2;4s", // scroll region left/right + "\x1b[2;2H", + "\x1b[S", + }, + want: []string{ + "AEF423 ", + "DHI756 ", + "G 89 ", + }, + pos: Pos(1, 1), + }, + { + name: "SU Preserves Pending Wrap", + w: 10, h: 4, + input: []string{ + "\x1b[1;10H", // move to top-right + "\x1b[2J", // clear screen + "A", + "\x1b[2;10H", + "B", + "\x1b[3;10H", + "C", + "\x1b[S", + "X", + }, + want: []string{ + " B", + " C", + " ", + "X ", + }, + pos: Pos(1, 3), + }, + { + name: "SU Scroll Full Top/Bottom Scroll Region", + w: 10, h: 5, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "top", + "\x1b[5;1H", + "ABCDEF", + "\x1b[2;5r", // scroll region top/bottom + "\x1b[4S", + }, + want: []string{ + "top ", + " ", + " ", + " ", + " ", + }, + pos: Pos(0, 0), + }, + + // Tab Clear [ansi.TBC] + { + name: "TBC Clear Single Tab Stop", + w: 23, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?W", // reset tabs + "\t", // tab to first stop + "\x1b[g", // clear current tab stop + "\x1b[1G", // move back to start + "\t", // tab again - should go to next stop + }, + want: []string{" "}, + pos: Pos(16, 0), + }, + { + name: "TBC Clear All Tab Stops", + w: 23, h: 1, + input: []string{ + "\x1b[1;1H", // move to top-left + "\x1b[0J", // clear screen + "\x1b[?W", // reset tabs + "\x1b[3g", // clear all tab stops + "\x1b[1G", // move back to start + "\t", // tab - should go to end since no stops + }, + want: []string{" "}, + pos: Pos(22, 0), + }, +} + +// TestTerminal tests the terminal. +func TestTerminal(t *testing.T) { + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + term := newTestTerminal(t, tt.w, tt.h) + for _, in := range tt.input { + term.Write([]byte(in)) + } + got := termText(term) + if len(got) != len(tt.want) { + t.Errorf("output length doesn't match: want %d, got %d", len(tt.want), len(got)) + } + for i := 0; i < len(got) && i < len(tt.want); i++ { + if got[i] != tt.want[i] { + t.Errorf("line %d doesn't match:\nwant: %q\ngot: %q", i+1, tt.want[i], got[i]) + } + } + pos := term.CursorPosition() + if pos != tt.pos { + t.Errorf("cursor position doesn't match: want %v, got %v", tt.pos, pos) + } + }) + } +} + +func termText(term *Terminal) []string { + var lines []string + for y := 0; y < term.Height(); y++ { + var line string + for x := 0; x < term.Width(); x++ { + cell := term.Cell(x, y) + if cell == nil { + continue + } + line += cell.Content + } + lines = append(lines, line) + } + return lines +} diff --git a/vt/utf8.go b/vt/utf8.go index fa5267c2..51e30016 100644 --- a/vt/utf8.go +++ b/vt/utf8.go @@ -2,25 +2,74 @@ package vt import ( "github.com/charmbracelet/x/ansi" - "github.com/charmbracelet/x/cellbuf" + "github.com/charmbracelet/x/wcwidth" ) // handleUtf8 handles a UTF-8 characters. -func (t *Terminal) handleUtf8(seq []byte, width int, r rune, rw int) { - cur := t.scr.cur - x, y := cur.Pos.X, cur.Pos.Y - if autowrap, ok := t.pmodes[ansi.AutowrapMode]; ok && autowrap.IsSet() { - if x+width > t.scr.Width() { - x = 0 - y++ +func (t *Terminal) handleUtf8(seq ansi.Sequence) { + var width int + var content string + switch seq := seq.(type) { + case ansi.Rune: + width = wcwidth.RuneWidth(rune(seq)) + content = string(seq) + case ansi.Grapheme: + width = seq.Width + content = seq.Cluster + default: + return + } + + x, y := t.scr.CursorPosition() + if t.atPhantom || x+width > t.scr.Width() { + // moves cursor down similar to [Terminal.linefeed] except it doesn't + // respects [ansi.LNM] mode. + // This will rest the phantom state i.e. pending wrap state. + t.index() + _, y = t.scr.CursorPosition() + x = 0 + } + + // Handle character set mappings + if len(content) == 1 { + var charset CharSet + c := content[0] + if t.gsingle > 1 && t.gsingle < 4 { + charset = t.charsets[t.gsingle] + t.gsingle = 0 + } else if c < 128 { + charset = t.charsets[t.gl] + } else { + charset = t.charsets[t.gr] + } + + if charset != nil { + if r, ok := charset[c]; ok { + content = r + } } } - t.scr.Draw(x, y, cellbuf.Cell{ - Style: t.scr.cur.Pen, - Link: cellbuf.Link{}, - Content: string(seq), + cell := &Cell{ + Style: t.scr.cursorPen(), + Link: Link{}, // TODO: Link support + Content: content, Width: width, - }) - t.scr.moveCursor(x+width, y) + } + + if t.scr.SetCell(x, y, cell) { + t.lastChar = seq + } + + // Handle phantom state at the end of the line + if x+width >= t.scr.Width() { + if t.isModeSet(ansi.AutoWrapMode) { + t.atPhantom = true + } + } else { + x += width + } + + // NOTE: We don't reset the phantom state here, we handle it up above. + t.scr.setCursor(x, y, false) } diff --git a/vt/utils.go b/vt/utils.go new file mode 100644 index 00000000..9335bdb7 --- /dev/null +++ b/vt/utils.go @@ -0,0 +1,22 @@ +package vt + +func max(a, b int) int { //nolint:predeclared + if a > b { + return a + } + return b +} + +func min(a, b int) int { //nolint:predeclared + if a > b { + return b + } + return a +} + +func clamp(v, low, high int) int { + if high < low { + low, high = high, low + } + return min(high, max(low, v)) +} diff --git a/vt/vt.go b/vt/vt.go index d26c19e3..052218bd 100644 --- a/vt/vt.go +++ b/vt/vt.go @@ -1,3 +1,3 @@ -// vt is a virtual terminal emulator that can be used to render text-based user -// interfaces. +// Package vt is a virtual terminal emulator that can be used to emulate a +// modern terminal application. package vt