summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authoradamdotdevin <[email protected]>2025-07-10 15:49:49 -0500
committeradamdotdevin <[email protected]>2025-07-10 15:49:58 -0500
commit294d0e7ee3476f4425c3d21fbaf82dfce3aba017 (patch)
tree3c24c5caf7075612dc58ff4cdb1b1f87eedc2d9b
parent8be1ca836c806c5a3ea3f2f5b49a696063dd3a91 (diff)
downloadopencode-294d0e7ee3476f4425c3d21fbaf82dfce3aba017.tar.gz
opencode-294d0e7ee3476f4425c3d21fbaf82dfce3aba017.zip
fix(tui): mouse wheel ansi codes leaking into editor
-rw-r--r--packages/tui/cmd/opencode/main.go2
-rw-r--r--packages/tui/go.mod15
-rw-r--r--packages/tui/go.sum18
-rw-r--r--packages/tui/input/cancelreader_other.go14
-rw-r--r--packages/tui/input/cancelreader_windows.go143
-rw-r--r--packages/tui/input/clipboard.go25
-rw-r--r--packages/tui/input/color.go136
-rw-r--r--packages/tui/input/cursor.go7
-rw-r--r--packages/tui/input/da1.go18
-rw-r--r--packages/tui/input/doc.go6
-rw-r--r--packages/tui/input/driver.go196
-rw-r--r--packages/tui/input/driver_other.go17
-rw-r--r--packages/tui/input/driver_test.go25
-rw-r--r--packages/tui/input/driver_windows.go620
-rw-r--r--packages/tui/input/driver_windows_test.go271
-rw-r--r--packages/tui/input/focus.go9
-rw-r--r--packages/tui/input/focus_test.go27
-rw-r--r--packages/tui/input/go.mod18
-rw-r--r--packages/tui/input/go.sum19
-rw-r--r--packages/tui/input/input.go45
-rw-r--r--packages/tui/input/key.go574
-rw-r--r--packages/tui/input/key_test.go880
-rw-r--r--packages/tui/input/kitty.go353
-rw-r--r--packages/tui/input/mod.go37
-rw-r--r--packages/tui/input/mode.go14
-rw-r--r--packages/tui/input/mouse.go292
-rw-r--r--packages/tui/input/mouse_test.go481
-rw-r--r--packages/tui/input/parse.go1029
-rw-r--r--packages/tui/input/parse_test.go47
-rw-r--r--packages/tui/input/paste.go13
-rw-r--r--packages/tui/input/table.go389
-rw-r--r--packages/tui/input/termcap.go54
-rw-r--r--packages/tui/input/terminfo.go277
-rw-r--r--packages/tui/input/xterm.go47
-rw-r--r--packages/tui/internal/tui/tui.go47
35 files changed, 6104 insertions, 61 deletions
diff --git a/packages/tui/cmd/opencode/main.go b/packages/tui/cmd/opencode/main.go
index 363ade844..2f079cfab 100644
--- a/packages/tui/cmd/opencode/main.go
+++ b/packages/tui/cmd/opencode/main.go
@@ -77,7 +77,7 @@ func main() {
program := tea.NewProgram(
tui.NewModel(app_),
tea.WithAltScreen(),
- tea.WithKeyboardEnhancements(),
+ // tea.WithKeyboardEnhancements(),
tea.WithMouseCellMotion(),
)
diff --git a/packages/tui/go.mod b/packages/tui/go.mod
index 12dd3f88e..f2ad0c56c 100644
--- a/packages/tui/go.mod
+++ b/packages/tui/go.mod
@@ -6,10 +6,11 @@ require (
github.com/BurntSushi/toml v1.5.0
github.com/alecthomas/chroma/v2 v2.18.0
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1
- github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3
+ github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4
github.com/charmbracelet/glamour v0.10.0
- github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1
- github.com/charmbracelet/x/ansi v0.8.0
+ github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3
+ github.com/charmbracelet/x/ansi v0.9.3
+ github.com/charmbracelet/x/input v0.3.7
github.com/google/uuid v1.6.0
github.com/lithammer/fuzzysearch v1.1.8
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6
@@ -21,7 +22,10 @@ require (
rsc.io/qr v0.2.0
)
-replace github.com/sst/opencode-sdk-go => ./sdk
+replace (
+ github.com/charmbracelet/x/input => ./input
+ github.com/sst/opencode-sdk-go => ./sdk
+)
require golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
@@ -30,7 +34,6 @@ require (
github.com/atombender/go-jsonschema v0.20.0 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
- github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 // indirect
github.com/charmbracelet/x/windows v0.2.1 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
@@ -65,7 +68,7 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
- github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 // indirect
+ github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/google/go-cmp v0.7.0 // indirect
diff --git a/packages/tui/go.sum b/packages/tui/go.sum
index 9eab3f577..370ea7121 100644
--- a/packages/tui/go.sum
+++ b/packages/tui/go.sum
@@ -22,26 +22,24 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1 h1:swACzss0FjnyPz1enfX56GKkLiuKg5FlyVmOLIlU2kE=
github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3 h1:5A2e3myxXMpCES+kjEWgGsaf9VgZXjZbLi5iMTH7j40=
-github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.3/go.mod h1:ZFDg5oPjyRYrPAa3iFrtP1DO8xy+LUQxd9JFHEcuwJY=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4 h1:UgUuKKvBwgqm2ZEL+sKv/OLeavrUb4gfHgdxe6oIOno=
+github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4/go.mod h1:0wWFRpsgF7vHsCukVZ5LAhZkiR4j875H6KEM2/tFQmA=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 h1:D9AJJuYTN5pvz6mpIGO1ijLKpfTYSHOtKGgwoTQ4Gog=
-github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
-github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
-github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81 h1:iGrflaL5jQW6crML+pZx/ulWAVZQR3CQoRGvFsr2Tyg=
-github.com/charmbracelet/x/cellbuf v0.0.14-0.20250501183327-ad3bc78c6a81/go.mod h1:poPFOXFTsJsnLbkV3H2KxAAXT7pdjxxLujLocWjkyzM=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3 h1:W6DpZX6zSkZr0iFq6JVh1vItLoxfYtNlaxOJtWp8Kis=
+github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3/go.mod h1:65HTtKURcv/ict9ZQhr6zT84JqIjMcJbyrZYHHKNfKA=
+github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
+github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1 h1:MTSs/nsZNfZPbYk/r9hluK2BtwoqvEYruAujNVwgDv0=
+github.com/charmbracelet/x/cellbuf v0.0.14-0.20250505150409-97991a1f17d1/go.mod h1:xBlh2Yi3DL3zy/2n15kITpg0YZardf/aa/hgUaIM6Rk=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw=
github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
-github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197 h1:fsWj8NF5njyMVzELc7++HsvRDvgz3VcgGAUgWBDWWWM=
-github.com/charmbracelet/x/input v0.3.5-0.20250424101541-abb4d9a9b197/go.mod h1:xseGeVftoP9rVI+/8WKYrJFH6ior6iERGvklwwHz5+s=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
diff --git a/packages/tui/input/cancelreader_other.go b/packages/tui/input/cancelreader_other.go
new file mode 100644
index 000000000..dbd22a2e9
--- /dev/null
+++ b/packages/tui/input/cancelreader_other.go
@@ -0,0 +1,14 @@
+//go:build !windows
+// +build !windows
+
+package input
+
+import (
+ "io"
+
+ "github.com/muesli/cancelreader"
+)
+
+func newCancelreader(r io.Reader, _ int) (cancelreader.CancelReader, error) {
+ return cancelreader.NewReader(r) //nolint:wrapcheck
+}
diff --git a/packages/tui/input/cancelreader_windows.go b/packages/tui/input/cancelreader_windows.go
new file mode 100644
index 000000000..19abfce4a
--- /dev/null
+++ b/packages/tui/input/cancelreader_windows.go
@@ -0,0 +1,143 @@
+//go:build windows
+// +build windows
+
+package input
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "sync"
+
+ xwindows "github.com/charmbracelet/x/windows"
+ "github.com/muesli/cancelreader"
+ "golang.org/x/sys/windows"
+)
+
+type conInputReader struct {
+ cancelMixin
+ conin windows.Handle
+ originalMode uint32
+}
+
+var _ cancelreader.CancelReader = &conInputReader{}
+
+func newCancelreader(r io.Reader, flags int) (cancelreader.CancelReader, error) {
+ fallback := func(io.Reader) (cancelreader.CancelReader, error) {
+ return cancelreader.NewReader(r)
+ }
+
+ var dummy uint32
+ if f, ok := r.(cancelreader.File); !ok || f.Fd() != os.Stdin.Fd() ||
+ // If data was piped to the standard input, it does not emit events
+ // anymore. We can detect this if the console mode cannot be set anymore,
+ // in this case, we fallback to the default cancelreader implementation.
+ windows.GetConsoleMode(windows.Handle(f.Fd()), &dummy) != nil {
+ return fallback(r)
+ }
+
+ conin, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE)
+ if err != nil {
+ return fallback(r)
+ }
+
+ // Discard any pending input events.
+ if err := xwindows.FlushConsoleInputBuffer(conin); err != nil {
+ return fallback(r)
+ }
+
+ modes := []uint32{
+ windows.ENABLE_WINDOW_INPUT,
+ windows.ENABLE_EXTENDED_FLAGS,
+ }
+
+ // Enabling mouse mode implicitly blocks console text selection. Thus, we
+ // need to enable it only if the mouse mode is requested.
+ // In order to toggle mouse mode, the caller must recreate the reader with
+ // the appropriate flag toggled.
+ if flags&FlagMouseMode != 0 {
+ modes = append(modes, windows.ENABLE_MOUSE_INPUT)
+ }
+
+ originalMode, err := prepareConsole(conin, modes...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to prepare console input: %w", err)
+ }
+
+ return &conInputReader{
+ conin: conin,
+ originalMode: originalMode,
+ }, nil
+}
+
+// Cancel implements cancelreader.CancelReader.
+func (r *conInputReader) Cancel() bool {
+ r.setCanceled()
+
+ return windows.CancelIoEx(r.conin, nil) == nil || windows.CancelIo(r.conin) == nil
+}
+
+// Close implements cancelreader.CancelReader.
+func (r *conInputReader) Close() error {
+ if r.originalMode != 0 {
+ err := windows.SetConsoleMode(r.conin, r.originalMode)
+ if err != nil {
+ return fmt.Errorf("reset console mode: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// Read implements cancelreader.CancelReader.
+func (r *conInputReader) Read(data []byte) (int, error) {
+ if r.isCanceled() {
+ return 0, cancelreader.ErrCanceled
+ }
+
+ var n uint32
+ if err := windows.ReadFile(r.conin, data, &n, nil); err != nil {
+ return int(n), fmt.Errorf("read console input: %w", err)
+ }
+
+ return int(n), nil
+}
+
+func prepareConsole(input windows.Handle, modes ...uint32) (originalMode uint32, err error) {
+ err = windows.GetConsoleMode(input, &originalMode)
+ if err != nil {
+ return 0, fmt.Errorf("get console mode: %w", err)
+ }
+
+ var newMode uint32
+ for _, mode := range modes {
+ newMode |= mode
+ }
+
+ err = windows.SetConsoleMode(input, newMode)
+ if err != nil {
+ return 0, fmt.Errorf("set console mode: %w", err)
+ }
+
+ return originalMode, nil
+}
+
+// cancelMixin represents a goroutine-safe cancelation status.
+type cancelMixin struct {
+ unsafeCanceled bool
+ lock sync.Mutex
+}
+
+func (c *cancelMixin) setCanceled() {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ c.unsafeCanceled = true
+}
+
+func (c *cancelMixin) isCanceled() bool {
+ c.lock.Lock()
+ defer c.lock.Unlock()
+
+ return c.unsafeCanceled
+}
diff --git a/packages/tui/input/clipboard.go b/packages/tui/input/clipboard.go
new file mode 100644
index 000000000..725a2d955
--- /dev/null
+++ b/packages/tui/input/clipboard.go
@@ -0,0 +1,25 @@
+package input
+
+import "github.com/charmbracelet/x/ansi"
+
+// ClipboardSelection represents a clipboard selection. The most common
+// clipboard selections are "system" and "primary" and selections.
+type ClipboardSelection = byte
+
+// Clipboard selections.
+const (
+ SystemClipboard ClipboardSelection = ansi.SystemClipboard
+ PrimaryClipboard ClipboardSelection = ansi.PrimaryClipboard
+)
+
+// ClipboardEvent is a clipboard read message event. This message is emitted when
+// a terminal receives an OSC52 clipboard read message event.
+type ClipboardEvent struct {
+ Content string
+ Selection ClipboardSelection
+}
+
+// String returns the string representation of the clipboard message.
+func (e ClipboardEvent) String() string {
+ return e.Content
+}
diff --git a/packages/tui/input/color.go b/packages/tui/input/color.go
new file mode 100644
index 000000000..9bcf74999
--- /dev/null
+++ b/packages/tui/input/color.go
@@ -0,0 +1,136 @@
+package input
+
+import (
+ "fmt"
+ "image/color"
+ "math"
+)
+
+// ForegroundColorEvent represents a foreground color event. This event is
+// emitted when the terminal requests the terminal foreground color using
+// [ansi.RequestForegroundColor].
+type ForegroundColorEvent struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e ForegroundColorEvent) String() string {
+ return colorToHex(e.Color)
+}
+
+// IsDark returns whether the color is dark.
+func (e ForegroundColorEvent) IsDark() bool {
+ return isDarkColor(e.Color)
+}
+
+// BackgroundColorEvent represents a background color event. This event is
+// emitted when the terminal requests the terminal background color using
+// [ansi.RequestBackgroundColor].
+type BackgroundColorEvent struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e BackgroundColorEvent) String() string {
+ return colorToHex(e)
+}
+
+// IsDark returns whether the color is dark.
+func (e BackgroundColorEvent) IsDark() bool {
+ return isDarkColor(e.Color)
+}
+
+// CursorColorEvent represents a cursor color change event. This event is
+// emitted when the program requests the terminal cursor color using
+// [ansi.RequestCursorColor].
+type CursorColorEvent struct{ color.Color }
+
+// String returns the hex representation of the color.
+func (e CursorColorEvent) String() string {
+ return colorToHex(e)
+}
+
+// IsDark returns whether the color is dark.
+func (e CursorColorEvent) IsDark() bool {
+ return isDarkColor(e)
+}
+
+type shiftable interface {
+ ~uint | ~uint16 | ~uint32 | ~uint64
+}
+
+func shift[T shiftable](x T) T {
+ if x > 0xff {
+ x >>= 8
+ }
+ return x
+}
+
+func colorToHex(c color.Color) string {
+ if c == nil {
+ return ""
+ }
+ r, g, b, _ := c.RGBA()
+ return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b))
+}
+
+func getMaxMin(a, b, c float64) (ma, mi float64) {
+ if a > b {
+ ma = a
+ mi = b
+ } else {
+ ma = b
+ mi = a
+ }
+ if c > ma {
+ ma = c
+ } else if c < mi {
+ mi = c
+ }
+ return ma, mi
+}
+
+func round(x float64) float64 {
+ return math.Round(x*1000) / 1000
+}
+
+// rgbToHSL converts an RGB triple to an HSL triple.
+func rgbToHSL(r, g, b uint8) (h, s, l float64) {
+ // convert uint32 pre-multiplied value to uint8
+ // The r,g,b values are divided by 255 to change the range from 0..255 to 0..1:
+ Rnot := float64(r) / 255
+ Gnot := float64(g) / 255
+ Bnot := float64(b) / 255
+ Cmax, Cmin := getMaxMin(Rnot, Gnot, Bnot)
+ Δ := Cmax - Cmin
+ // Lightness calculation:
+ l = (Cmax + Cmin) / 2
+ // Hue and Saturation Calculation:
+ if Δ == 0 {
+ h = 0
+ s = 0
+ } else {
+ switch Cmax {
+ case Rnot:
+ h = 60 * (math.Mod((Gnot-Bnot)/Δ, 6))
+ case Gnot:
+ h = 60 * (((Bnot - Rnot) / Δ) + 2)
+ case Bnot:
+ h = 60 * (((Rnot - Gnot) / Δ) + 4)
+ }
+ if h < 0 {
+ h += 360
+ }
+
+ s = Δ / (1 - math.Abs((2*l)-1))
+ }
+
+ return h, round(s), round(l)
+}
+
+// isDarkColor returns whether the given color is dark.
+func isDarkColor(c color.Color) bool {
+ if c == nil {
+ return true
+ }
+
+ r, g, b, _ := c.RGBA()
+ _, _, l := rgbToHSL(uint8(r>>8), uint8(g>>8), uint8(b>>8)) //nolint:gosec
+ return l < 0.5
+}
diff --git a/packages/tui/input/cursor.go b/packages/tui/input/cursor.go
new file mode 100644
index 000000000..cf4e973d2
--- /dev/null
+++ b/packages/tui/input/cursor.go
@@ -0,0 +1,7 @@
+package input
+
+import "image"
+
+// CursorPositionEvent represents a cursor position event. Where X is the
+// zero-based column and Y is the zero-based row.
+type CursorPositionEvent image.Point
diff --git a/packages/tui/input/da1.go b/packages/tui/input/da1.go
new file mode 100644
index 000000000..c2cd94cf7
--- /dev/null
+++ b/packages/tui/input/da1.go
@@ -0,0 +1,18 @@
+package input
+
+import "github.com/charmbracelet/x/ansi"
+
+// PrimaryDeviceAttributesEvent is an event that represents the terminal
+// primary device attributes.
+type PrimaryDeviceAttributesEvent []int
+
+func parsePrimaryDevAttrs(params ansi.Params) Event {
+ // Primary Device Attributes
+ da1 := make(PrimaryDeviceAttributesEvent, len(params))
+ for i, p := range params {
+ if !p.HasMore() {
+ da1[i] = p.Param(0)
+ }
+ }
+ return da1
+}
diff --git a/packages/tui/input/doc.go b/packages/tui/input/doc.go
new file mode 100644
index 000000000..2877d496e
--- /dev/null
+++ b/packages/tui/input/doc.go
@@ -0,0 +1,6 @@
+// Package input provides a set of utilities for handling input events in a
+// terminal environment. It includes support for reading input events, parsing
+// escape sequences, and handling clipboard events.
+// The package is designed to work with various terminal types and supports
+// customization through flags and options.
+package input
diff --git a/packages/tui/input/driver.go b/packages/tui/input/driver.go
new file mode 100644
index 000000000..ded96e756
--- /dev/null
+++ b/packages/tui/input/driver.go
@@ -0,0 +1,196 @@
+//nolint:unused,revive,nolintlint
+package input
+
+import (
+ "bytes"
+ "io"
+ "unicode/utf8"
+
+ "github.com/muesli/cancelreader"
+)
+
+// Logger is a simple logger interface.
+type Logger interface {
+ Printf(format string, v ...any)
+}
+
+// win32InputState is a state machine for parsing key events from the Windows
+// Console API into escape sequences and utf8 runes, and keeps track of the last
+// control key state to determine modifier key changes. It also keeps track of
+// the last mouse button state and window size changes to determine which mouse
+// buttons were released and to prevent multiple size events from firing.
+type win32InputState struct {
+ ansiBuf [256]byte
+ ansiIdx int
+ utf16Buf [2]rune
+ utf16Half bool
+ lastCks uint32 // the last control key state for the previous event
+ lastMouseBtns uint32 // the last mouse button state for the previous event
+ lastWinsizeX, lastWinsizeY int16 // the last window size for the previous event to prevent multiple size events from firing
+}
+
+// Reader represents an input event reader. It reads input events and parses
+// escape sequences from the terminal input buffer and translates them into
+// human-readable events.
+type Reader struct {
+ rd cancelreader.CancelReader
+ table map[string]Key // table is a lookup table for key sequences.
+
+ term string // term is the terminal name $TERM.
+
+ // paste is the bracketed paste mode buffer.
+ // When nil, bracketed paste mode is disabled.
+ paste []byte
+
+ buf [256]byte // do we need a larger buffer?
+
+ // partialSeq holds incomplete escape sequences that need more data
+ partialSeq []byte
+
+ // keyState keeps track of the current Windows Console API key events state.
+ // It is used to decode ANSI escape sequences and utf16 sequences.
+ keyState win32InputState
+
+ parser Parser
+ logger Logger
+}
+
+// NewReader returns a new input event reader. The reader reads input events
+// from the terminal and parses escape sequences into human-readable events. It
+// supports reading Terminfo databases. See [Parser] for more information.
+//
+// Example:
+//
+// r, _ := input.NewReader(os.Stdin, os.Getenv("TERM"), 0)
+// defer r.Close()
+// events, _ := r.ReadEvents()
+// for _, ev := range events {
+// log.Printf("%v", ev)
+// }
+func NewReader(r io.Reader, termType string, flags int) (*Reader, error) {
+ d := new(Reader)
+ cr, err := newCancelreader(r, flags)
+ if err != nil {
+ return nil, err
+ }
+
+ d.rd = cr
+ d.table = buildKeysTable(flags, termType)
+ d.term = termType
+ d.parser.flags = flags
+ return d, nil
+}
+
+// SetLogger sets a logger for the reader.
+func (d *Reader) SetLogger(l Logger) {
+ d.logger = l
+}
+
+// Read implements [io.Reader].
+func (d *Reader) Read(p []byte) (int, error) {
+ return d.rd.Read(p) //nolint:wrapcheck
+}
+
+// Cancel cancels the underlying reader.
+func (d *Reader) Cancel() bool {
+ return d.rd.Cancel()
+}
+
+// Close closes the underlying reader.
+func (d *Reader) Close() error {
+ return d.rd.Close() //nolint:wrapcheck
+}
+
+func (d *Reader) readEvents() ([]Event, error) {
+ nb, err := d.rd.Read(d.buf[:])
+ if err != nil {
+ return nil, err //nolint:wrapcheck
+ }
+
+ var events []Event
+
+ // Combine any partial sequence from previous read with new data
+ var buf []byte
+ if len(d.partialSeq) > 0 {
+ buf = make([]byte, len(d.partialSeq)+nb)
+ copy(buf, d.partialSeq)
+ copy(buf[len(d.partialSeq):], d.buf[:nb])
+ d.partialSeq = nil // clear the partial sequence
+ } else {
+ buf = d.buf[:nb]
+ }
+
+ // Lookup table first
+ if bytes.HasPrefix(buf, []byte{'\x1b'}) {
+ if k, ok := d.table[string(buf)]; ok {
+ if d.logger != nil {
+ d.logger.Printf("input: %q", buf)
+ }
+ events = append(events, KeyPressEvent(k))
+ return events, nil
+ }
+ }
+
+ var i int
+ for i < len(buf) {
+ nb, ev := d.parser.parseSequence(buf[i:])
+ if d.logger != nil && nb > 0 {
+ d.logger.Printf("input: %q", buf[i:i+nb])
+ }
+
+ // Handle incomplete sequences - when parseSequence returns (0, nil)
+ // it means we need more data to complete the sequence
+ if nb == 0 && ev == nil {
+ // Store the remaining data for the next read
+ remaining := len(buf) - i
+ if remaining > 0 {
+ d.partialSeq = make([]byte, remaining)
+ copy(d.partialSeq, buf[i:])
+ }
+ break
+ }
+
+ // Handle bracketed-paste
+ if d.paste != nil {
+ if _, ok := ev.(PasteEndEvent); !ok {
+ d.paste = append(d.paste, buf[i])
+ i++
+ continue
+ }
+ }
+
+ switch ev.(type) {
+ case UnknownEvent:
+ // If the sequence is not recognized by the parser, try looking it up.
+ if k, ok := d.table[string(buf[i:i+nb])]; ok {
+ ev = KeyPressEvent(k)
+ }
+ case PasteStartEvent:
+ d.paste = []byte{}
+ case PasteEndEvent:
+ // Decode the captured data into runes.
+ var paste []rune
+ for len(d.paste) > 0 {
+ r, w := utf8.DecodeRune(d.paste)
+ if r != utf8.RuneError {
+ paste = append(paste, r)
+ }
+ d.paste = d.paste[w:]
+ }
+ d.paste = nil // reset the buffer
+ events = append(events, PasteEvent(paste))
+ case nil:
+ i++
+ continue
+ }
+
+ if mevs, ok := ev.(MultiEvent); ok {
+ events = append(events, []Event(mevs)...)
+ } else {
+ events = append(events, ev)
+ }
+ i += nb
+ }
+
+ return events, nil
+}
diff --git a/packages/tui/input/driver_other.go b/packages/tui/input/driver_other.go
new file mode 100644
index 000000000..fd3df06c6
--- /dev/null
+++ b/packages/tui/input/driver_other.go
@@ -0,0 +1,17 @@
+//go:build !windows
+// +build !windows
+
+package input
+
+// ReadEvents reads input events from the terminal.
+//
+// It reads the events available in the input buffer and returns them.
+func (d *Reader) ReadEvents() ([]Event, error) {
+ return d.readEvents()
+}
+
+// parseWin32InputKeyEvent parses a Win32 input key events. This function is
+// only available on Windows.
+func (p *Parser) parseWin32InputKeyEvent(*win32InputState, uint16, uint16, rune, bool, uint32, uint16) Event {
+ return nil
+}
diff --git a/packages/tui/input/driver_test.go b/packages/tui/input/driver_test.go
new file mode 100644
index 000000000..affdf5b88
--- /dev/null
+++ b/packages/tui/input/driver_test.go
@@ -0,0 +1,25 @@
+package input
+
+import (
+ "io"
+ "strings"
+ "testing"
+)
+
+func BenchmarkDriver(b *testing.B) {
+ input := "\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~"
+ rdr := strings.NewReader(input)
+ drv, err := NewReader(rdr, "dumb", 0)
+ if err != nil {
+ b.Fatalf("could not create driver: %v", err)
+ }
+
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ rdr.Reset(input)
+ if _, err := drv.ReadEvents(); err != nil && err != io.EOF {
+ b.Errorf("error reading input: %v", err)
+ }
+ }
+}
diff --git a/packages/tui/input/driver_windows.go b/packages/tui/input/driver_windows.go
new file mode 100644
index 000000000..acdd69845
--- /dev/null
+++ b/packages/tui/input/driver_windows.go
@@ -0,0 +1,620 @@
+//go:build windows
+// +build windows
+
+package input
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf16"
+ "unicode/utf8"
+
+ "github.com/charmbracelet/x/ansi"
+ xwindows "github.com/charmbracelet/x/windows"
+ "github.com/muesli/cancelreader"
+ "golang.org/x/sys/windows"
+)
+
+// ReadEvents reads input events from the terminal.
+//
+// It reads the events available in the input buffer and returns them.
+func (d *Reader) ReadEvents() ([]Event, error) {
+ events, err := d.handleConInput()
+ if errors.Is(err, errNotConInputReader) {
+ return d.readEvents()
+ }
+ return events, err
+}
+
+var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader")
+
+func (d *Reader) handleConInput() ([]Event, error) {
+ cc, ok := d.rd.(*conInputReader)
+ if !ok {
+ return nil, errNotConInputReader
+ }
+
+ var (
+ events []xwindows.InputRecord
+ err error
+ )
+ for {
+ // Peek up to 256 events, this is to allow for sequences events reported as
+ // key events.
+ events, err = peekNConsoleInputs(cc.conin, 256)
+ if cc.isCanceled() {
+ return nil, cancelreader.ErrCanceled
+ }
+ if err != nil {
+ return nil, fmt.Errorf("peek coninput events: %w", err)
+ }
+ if len(events) > 0 {
+ break
+ }
+
+ // Sleep for a bit to avoid busy waiting.
+ time.Sleep(10 * time.Millisecond)
+ }
+
+ events, err = readNConsoleInputs(cc.conin, uint32(len(events)))
+ if cc.isCanceled() {
+ return nil, cancelreader.ErrCanceled
+ }
+ if err != nil {
+ return nil, fmt.Errorf("read coninput events: %w", err)
+ }
+
+ var evs []Event
+ for _, event := range events {
+ if e := d.parser.parseConInputEvent(event, &d.keyState); e != nil {
+ if multi, ok := e.(MultiEvent); ok {
+ evs = append(evs, multi...)
+ } else {
+ evs = append(evs, e)
+ }
+ }
+ }
+
+ return evs, nil
+}
+
+func (p *Parser) parseConInputEvent(event xwindows.InputRecord, keyState *win32InputState) Event {
+ switch event.EventType {
+ case xwindows.KEY_EVENT:
+ kevent := event.KeyEvent()
+ return p.parseWin32InputKeyEvent(keyState, kevent.VirtualKeyCode, kevent.VirtualScanCode,
+ kevent.Char, kevent.KeyDown, kevent.ControlKeyState, kevent.RepeatCount)
+
+ case xwindows.WINDOW_BUFFER_SIZE_EVENT:
+ wevent := event.WindowBufferSizeEvent()
+ if wevent.Size.X != keyState.lastWinsizeX || wevent.Size.Y != keyState.lastWinsizeY {
+ keyState.lastWinsizeX, keyState.lastWinsizeY = wevent.Size.X, wevent.Size.Y
+ return WindowSizeEvent{
+ Width: int(wevent.Size.X),
+ Height: int(wevent.Size.Y),
+ }
+ }
+ case xwindows.MOUSE_EVENT:
+ mevent := event.MouseEvent()
+ Event := mouseEvent(keyState.lastMouseBtns, mevent)
+ keyState.lastMouseBtns = mevent.ButtonState
+ return Event
+ case xwindows.FOCUS_EVENT:
+ fevent := event.FocusEvent()
+ if fevent.SetFocus {
+ return FocusEvent{}
+ }
+ return BlurEvent{}
+ case xwindows.MENU_EVENT:
+ // ignore
+ }
+ return nil
+}
+
+func mouseEventButton(p, s uint32) (MouseButton, bool) {
+ var isRelease bool
+ button := MouseNone
+ btn := p ^ s
+ if btn&s == 0 {
+ isRelease = true
+ }
+
+ if btn == 0 {
+ switch {
+ case s&xwindows.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
+ button = MouseLeft
+ case s&xwindows.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
+ button = MouseMiddle
+ case s&xwindows.RIGHTMOST_BUTTON_PRESSED > 0:
+ button = MouseRight
+ case s&xwindows.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
+ button = MouseBackward
+ case s&xwindows.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
+ button = MouseForward
+ }
+ return button, isRelease
+ }
+
+ switch btn {
+ case xwindows.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
+ button = MouseLeft
+ case xwindows.RIGHTMOST_BUTTON_PRESSED: // right button
+ button = MouseRight
+ case xwindows.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
+ button = MouseMiddle
+ case xwindows.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
+ button = MouseBackward
+ case xwindows.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
+ button = MouseForward
+ }
+
+ return button, isRelease
+}
+
+func mouseEvent(p uint32, e xwindows.MouseEventRecord) (ev Event) {
+ var mod KeyMod
+ var isRelease bool
+ if e.ControlKeyState&(xwindows.LEFT_ALT_PRESSED|xwindows.RIGHT_ALT_PRESSED) != 0 {
+ mod |= ModAlt
+ }
+ if e.ControlKeyState&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_CTRL_PRESSED) != 0 {
+ mod |= ModCtrl
+ }
+ if e.ControlKeyState&(xwindows.SHIFT_PRESSED) != 0 {
+ mod |= ModShift
+ }
+
+ m := Mouse{
+ X: int(e.MousePositon.X),
+ Y: int(e.MousePositon.Y),
+ Mod: mod,
+ }
+
+ wheelDirection := int16(highWord(e.ButtonState)) //nolint:gosec
+ switch e.EventFlags {
+ case 0, xwindows.DOUBLE_CLICK:
+ m.Button, isRelease = mouseEventButton(p, e.ButtonState)
+ case xwindows.MOUSE_WHEELED:
+ if wheelDirection > 0 {
+ m.Button = MouseWheelUp
+ } else {
+ m.Button = MouseWheelDown
+ }
+ case xwindows.MOUSE_HWHEELED:
+ if wheelDirection > 0 {
+ m.Button = MouseWheelRight
+ } else {
+ m.Button = MouseWheelLeft
+ }
+ case xwindows.MOUSE_MOVED:
+ m.Button, _ = mouseEventButton(p, e.ButtonState)
+ return MouseMotionEvent(m)
+ }
+
+ if isWheel(m.Button) {
+ return MouseWheelEvent(m)
+ } else if isRelease {
+ return MouseReleaseEvent(m)
+ }
+
+ return MouseClickEvent(m)
+}
+
+func highWord(data uint32) uint16 {
+ return uint16((data & 0xFFFF0000) >> 16) //nolint:gosec
+}
+
+func readNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
+ if maxEvents == 0 {
+ return nil, fmt.Errorf("maxEvents cannot be zero")
+ }
+
+ records := make([]xwindows.InputRecord, maxEvents)
+ n, err := readConsoleInput(console, records)
+ return records[:n], err
+}
+
+func readConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
+ if len(inputRecords) == 0 {
+ return 0, fmt.Errorf("size of input record buffer cannot be zero")
+ }
+
+ var read uint32
+
+ err := xwindows.ReadConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
+
+ return read, err //nolint:wrapcheck
+}
+
+func peekConsoleInput(console windows.Handle, inputRecords []xwindows.InputRecord) (uint32, error) {
+ if len(inputRecords) == 0 {
+ return 0, fmt.Errorf("size of input record buffer cannot be zero")
+ }
+
+ var read uint32
+
+ err := xwindows.PeekConsoleInput(console, &inputRecords[0], uint32(len(inputRecords)), &read) //nolint:gosec
+
+ return read, err //nolint:wrapcheck
+}
+
+func peekNConsoleInputs(console windows.Handle, maxEvents uint32) ([]xwindows.InputRecord, error) {
+ if maxEvents == 0 {
+ return nil, fmt.Errorf("maxEvents cannot be zero")
+ }
+
+ records := make([]xwindows.InputRecord, maxEvents)
+ n, err := peekConsoleInput(console, records)
+ return records[:n], err
+}
+
+// parseWin32InputKeyEvent parses a single key event from either the Windows
+// Console API or win32-input-mode events. When state is nil, it means this is
+// an event from win32-input-mode. Otherwise, it's a key event from the Windows
+// Console API and needs a state to decode ANSI escape sequences and utf16
+// runes.
+func (p *Parser) parseWin32InputKeyEvent(state *win32InputState, vkc uint16, _ uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) (event Event) {
+ defer func() {
+ // Respect the repeat count.
+ if repeatCount > 1 {
+ var multi MultiEvent
+ for i := 0; i < int(repeatCount); i++ {
+ multi = append(multi, event)
+ }
+ event = multi
+ }
+ }()
+ if state != nil {
+ defer func() {
+ state.lastCks = cks
+ }()
+ }
+
+ var utf8Buf [utf8.UTFMax]byte
+ var key Key
+ if state != nil && state.utf16Half {
+ state.utf16Half = false
+ state.utf16Buf[1] = r
+ codepoint := utf16.DecodeRune(state.utf16Buf[0], state.utf16Buf[1])
+ rw := utf8.EncodeRune(utf8Buf[:], codepoint)
+ r, _ = utf8.DecodeRune(utf8Buf[:rw])
+ key.Code = r
+ key.Text = string(r)
+ key.Mod = translateControlKeyState(cks)
+ key = ensureKeyCase(key, cks)
+ if keyDown {
+ return KeyPressEvent(key)
+ }
+ return KeyReleaseEvent(key)
+ }
+
+ var baseCode rune
+ switch {
+ case vkc == 0:
+ // Zero means this event is either an escape code or a unicode
+ // codepoint.
+ if state != nil && state.ansiIdx == 0 && r != ansi.ESC {
+ // This is a unicode codepoint.
+ baseCode = r
+ break
+ }
+
+ if state != nil {
+ // Collect ANSI escape code.
+ state.ansiBuf[state.ansiIdx] = byte(r)
+ state.ansiIdx++
+ if state.ansiIdx <= 2 {
+ // We haven't received enough bytes to determine if this is an
+ // ANSI escape code.
+ return nil
+ }
+ if r == ansi.ESC {
+ // We're expecting a closing String Terminator [ansi.ST].
+ return nil
+ }
+
+ n, event := p.parseSequence(state.ansiBuf[:state.ansiIdx])
+ if n == 0 {
+ return nil
+ }
+ if _, ok := event.(UnknownEvent); ok {
+ return nil
+ }
+
+ state.ansiIdx = 0
+ return event
+ }
+ case vkc == xwindows.VK_BACK:
+ baseCode = KeyBackspace
+ case vkc == xwindows.VK_TAB:
+ baseCode = KeyTab
+ case vkc == xwindows.VK_RETURN:
+ baseCode = KeyEnter
+ case vkc == xwindows.VK_SHIFT:
+ //nolint:nestif
+ if cks&xwindows.SHIFT_PRESSED != 0 {
+ if cks&xwindows.ENHANCED_KEY != 0 {
+ baseCode = KeyRightShift
+ } else {
+ baseCode = KeyLeftShift
+ }
+ } else if state != nil {
+ if state.lastCks&xwindows.SHIFT_PRESSED != 0 {
+ if state.lastCks&xwindows.ENHANCED_KEY != 0 {
+ baseCode = KeyRightShift
+ } else {
+ baseCode = KeyLeftShift
+ }
+ }
+ }
+ case vkc == xwindows.VK_CONTROL:
+ if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
+ baseCode = KeyLeftCtrl
+ } else if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
+ baseCode = KeyRightCtrl
+ } else if state != nil {
+ if state.lastCks&xwindows.LEFT_CTRL_PRESSED != 0 {
+ baseCode = KeyLeftCtrl
+ } else if state.lastCks&xwindows.RIGHT_CTRL_PRESSED != 0 {
+ baseCode = KeyRightCtrl
+ }
+ }
+ case vkc == xwindows.VK_MENU:
+ if cks&xwindows.LEFT_ALT_PRESSED != 0 {
+ baseCode = KeyLeftAlt
+ } else if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
+ baseCode = KeyRightAlt
+ } else if state != nil {
+ if state.lastCks&xwindows.LEFT_ALT_PRESSED != 0 {
+ baseCode = KeyLeftAlt
+ } else if state.lastCks&xwindows.RIGHT_ALT_PRESSED != 0 {
+ baseCode = KeyRightAlt
+ }
+ }
+ case vkc == xwindows.VK_PAUSE:
+ baseCode = KeyPause
+ case vkc == xwindows.VK_CAPITAL:
+ baseCode = KeyCapsLock
+ case vkc == xwindows.VK_ESCAPE:
+ baseCode = KeyEscape
+ case vkc == xwindows.VK_SPACE:
+ baseCode = KeySpace
+ case vkc == xwindows.VK_PRIOR:
+ baseCode = KeyPgUp
+ case vkc == xwindows.VK_NEXT:
+ baseCode = KeyPgDown
+ case vkc == xwindows.VK_END:
+ baseCode = KeyEnd
+ case vkc == xwindows.VK_HOME:
+ baseCode = KeyHome
+ case vkc == xwindows.VK_LEFT:
+ baseCode = KeyLeft
+ case vkc == xwindows.VK_UP:
+ baseCode = KeyUp
+ case vkc == xwindows.VK_RIGHT:
+ baseCode = KeyRight
+ case vkc == xwindows.VK_DOWN:
+ baseCode = KeyDown
+ case vkc == xwindows.VK_SELECT:
+ baseCode = KeySelect
+ case vkc == xwindows.VK_SNAPSHOT:
+ baseCode = KeyPrintScreen
+ case vkc == xwindows.VK_INSERT:
+ baseCode = KeyInsert
+ case vkc == xwindows.VK_DELETE:
+ baseCode = KeyDelete
+ case vkc >= '0' && vkc <= '9':
+ baseCode = rune(vkc)
+ case vkc >= 'A' && vkc <= 'Z':
+ // Convert to lowercase.
+ baseCode = rune(vkc) + 32
+ case vkc == xwindows.VK_LWIN:
+ baseCode = KeyLeftSuper
+ case vkc == xwindows.VK_RWIN:
+ baseCode = KeyRightSuper
+ case vkc == xwindows.VK_APPS:
+ baseCode = KeyMenu
+ case vkc >= xwindows.VK_NUMPAD0 && vkc <= xwindows.VK_NUMPAD9:
+ baseCode = rune(vkc-xwindows.VK_NUMPAD0) + KeyKp0
+ case vkc == xwindows.VK_MULTIPLY:
+ baseCode = KeyKpMultiply
+ case vkc == xwindows.VK_ADD:
+ baseCode = KeyKpPlus
+ case vkc == xwindows.VK_SEPARATOR:
+ baseCode = KeyKpComma
+ case vkc == xwindows.VK_SUBTRACT:
+ baseCode = KeyKpMinus
+ case vkc == xwindows.VK_DECIMAL:
+ baseCode = KeyKpDecimal
+ case vkc == xwindows.VK_DIVIDE:
+ baseCode = KeyKpDivide
+ case vkc >= xwindows.VK_F1 && vkc <= xwindows.VK_F24:
+ baseCode = rune(vkc-xwindows.VK_F1) + KeyF1
+ case vkc == xwindows.VK_NUMLOCK:
+ baseCode = KeyNumLock
+ case vkc == xwindows.VK_SCROLL:
+ baseCode = KeyScrollLock
+ case vkc == xwindows.VK_LSHIFT:
+ baseCode = KeyLeftShift
+ case vkc == xwindows.VK_RSHIFT:
+ baseCode = KeyRightShift
+ case vkc == xwindows.VK_LCONTROL:
+ baseCode = KeyLeftCtrl
+ case vkc == xwindows.VK_RCONTROL:
+ baseCode = KeyRightCtrl
+ case vkc == xwindows.VK_LMENU:
+ baseCode = KeyLeftAlt
+ case vkc == xwindows.VK_RMENU:
+ baseCode = KeyRightAlt
+ case vkc == xwindows.VK_VOLUME_MUTE:
+ baseCode = KeyMute
+ case vkc == xwindows.VK_VOLUME_DOWN:
+ baseCode = KeyLowerVol
+ case vkc == xwindows.VK_VOLUME_UP:
+ baseCode = KeyRaiseVol
+ case vkc == xwindows.VK_MEDIA_NEXT_TRACK:
+ baseCode = KeyMediaNext
+ case vkc == xwindows.VK_MEDIA_PREV_TRACK:
+ baseCode = KeyMediaPrev
+ case vkc == xwindows.VK_MEDIA_STOP:
+ baseCode = KeyMediaStop
+ case vkc == xwindows.VK_MEDIA_PLAY_PAUSE:
+ baseCode = KeyMediaPlayPause
+ case vkc == xwindows.VK_OEM_1:
+ baseCode = ';'
+ case vkc == xwindows.VK_OEM_PLUS:
+ baseCode = '+'
+ case vkc == xwindows.VK_OEM_COMMA:
+ baseCode = ','
+ case vkc == xwindows.VK_OEM_MINUS:
+ baseCode = '-'
+ case vkc == xwindows.VK_OEM_PERIOD:
+ baseCode = '.'
+ case vkc == xwindows.VK_OEM_2:
+ baseCode = '/'
+ case vkc == xwindows.VK_OEM_3:
+ baseCode = '`'
+ case vkc == xwindows.VK_OEM_4:
+ baseCode = '['
+ case vkc == xwindows.VK_OEM_5:
+ baseCode = '\\'
+ case vkc == xwindows.VK_OEM_6:
+ baseCode = ']'
+ case vkc == xwindows.VK_OEM_7:
+ baseCode = '\''
+ }
+
+ if utf16.IsSurrogate(r) {
+ if state != nil {
+ state.utf16Buf[0] = r
+ state.utf16Half = true
+ }
+ return nil
+ }
+
+ // AltGr is left ctrl + right alt. On non-US keyboards, this is used to type
+ // special characters and produce printable events.
+ // XXX: Should this be a KeyMod?
+ altGr := cks&(xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED) == xwindows.LEFT_CTRL_PRESSED|xwindows.RIGHT_ALT_PRESSED
+
+ var text string
+ keyCode := baseCode
+ if !unicode.IsControl(r) {
+ rw := utf8.EncodeRune(utf8Buf[:], r)
+ keyCode, _ = utf8.DecodeRune(utf8Buf[:rw])
+ if unicode.IsPrint(keyCode) && (cks == 0 ||
+ cks == xwindows.SHIFT_PRESSED ||
+ cks == xwindows.CAPSLOCK_ON ||
+ altGr) {
+ // If the control key state is 0, shift is pressed, or caps lock
+ // then the key event is a printable event i.e. [text] is not empty.
+ text = string(keyCode)
+ }
+ }
+
+ key.Code = keyCode
+ key.Text = text
+ key.Mod = translateControlKeyState(cks)
+ key.BaseCode = baseCode
+ key = ensureKeyCase(key, cks)
+ if keyDown {
+ return KeyPressEvent(key)
+ }
+
+ return KeyReleaseEvent(key)
+}
+
+// ensureKeyCase ensures that the key's text is in the correct case based on the
+// control key state.
+func ensureKeyCase(key Key, cks uint32) Key {
+ if len(key.Text) == 0 {
+ return key
+ }
+
+ hasShift := cks&xwindows.SHIFT_PRESSED != 0
+ hasCaps := cks&xwindows.CAPSLOCK_ON != 0
+ if hasShift || hasCaps {
+ if unicode.IsLower(key.Code) {
+ key.ShiftedCode = unicode.ToUpper(key.Code)
+ key.Text = string(key.ShiftedCode)
+ }
+ } else {
+ if unicode.IsUpper(key.Code) {
+ key.ShiftedCode = unicode.ToLower(key.Code)
+ key.Text = string(key.ShiftedCode)
+ }
+ }
+
+ return key
+}
+
+// translateControlKeyState translates the control key state from the Windows
+// Console API into a Mod bitmask.
+func translateControlKeyState(cks uint32) (m KeyMod) {
+ if cks&xwindows.LEFT_CTRL_PRESSED != 0 || cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
+ m |= ModCtrl
+ }
+ if cks&xwindows.LEFT_ALT_PRESSED != 0 || cks&xwindows.RIGHT_ALT_PRESSED != 0 {
+ m |= ModAlt
+ }
+ if cks&xwindows.SHIFT_PRESSED != 0 {
+ m |= ModShift
+ }
+ if cks&xwindows.CAPSLOCK_ON != 0 {
+ m |= ModCapsLock
+ }
+ if cks&xwindows.NUMLOCK_ON != 0 {
+ m |= ModNumLock
+ }
+ if cks&xwindows.SCROLLLOCK_ON != 0 {
+ m |= ModScrollLock
+ }
+ return
+}
+
+//nolint:unused
+func keyEventString(vkc, sc uint16, r rune, keyDown bool, cks uint32, repeatCount uint16) string {
+ var s strings.Builder
+ s.WriteString("vkc: ")
+ s.WriteString(fmt.Sprintf("%d, 0x%02x", vkc, vkc))
+ s.WriteString(", sc: ")
+ s.WriteString(fmt.Sprintf("%d, 0x%02x", sc, sc))
+ s.WriteString(", r: ")
+ s.WriteString(fmt.Sprintf("%q", r))
+ s.WriteString(", down: ")
+ s.WriteString(fmt.Sprintf("%v", keyDown))
+ s.WriteString(", cks: [")
+ if cks&xwindows.LEFT_ALT_PRESSED != 0 {
+ s.WriteString("left alt, ")
+ }
+ if cks&xwindows.RIGHT_ALT_PRESSED != 0 {
+ s.WriteString("right alt, ")
+ }
+ if cks&xwindows.LEFT_CTRL_PRESSED != 0 {
+ s.WriteString("left ctrl, ")
+ }
+ if cks&xwindows.RIGHT_CTRL_PRESSED != 0 {
+ s.WriteString("right ctrl, ")
+ }
+ if cks&xwindows.SHIFT_PRESSED != 0 {
+ s.WriteString("shift, ")
+ }
+ if cks&xwindows.CAPSLOCK_ON != 0 {
+ s.WriteString("caps lock, ")
+ }
+ if cks&xwindows.NUMLOCK_ON != 0 {
+ s.WriteString("num lock, ")
+ }
+ if cks&xwindows.SCROLLLOCK_ON != 0 {
+ s.WriteString("scroll lock, ")
+ }
+ if cks&xwindows.ENHANCED_KEY != 0 {
+ s.WriteString("enhanced key, ")
+ }
+ s.WriteString("], repeat count: ")
+ s.WriteString(fmt.Sprintf("%d", repeatCount))
+ return s.String()
+}
diff --git a/packages/tui/input/driver_windows_test.go b/packages/tui/input/driver_windows_test.go
new file mode 100644
index 000000000..45371fd13
--- /dev/null
+++ b/packages/tui/input/driver_windows_test.go
@@ -0,0 +1,271 @@
+package input
+
+import (
+ "encoding/binary"
+ "image/color"
+ "reflect"
+ "testing"
+ "unicode/utf16"
+
+ "github.com/charmbracelet/x/ansi"
+ xwindows "github.com/charmbracelet/x/windows"
+ "golang.org/x/sys/windows"
+)
+
+func TestWindowsInputEvents(t *testing.T) {
+ cases := []struct {
+ name string
+ events []xwindows.InputRecord
+ expected []Event
+ sequence bool // indicates that the input events are ANSI sequence or utf16
+ }{
+ {
+ name: "single key event",
+ events: []xwindows.InputRecord{
+ encodeKeyEvent(xwindows.KeyEventRecord{
+ KeyDown: true,
+ Char: 'a',
+ VirtualKeyCode: 'A',
+ }),
+ },
+ expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Text: "a"}},
+ },
+ {
+ name: "single key event with control key",
+ events: []xwindows.InputRecord{
+ encodeKeyEvent(xwindows.KeyEventRecord{
+ KeyDown: true,
+ Char: 'a',
+ VirtualKeyCode: 'A',
+ ControlKeyState: xwindows.LEFT_CTRL_PRESSED,
+ }),
+ },
+ expected: []Event{KeyPressEvent{Code: 'a', BaseCode: 'a', Mod: ModCtrl}},
+ },
+ {
+ name: "escape alt key event",
+ events: []xwindows.InputRecord{
+ encodeKeyEvent(xwindows.KeyEventRecord{
+ KeyDown: true,
+ Char: ansi.ESC,
+ VirtualKeyCode: ansi.ESC,
+ ControlKeyState: xwindows.LEFT_ALT_PRESSED,
+ }),
+ },
+ expected: []Event{KeyPressEvent{Code: ansi.ESC, BaseCode: ansi.ESC, Mod: ModAlt}},
+ },
+ {
+ name: "single shifted key event",
+ events: []xwindows.InputRecord{
+ encodeKeyEvent(xwindows.KeyEventRecord{
+ KeyDown: true,
+ Char: 'A',
+ VirtualKeyCode: 'A',
+ ControlKeyState: xwindows.SHIFT_PRESSED,
+ }),
+ },
+ expected: []Event{KeyPressEvent{Code: 'A', BaseCode: 'a', Text: "A", Mod: ModShift}},
+ },
+ {
+ name: "utf16 rune",
+ events: encodeUtf16Rune('😊'), // smiley emoji '😊'
+ expected: []Event{
+ KeyPressEvent{Code: '😊', Text: "😊"},
+ },
+ sequence: true,
+ },
+ {
+ name: "background color response",
+ events: encodeSequence("\x1b]11;rgb:ff/ff/ff\x07"),
+ expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
+ sequence: true,
+ },
+ {
+ name: "st terminated background color response",
+ events: encodeSequence("\x1b]11;rgb:ffff/ffff/ffff\x1b\\"),
+ expected: []Event{BackgroundColorEvent{Color: color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}}},
+ sequence: true,
+ },
+ {
+ name: "simple mouse event",
+ events: []xwindows.InputRecord{
+ encodeMouseEvent(xwindows.MouseEventRecord{
+ MousePositon: windows.Coord{X: 10, Y: 20},
+ ButtonState: xwindows.FROM_LEFT_1ST_BUTTON_PRESSED,
+ EventFlags: 0,
+ }),
+ encodeMouseEvent(xwindows.MouseEventRecord{
+ MousePositon: windows.Coord{X: 10, Y: 20},
+ EventFlags: 0,
+ }),
+ },
+ expected: []Event{
+ MouseClickEvent{Button: MouseLeft, X: 10, Y: 20},
+ MouseReleaseEvent{Button: MouseLeft, X: 10, Y: 20},
+ },
+ },
+ {
+ name: "focus event",
+ events: []xwindows.InputRecord{
+ encodeFocusEvent(xwindows.FocusEventRecord{
+ SetFocus: true,
+ }),
+ encodeFocusEvent(xwindows.FocusEventRecord{
+ SetFocus: false,
+ }),
+ },
+ expected: []Event{
+ FocusEvent{},
+ BlurEvent{},
+ },
+ },
+ {
+ name: "window size event",
+ events: []xwindows.InputRecord{
+ encodeWindowBufferSizeEvent(xwindows.WindowBufferSizeRecord{
+ Size: windows.Coord{X: 10, Y: 20},
+ }),
+ },
+ expected: []Event{
+ WindowSizeEvent{Width: 10, Height: 20},
+ },
+ },
+ }
+
+ // p is the parser to parse the input events
+ var p Parser
+
+ // keep track of the state of the driver to handle ANSI sequences and utf16
+ var state win32InputState
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if tc.sequence {
+ var Event Event
+ for _, ev := range tc.events {
+ if ev.EventType != xwindows.KEY_EVENT {
+ t.Fatalf("expected key event, got %v", ev.EventType)
+ }
+
+ key := ev.KeyEvent()
+ Event = p.parseWin32InputKeyEvent(&state, key.VirtualKeyCode, key.VirtualScanCode, key.Char, key.KeyDown, key.ControlKeyState, key.RepeatCount)
+ }
+ if len(tc.expected) != 1 {
+ t.Fatalf("expected 1 event, got %d", len(tc.expected))
+ }
+ if !reflect.DeepEqual(Event, tc.expected[0]) {
+ t.Errorf("expected %v, got %v", tc.expected[0], Event)
+ }
+ } else {
+ if len(tc.events) != len(tc.expected) {
+ t.Fatalf("expected %d events, got %d", len(tc.expected), len(tc.events))
+ }
+ for j, ev := range tc.events {
+ Event := p.parseConInputEvent(ev, &state)
+ if !reflect.DeepEqual(Event, tc.expected[j]) {
+ t.Errorf("expected %#v, got %#v", tc.expected[j], Event)
+ }
+ }
+ }
+ })
+ }
+}
+
+func boolToUint32(b bool) uint32 {
+ if b {
+ return 1
+ }
+ return 0
+}
+
+func encodeMenuEvent(menu xwindows.MenuEventRecord) xwindows.InputRecord {
+ var bts [16]byte
+ binary.LittleEndian.PutUint32(bts[0:4], menu.CommandID)
+ return xwindows.InputRecord{
+ EventType: xwindows.MENU_EVENT,
+ Event: bts,
+ }
+}
+
+func encodeWindowBufferSizeEvent(size xwindows.WindowBufferSizeRecord) xwindows.InputRecord {
+ var bts [16]byte
+ binary.LittleEndian.PutUint16(bts[0:2], uint16(size.Size.X))
+ binary.LittleEndian.PutUint16(bts[2:4], uint16(size.Size.Y))
+ return xwindows.InputRecord{
+ EventType: xwindows.WINDOW_BUFFER_SIZE_EVENT,
+ Event: bts,
+ }
+}
+
+func encodeFocusEvent(focus xwindows.FocusEventRecord) xwindows.InputRecord {
+ var bts [16]byte
+ if focus.SetFocus {
+ bts[0] = 1
+ }
+ return xwindows.InputRecord{
+ EventType: xwindows.FOCUS_EVENT,
+ Event: bts,
+ }
+}
+
+func encodeMouseEvent(mouse xwindows.MouseEventRecord) xwindows.InputRecord {
+ var bts [16]byte
+ binary.LittleEndian.PutUint16(bts[0:2], uint16(mouse.MousePositon.X))
+ binary.LittleEndian.PutUint16(bts[2:4], uint16(mouse.MousePositon.Y))
+ binary.LittleEndian.PutUint32(bts[4:8], mouse.ButtonState)
+ binary.LittleEndian.PutUint32(bts[8:12], mouse.ControlKeyState)
+ binary.LittleEndian.PutUint32(bts[12:16], mouse.EventFlags)
+ return xwindows.InputRecord{
+ EventType: xwindows.MOUSE_EVENT,
+ Event: bts,
+ }
+}
+
+func encodeKeyEvent(key xwindows.KeyEventRecord) xwindows.InputRecord {
+ var bts [16]byte
+ binary.LittleEndian.PutUint32(bts[0:4], boolToUint32(key.KeyDown))
+ binary.LittleEndian.PutUint16(bts[4:6], key.RepeatCount)
+ binary.LittleEndian.PutUint16(bts[6:8], key.VirtualKeyCode)
+ binary.LittleEndian.PutUint16(bts[8:10], key.VirtualScanCode)
+ binary.LittleEndian.PutUint16(bts[10:12], uint16(key.Char))
+ binary.LittleEndian.PutUint32(bts[12:16], key.ControlKeyState)
+ return xwindows.InputRecord{
+ EventType: xwindows.KEY_EVENT,
+ Event: bts,
+ }
+}
+
+// encodeSequence encodes a string of ANSI escape sequences into a slice of
+// Windows input key records.
+func encodeSequence(s string) (evs []xwindows.InputRecord) {
+ var state byte
+ for len(s) > 0 {
+ seq, _, n, newState := ansi.DecodeSequence(s, state, nil)
+ for i := 0; i < n; i++ {
+ evs = append(evs, encodeKeyEvent(xwindows.KeyEventRecord{
+ KeyDown: true,
+ Char: rune(seq[i]),
+ }))
+ }
+ state = newState
+ s = s[n:]
+ }
+ return
+}
+
+func encodeUtf16Rune(r rune) []xwindows.InputRecord {
+ r1, r2 := utf16.EncodeRune(r)
+ return encodeUtf16Pair(r1, r2)
+}
+
+func encodeUtf16Pair(r1, r2 rune) []xwindows.InputRecord {
+ return []xwindows.InputRecord{
+ encodeKeyEvent(xwindows.KeyEventRecord{
+ KeyDown: true,
+ Char: r1,
+ }),
+ encodeKeyEvent(xwindows.KeyEventRecord{
+ KeyDown: true,
+ Char: r2,
+ }),
+ }
+}
diff --git a/packages/tui/input/focus.go b/packages/tui/input/focus.go
new file mode 100644
index 000000000..796d95f64
--- /dev/null
+++ b/packages/tui/input/focus.go
@@ -0,0 +1,9 @@
+package input
+
+// FocusEvent represents a terminal focus event.
+// This occurs when the terminal gains focus.
+type FocusEvent struct{}
+
+// BlurEvent represents a terminal blur event.
+// This occurs when the terminal loses focus.
+type BlurEvent struct{}
diff --git a/packages/tui/input/focus_test.go b/packages/tui/input/focus_test.go
new file mode 100644
index 000000000..2d35e4768
--- /dev/null
+++ b/packages/tui/input/focus_test.go
@@ -0,0 +1,27 @@
+package input
+
+import (
+ "testing"
+)
+
+func TestFocus(t *testing.T) {
+ var p Parser
+ _, e := p.parseSequence([]byte("\x1b[I"))
+ switch e.(type) {
+ case FocusEvent:
+ // ok
+ default:
+ t.Error("invalid sequence")
+ }
+}
+
+func TestBlur(t *testing.T) {
+ var p Parser
+ _, e := p.parseSequence([]byte("\x1b[O"))
+ switch e.(type) {
+ case BlurEvent:
+ // ok
+ default:
+ t.Error("invalid sequence")
+ }
+}
diff --git a/packages/tui/input/go.mod b/packages/tui/input/go.mod
new file mode 100644
index 000000000..36a9a92ab
--- /dev/null
+++ b/packages/tui/input/go.mod
@@ -0,0 +1,18 @@
+module github.com/charmbracelet/x/input
+
+go 1.23.0
+
+require (
+ github.com/charmbracelet/x/ansi v0.9.3
+ github.com/charmbracelet/x/windows v0.2.1
+ github.com/muesli/cancelreader v0.2.2
+ github.com/rivo/uniseg v0.4.7
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e
+ golang.org/x/sys v0.33.0
+)
+
+require (
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
+ github.com/mattn/go-runewidth v0.0.16 // indirect
+ golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
+)
diff --git a/packages/tui/input/go.sum b/packages/tui/input/go.sum
new file mode 100644
index 000000000..7bc7a2ebd
--- /dev/null
+++ b/packages/tui/input/go.sum
@@ -0,0 +1,19 @@
+github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
+github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
+github.com/charmbracelet/x/windows v0.2.1 h1:3x7vnbpQrjpuq/4L+I4gNsG5htYoCiA5oe9hLjAij5I=
+github.com/charmbracelet/x/windows v0.2.1/go.mod h1:ptZp16h40gDYqs5TSawSVW+yiLB13j4kSMA0lSCHL0M=
+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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
+github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+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-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
+golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
+golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
+golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
diff --git a/packages/tui/input/input.go b/packages/tui/input/input.go
new file mode 100644
index 000000000..da5e4f0be
--- /dev/null
+++ b/packages/tui/input/input.go
@@ -0,0 +1,45 @@
+package input
+
+import (
+ "fmt"
+ "strings"
+)
+
+// Event represents a terminal event.
+type Event any
+
+// UnknownEvent represents an unknown event.
+type UnknownEvent string
+
+// String returns a string representation of the unknown event.
+func (e UnknownEvent) String() string {
+ return fmt.Sprintf("%q", string(e))
+}
+
+// MultiEvent represents multiple messages event.
+type MultiEvent []Event
+
+// String returns a string representation of the multiple messages event.
+func (e MultiEvent) String() string {
+ var sb strings.Builder
+ for _, ev := range e {
+ sb.WriteString(fmt.Sprintf("%v\n", ev))
+ }
+ return sb.String()
+}
+
+// WindowSizeEvent is used to report the terminal size. Note that Windows does
+// not have support for reporting resizes via SIGWINCH signals and relies on
+// the Windows Console API to report window size changes.
+type WindowSizeEvent struct {
+ Width int
+ Height int
+}
+
+// WindowOpEvent is a window operation (XTWINOPS) report event. This is used to
+// report various window operations such as reporting the window size or cell
+// size.
+type WindowOpEvent struct {
+ Op int
+ Args []int
+}
diff --git a/packages/tui/input/key.go b/packages/tui/input/key.go
new file mode 100644
index 000000000..8d3e3ebe3
--- /dev/null
+++ b/packages/tui/input/key.go
@@ -0,0 +1,574 @@
+package input
+
+import (
+ "fmt"
+ "strings"
+ "unicode"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+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
+
+ //nolint:godox
+ // 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
+
+ //nolint:godox
+ // 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)
+)
+
+// KeyPressEvent represents a key press event.
+type KeyPressEvent Key
+
+// String implements [fmt.Stringer] and is quite useful for matching key
+// events. For details, on what this returns see [Key.String].
+func (k KeyPressEvent) String() string {
+ return Key(k).String()
+}
+
+// Keystroke returns the keystroke representation of the [Key]. While less type
+// safe than looking at the individual fields, it will usually be more
+// convenient and readable to use this method when matching against keys.
+//
+// Note that modifier keys are always printed in the following order:
+// - ctrl
+// - alt
+// - shift
+// - meta
+// - hyper
+// - super
+//
+// For example, you'll always see "ctrl+shift+alt+a" and never
+// "shift+ctrl+alt+a".
+func (k KeyPressEvent) Keystroke() string {
+ return Key(k).Keystroke()
+}
+
+// Key returns the underlying key event. This is a syntactic sugar for casting
+// the key event to a [Key].
+func (k KeyPressEvent) Key() Key {
+ return Key(k)
+}
+
+// KeyReleaseEvent represents a key release event.
+type KeyReleaseEvent Key
+
+// String implements [fmt.Stringer] and is quite useful for matching key
+// events. For details, on what this returns see [Key.String].
+func (k KeyReleaseEvent) String() string {
+ return Key(k).String()
+}
+
+// Keystroke returns the keystroke representation of the [Key]. While less type
+// safe than looking at the individual fields, it will usually be more
+// convenient and readable to use this method when matching against keys.
+//
+// Note that modifier keys are always printed in the following order:
+// - ctrl
+// - alt
+// - shift
+// - meta
+// - hyper
+// - super
+//
+// For example, you'll always see "ctrl+shift+alt+a" and never
+// "shift+ctrl+alt+a".
+func (k KeyReleaseEvent) Keystroke() string {
+ return Key(k).Keystroke()
+}
+
+// Key returns the underlying key event. This is a convenience method and
+// syntactic sugar to satisfy the [KeyEvent] interface, and cast the key event to
+// [Key].
+func (k KeyReleaseEvent) Key() Key {
+ return Key(k)
+}
+
+// KeyEvent represents a key event. This can be either a key press or a key
+// release event.
+type KeyEvent interface {
+ fmt.Stringer
+
+ // Key returns the underlying key event.
+ Key() Key
+}
+
+// Key represents a Key press or release event. It contains information about
+// the Key pressed, like the runes, the type of Key, and the modifiers pressed.
+// There are a couple general patterns you could use to check for key presses
+// or releases:
+//
+// // Switch on the string representation of the key (shorter)
+// switch ev := ev.(type) {
+// case KeyPressEvent:
+// switch ev.String() {
+// case "enter":
+// fmt.Println("you pressed enter!")
+// case "a":
+// fmt.Println("you pressed a!")
+// }
+// }
+//
+// // Switch on the key type (more foolproof)
+// switch ev := ev.(type) {
+// case KeyEvent:
+// // catch both KeyPressEvent and KeyReleaseEvent
+// switch key := ev.Key(); key.Code {
+// case KeyEnter:
+// fmt.Println("you pressed enter!")
+// default:
+// switch key.Text {
+// case "a":
+// fmt.Println("you pressed a!")
+// }
+// }
+// }
+//
+// Note that [Key.Text] will be empty for special keys like [KeyEnter],
+// [KeyTab], and for keys that don't represent printable characters like key
+// combos with modifier keys. In other words, [Key.Text] is populated only for
+// keys that represent printable characters shifted or unshifted (like 'a',
+// 'A', '1', '!', etc.).
+type Key struct {
+ // Text contains the actual characters received. This usually the same as
+ // [Key.Code]. When [Key.Text] is non-empty, it indicates that the key
+ // pressed represents printable character(s).
+ Text string
+
+ // Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on.
+ Mod KeyMod
+
+ // Code represents the key pressed. This is usually a special key like
+ // [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'.
+ Code rune
+
+ // ShiftedCode is the actual, shifted key pressed by the user. For example,
+ // if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will
+ // be 'A' and [Key.Code] will be 'a'.
+ //
+ // In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the
+ // unshifted key on the keyboard.
+ //
+ // This is only available with the Kitty Keyboard Protocol or the Windows
+ // Console API.
+ ShiftedCode rune
+
+ // BaseCode is the key pressed according to the standard PC-101 key layout.
+ // On international keyboards, this is the key that would be pressed if the
+ // keyboard was set to US PC-101 layout.
+ //
+ // For example, if the user presses 'q' on a French AZERTY keyboard,
+ // [Key.BaseCode] will be 'q'.
+ //
+ // This is only available with the Kitty Keyboard Protocol or the Windows
+ // Console API.
+ BaseCode rune
+
+ // IsRepeat indicates whether the key is being held down and sending events
+ // repeatedly.
+ //
+ // This is only available with the Kitty Keyboard Protocol or the Windows
+ // Console API.
+ IsRepeat bool
+}
+
+// String implements [fmt.Stringer] and is quite useful for matching key
+// events. It will return the textual representation of the [Key] if there is
+// one, otherwise, it will fallback to [Key.Keystroke].
+//
+// For example, you'll always get "?" and instead of "shift+/" on a US ANSI
+// keyboard.
+func (k Key) String() string {
+ if len(k.Text) > 0 && k.Text != " " {
+ return k.Text
+ }
+ return k.Keystroke()
+}
+
+// Keystroke returns the keystroke representation of the [Key]. While less type
+// safe than looking at the individual fields, it will usually be more
+// convenient and readable to use this method when matching against keys.
+//
+// Note that modifier keys are always printed in the following order:
+// - ctrl
+// - alt
+// - shift
+// - meta
+// - hyper
+// - super
+//
+// For example, you'll always see "ctrl+shift+alt+a" and never
+// "shift+ctrl+alt+a".
+func (k Key) Keystroke() string {
+ var sb strings.Builder
+ if k.Mod.Contains(ModCtrl) && k.Code != KeyLeftCtrl && k.Code != KeyRightCtrl {
+ sb.WriteString("ctrl+")
+ }
+ if k.Mod.Contains(ModAlt) && k.Code != KeyLeftAlt && k.Code != KeyRightAlt {
+ sb.WriteString("alt+")
+ }
+ if k.Mod.Contains(ModShift) && k.Code != KeyLeftShift && k.Code != KeyRightShift {
+ sb.WriteString("shift+")
+ }
+ if k.Mod.Contains(ModMeta) && k.Code != KeyLeftMeta && k.Code != KeyRightMeta {
+ sb.WriteString("meta+")
+ }
+ if k.Mod.Contains(ModHyper) && k.Code != KeyLeftHyper && k.Code != KeyRightHyper {
+ sb.WriteString("hyper+")
+ }
+ if k.Mod.Contains(ModSuper) && k.Code != KeyLeftSuper && k.Code != KeyRightSuper {
+ sb.WriteString("super+")
+ }
+
+ if kt, ok := keyTypeString[k.Code]; ok {
+ sb.WriteString(kt)
+ } else {
+ code := k.Code
+ if k.BaseCode != 0 {
+ // If a [Key.BaseCode] is present, use it to represent a key using the standard
+ // PC-101 key layout.
+ code = k.BaseCode
+ }
+
+ switch code {
+ case KeySpace:
+ // Space is the only invisible printable character.
+ sb.WriteString("space")
+ case KeyExtended:
+ // Write the actual text of the key when the key contains multiple
+ // runes.
+ sb.WriteString(k.Text)
+ default:
+ sb.WriteRune(code)
+ }
+ }
+
+ return sb.String()
+}
+
+var keyTypeString = map[rune]string{
+ KeyEnter: "enter",
+ KeyTab: "tab",
+ KeyBackspace: "backspace",
+ KeyEscape: "esc",
+ KeySpace: "space",
+ KeyUp: "up",
+ KeyDown: "down",
+ KeyLeft: "left",
+ KeyRight: "right",
+ KeyBegin: "begin",
+ KeyFind: "find",
+ KeyInsert: "insert",
+ KeyDelete: "delete",
+ KeySelect: "select",
+ KeyPgUp: "pgup",
+ KeyPgDown: "pgdown",
+ KeyHome: "home",
+ KeyEnd: "end",
+ KeyKpEnter: "kpenter",
+ KeyKpEqual: "kpequal",
+ KeyKpMultiply: "kpmul",
+ KeyKpPlus: "kpplus",
+ KeyKpComma: "kpcomma",
+ KeyKpMinus: "kpminus",
+ KeyKpDecimal: "kpperiod",
+ KeyKpDivide: "kpdiv",
+ KeyKp0: "kp0",
+ KeyKp1: "kp1",
+ KeyKp2: "kp2",
+ KeyKp3: "kp3",
+ KeyKp4: "kp4",
+ KeyKp5: "kp5",
+ KeyKp6: "kp6",
+ KeyKp7: "kp7",
+ KeyKp8: "kp8",
+ KeyKp9: "kp9",
+
+ // Kitty keyboard extension
+ KeyKpSep: "kpsep",
+ KeyKpUp: "kpup",
+ KeyKpDown: "kpdown",
+ KeyKpLeft: "kpleft",
+ KeyKpRight: "kpright",
+ KeyKpPgUp: "kppgup",
+ KeyKpPgDown: "kppgdown",
+ KeyKpHome: "kphome",
+ KeyKpEnd: "kpend",
+ KeyKpInsert: "kpinsert",
+ KeyKpDelete: "kpdelete",
+ KeyKpBegin: "kpbegin",
+
+ KeyF1: "f1",
+ KeyF2: "f2",
+ KeyF3: "f3",
+ KeyF4: "f4",
+ KeyF5: "f5",
+ KeyF6: "f6",
+ KeyF7: "f7",
+ KeyF8: "f8",
+ KeyF9: "f9",
+ KeyF10: "f10",
+ KeyF11: "f11",
+ KeyF12: "f12",
+ KeyF13: "f13",
+ KeyF14: "f14",
+ KeyF15: "f15",
+ KeyF16: "f16",
+ KeyF17: "f17",
+ KeyF18: "f18",
+ KeyF19: "f19",
+ KeyF20: "f20",
+ KeyF21: "f21",
+ KeyF22: "f22",
+ KeyF23: "f23",
+ KeyF24: "f24",
+ KeyF25: "f25",
+ KeyF26: "f26",
+ KeyF27: "f27",
+ KeyF28: "f28",
+ KeyF29: "f29",
+ KeyF30: "f30",
+ KeyF31: "f31",
+ KeyF32: "f32",
+ KeyF33: "f33",
+ KeyF34: "f34",
+ KeyF35: "f35",
+ KeyF36: "f36",
+ KeyF37: "f37",
+ KeyF38: "f38",
+ KeyF39: "f39",
+ KeyF40: "f40",
+ KeyF41: "f41",
+ KeyF42: "f42",
+ KeyF43: "f43",
+ KeyF44: "f44",
+ KeyF45: "f45",
+ KeyF46: "f46",
+ KeyF47: "f47",
+ KeyF48: "f48",
+ KeyF49: "f49",
+ KeyF50: "f50",
+ KeyF51: "f51",
+ KeyF52: "f52",
+ KeyF53: "f53",
+ KeyF54: "f54",
+ KeyF55: "f55",
+ KeyF56: "f56",
+ KeyF57: "f57",
+ KeyF58: "f58",
+ KeyF59: "f59",
+ KeyF60: "f60",
+ KeyF61: "f61",
+ KeyF62: "f62",
+ KeyF63: "f63",
+
+ // Kitty keyboard extension
+ KeyCapsLock: "capslock",
+ KeyScrollLock: "scrolllock",
+ KeyNumLock: "numlock",
+ KeyPrintScreen: "printscreen",
+ KeyPause: "pause",
+ KeyMenu: "menu",
+ KeyMediaPlay: "mediaplay",
+ KeyMediaPause: "mediapause",
+ KeyMediaPlayPause: "mediaplaypause",
+ KeyMediaReverse: "mediareverse",
+ KeyMediaStop: "mediastop",
+ KeyMediaFastForward: "mediafastforward",
+ KeyMediaRewind: "mediarewind",
+ KeyMediaNext: "medianext",
+ KeyMediaPrev: "mediaprev",
+ KeyMediaRecord: "mediarecord",
+ KeyLowerVol: "lowervol",
+ KeyRaiseVol: "raisevol",
+ KeyMute: "mute",
+ KeyLeftShift: "leftshift",
+ KeyLeftAlt: "leftalt",
+ KeyLeftCtrl: "leftctrl",
+ KeyLeftSuper: "leftsuper",
+ KeyLeftHyper: "lefthyper",
+ KeyLeftMeta: "leftmeta",
+ KeyRightShift: "rightshift",
+ KeyRightAlt: "rightalt",
+ KeyRightCtrl: "rightctrl",
+ KeyRightSuper: "rightsuper",
+ KeyRightHyper: "righthyper",
+ KeyRightMeta: "rightmeta",
+ KeyIsoLevel3Shift: "isolevel3shift",
+ KeyIsoLevel5Shift: "isolevel5shift",
+}
diff --git a/packages/tui/input/key_test.go b/packages/tui/input/key_test.go
new file mode 100644
index 000000000..9bf4d9a51
--- /dev/null
+++ b/packages/tui/input/key_test.go
@@ -0,0 +1,880 @@
+package input
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "flag"
+ "fmt"
+ "image/color"
+ "io"
+ "math/rand"
+ "reflect"
+ "regexp"
+ "runtime"
+ "sort"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/ansi/kitty"
+)
+
+var sequences = buildKeysTable(FlagTerminfo, "dumb")
+
+func TestKeyString(t *testing.T) {
+ t.Run("alt+space", func(t *testing.T) {
+ k := KeyPressEvent{Code: KeySpace, Mod: ModAlt}
+ if got := k.String(); got != "alt+space" {
+ t.Fatalf(`expected a "alt+space", got %q`, got)
+ }
+ })
+
+ t.Run("runes", func(t *testing.T) {
+ k := KeyPressEvent{Code: 'a', Text: "a"}
+ if got := k.String(); got != "a" {
+ t.Fatalf(`expected an "a", got %q`, got)
+ }
+ })
+
+ t.Run("invalid", func(t *testing.T) {
+ k := KeyPressEvent{Code: 99999}
+ if got := k.String(); got != "𘚟" {
+ t.Fatalf(`expected a "unknown", got %q`, got)
+ }
+ })
+
+ t.Run("space", func(t *testing.T) {
+ k := KeyPressEvent{Code: KeySpace, Text: " "}
+ if got := k.String(); got != "space" {
+ t.Fatalf(`expected a "space", got %q`, got)
+ }
+ })
+
+ t.Run("shift+space", func(t *testing.T) {
+ k := KeyPressEvent{Code: KeySpace, Mod: ModShift}
+ if got := k.String(); got != "shift+space" {
+ t.Fatalf(`expected a "shift+space", got %q`, got)
+ }
+ })
+
+ t.Run("?", func(t *testing.T) {
+ k := KeyPressEvent{Code: '/', Mod: ModShift, Text: "?"}
+ if got := k.String(); got != "?" {
+ t.Fatalf(`expected a "?", got %q`, got)
+ }
+ })
+}
+
+type seqTest struct {
+ seq []byte
+ Events []Event
+}
+
+var f3CurPosRegexp = regexp.MustCompile(`\x1b\[1;(\d+)R`)
+
+// buildBaseSeqTests returns sequence tests that are valid for the
+// detectSequence() function.
+func buildBaseSeqTests() []seqTest {
+ td := []seqTest{}
+ for seq, key := range sequences {
+ k := KeyPressEvent(key)
+ st := seqTest{seq: []byte(seq), Events: []Event{k}}
+
+ // XXX: This is a special case to handle F3 key sequence and cursor
+ // position report having the same sequence. See [parseCsi] for more
+ // information.
+ if f3CurPosRegexp.MatchString(seq) {
+ st.Events = []Event{k, CursorPositionEvent{Y: 0, X: int(key.Mod)}}
+ }
+ td = append(td, st)
+ }
+
+ // Additional special cases.
+ td = append(td,
+ // Unrecognized CSI sequence.
+ seqTest{
+ []byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
+ []Event{
+ UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}),
+ },
+ },
+ // A lone space character.
+ seqTest{
+ []byte{' '},
+ []Event{
+ KeyPressEvent{Code: KeySpace, Text: " "},
+ },
+ },
+ // An escape character with the alt modifier.
+ seqTest{
+ []byte{'\x1b', ' '},
+ []Event{
+ KeyPressEvent{Code: KeySpace, Mod: ModAlt},
+ },
+ },
+ )
+ return td
+}
+
+func TestParseSequence(t *testing.T) {
+ td := buildBaseSeqTests()
+ td = append(td,
+ // Background color.
+ seqTest{
+ []byte("\x1b]11;rgb:1234/1234/1234\x07"),
+ []Event{BackgroundColorEvent{
+ Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
+ }},
+ },
+ seqTest{
+ []byte("\x1b]11;rgb:1234/1234/1234\x1b\\"),
+ []Event{BackgroundColorEvent{
+ Color: color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff},
+ }},
+ },
+ seqTest{
+ []byte("\x1b]11;rgb:1234/1234/1234\x1b"), // Incomplete sequences are ignored.
+ []Event{
+ UnknownEvent("\x1b]11;rgb:1234/1234/1234\x1b"),
+ },
+ },
+
+ // Kitty Graphics response.
+ seqTest{
+ []byte("\x1b_Ga=t;OK\x1b\\"),
+ []Event{KittyGraphicsEvent{
+ Options: kitty.Options{Action: kitty.Transmit},
+ Payload: []byte("OK"),
+ }},
+ },
+ seqTest{
+ []byte("\x1b_Gi=99,I=13;OK\x1b\\"),
+ []Event{KittyGraphicsEvent{
+ Options: kitty.Options{ID: 99, Number: 13},
+ Payload: []byte("OK"),
+ }},
+ },
+ seqTest{
+ []byte("\x1b_Gi=1337,q=1;EINVAL:your face\x1b\\"),
+ []Event{KittyGraphicsEvent{
+ Options: kitty.Options{ID: 1337, Quite: 1},
+ Payload: []byte("EINVAL:your face"),
+ }},
+ },
+
+ // Xterm modifyOtherKeys CSI 27 ; <modifier> ; <code> ~
+ seqTest{
+ []byte("\x1b[27;3;20320~"),
+ []Event{KeyPressEvent{Code: '你', Mod: ModAlt}},
+ },
+ seqTest{
+ []byte("\x1b[27;3;65~"),
+ []Event{KeyPressEvent{Code: 'A', Mod: ModAlt}},
+ },
+ seqTest{
+ []byte("\x1b[27;3;8~"),
+ []Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
+ },
+ seqTest{
+ []byte("\x1b[27;3;27~"),
+ []Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
+ },
+ seqTest{
+ []byte("\x1b[27;3;127~"),
+ []Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
+ },
+
+ // Xterm report window text area size.
+ seqTest{
+ []byte("\x1b[4;24;80t"),
+ []Event{
+ WindowOpEvent{Op: 4, Args: []int{24, 80}},
+ },
+ },
+
+ // Kitty keyboard / CSI u (fixterms)
+ seqTest{
+ []byte("\x1b[1B"),
+ []Event{KeyPressEvent{Code: KeyDown}},
+ },
+ seqTest{
+ []byte("\x1b[1;B"),
+ []Event{KeyPressEvent{Code: KeyDown}},
+ },
+ seqTest{
+ []byte("\x1b[1;4B"),
+ []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
+ },
+ seqTest{
+ []byte("\x1b[1;4:1B"),
+ []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
+ },
+ seqTest{
+ []byte("\x1b[1;4:2B"),
+ []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyDown, IsRepeat: true}},
+ },
+ seqTest{
+ []byte("\x1b[1;4:3B"),
+ []Event{KeyReleaseEvent{Mod: ModShift | ModAlt, Code: KeyDown}},
+ },
+ seqTest{
+ []byte("\x1b[8~"),
+ []Event{KeyPressEvent{Code: KeyEnd}},
+ },
+ seqTest{
+ []byte("\x1b[8;~"),
+ []Event{KeyPressEvent{Code: KeyEnd}},
+ },
+ seqTest{
+ []byte("\x1b[8;10~"),
+ []Event{KeyPressEvent{Mod: ModShift | ModMeta, Code: KeyEnd}},
+ },
+ seqTest{
+ []byte("\x1b[27;4u"),
+ []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyEscape}},
+ },
+ seqTest{
+ []byte("\x1b[127;4u"),
+ []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyBackspace}},
+ },
+ seqTest{
+ []byte("\x1b[57358;4u"),
+ []Event{KeyPressEvent{Mod: ModShift | ModAlt, Code: KeyCapsLock}},
+ },
+ seqTest{
+ []byte("\x1b[9;2u"),
+ []Event{KeyPressEvent{Mod: ModShift, Code: KeyTab}},
+ },
+ seqTest{
+ []byte("\x1b[195;u"),
+ []Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
+ },
+ seqTest{
+ []byte("\x1b[20320;2u"),
+ []Event{KeyPressEvent{Text: "你", Mod: ModShift, Code: '你'}},
+ },
+ seqTest{
+ []byte("\x1b[195;:1u"),
+ []Event{KeyPressEvent{Text: "Ã", Code: 'Ã'}},
+ },
+ seqTest{
+ []byte("\x1b[195;2:3u"),
+ []Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
+ },
+ seqTest{
+ []byte("\x1b[195;2:2u"),
+ []Event{KeyPressEvent{Code: 'Ã', Text: "Ã", IsRepeat: true, Mod: ModShift}},
+ },
+ seqTest{
+ []byte("\x1b[195;2:1u"),
+ []Event{KeyPressEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
+ },
+ seqTest{
+ []byte("\x1b[195;2:3u"),
+ []Event{KeyReleaseEvent{Code: 'Ã', Text: "Ã", Mod: ModShift}},
+ },
+ seqTest{
+ []byte("\x1b[97;2;65u"),
+ []Event{KeyPressEvent{Code: 'a', Text: "A", Mod: ModShift}},
+ },
+ seqTest{
+ []byte("\x1b[97;;229u"),
+ []Event{KeyPressEvent{Code: 'a', Text: "å"}},
+ },
+
+ // focus/blur
+ seqTest{
+ []byte{'\x1b', '[', 'I'},
+ []Event{
+ FocusEvent{},
+ },
+ },
+ seqTest{
+ []byte{'\x1b', '[', 'O'},
+ []Event{
+ BlurEvent{},
+ },
+ },
+ // Mouse event.
+ seqTest{
+ []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
+ []Event{
+ MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
+ },
+ },
+ // SGR Mouse event.
+ seqTest{
+ []byte("\x1b[<0;33;17M"),
+ []Event{
+ MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
+ },
+ },
+ // Runes.
+ seqTest{
+ []byte{'a'},
+ []Event{
+ KeyPressEvent{Code: 'a', Text: "a"},
+ },
+ },
+ seqTest{
+ []byte{'\x1b', 'a'},
+ []Event{
+ KeyPressEvent{Code: 'a', Mod: ModAlt},
+ },
+ },
+ seqTest{
+ []byte{'a', 'a', 'a'},
+ []Event{
+ KeyPressEvent{Code: 'a', Text: "a"},
+ KeyPressEvent{Code: 'a', Text: "a"},
+ KeyPressEvent{Code: 'a', Text: "a"},
+ },
+ },
+ // Multi-byte rune.
+ seqTest{
+ []byte("☃"),
+ []Event{
+ KeyPressEvent{Code: '☃', Text: "☃"},
+ },
+ },
+ seqTest{
+ []byte("\x1b☃"),
+ []Event{
+ KeyPressEvent{Code: '☃', Mod: ModAlt},
+ },
+ },
+ // Standalone control characters.
+ seqTest{
+ []byte{'\x1b'},
+ []Event{
+ KeyPressEvent{Code: KeyEscape},
+ },
+ },
+ seqTest{
+ []byte{ansi.SOH},
+ []Event{
+ KeyPressEvent{Code: 'a', Mod: ModCtrl},
+ },
+ },
+ seqTest{
+ []byte{'\x1b', ansi.SOH},
+ []Event{
+ KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
+ },
+ },
+ seqTest{
+ []byte{ansi.NUL},
+ []Event{
+ KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
+ },
+ },
+ seqTest{
+ []byte{'\x1b', ansi.NUL},
+ []Event{
+ KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt},
+ },
+ },
+ // C1 control characters.
+ seqTest{
+ []byte{'\x80'},
+ []Event{
+ KeyPressEvent{Code: rune(0x80 - '@'), Mod: ModCtrl | ModAlt},
+ },
+ },
+ )
+
+ if runtime.GOOS != "windows" {
+ // Sadly, utf8.DecodeRune([]byte(0xfe)) returns a valid rune on windows.
+ // This is incorrect, but it makes our test fail if we try it out.
+ td = append(td, seqTest{
+ []byte{'\xfe'},
+ []Event{
+ UnknownEvent(rune(0xfe)),
+ },
+ })
+ }
+
+ var p Parser
+ for _, tc := range td {
+ t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) {
+ var events []Event
+ buf := tc.seq
+ for len(buf) > 0 {
+ width, Event := p.parseSequence(buf)
+ switch Event := Event.(type) {
+ case MultiEvent:
+ events = append(events, Event...)
+ default:
+ events = append(events, Event)
+ }
+ buf = buf[width:]
+ }
+ if !reflect.DeepEqual(tc.Events, events) {
+ t.Errorf("\nexpected event for %q:\n %#v\ngot:\n %#v", tc.seq, tc.Events, events)
+ }
+ })
+ }
+}
+
+func TestReadLongInput(t *testing.T) {
+ expect := make([]Event, 1000)
+ for i := range 1000 {
+ expect[i] = KeyPressEvent{Code: 'a', Text: "a"}
+ }
+ input := strings.Repeat("a", 1000)
+ drv, err := NewReader(strings.NewReader(input), "dumb", 0)
+ if err != nil {
+ t.Fatalf("unexpected input driver error: %v", err)
+ }
+
+ var Events []Event
+ for {
+ events, err := drv.ReadEvents()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatalf("unexpected input error: %v", err)
+ }
+ Events = append(Events, events...)
+ }
+
+ if !reflect.DeepEqual(expect, Events) {
+ t.Errorf("unexpected messages, expected:\n %+v\ngot:\n %+v", expect, Events)
+ }
+}
+
+func TestReadInput(t *testing.T) {
+ type test struct {
+ keyname string
+ in []byte
+ out []Event
+ }
+ testData := []test{
+ {
+ "a",
+ []byte{'a'},
+ []Event{
+ KeyPressEvent{Code: 'a', Text: "a"},
+ },
+ },
+ {
+ "space",
+ []byte{' '},
+ []Event{
+ KeyPressEvent{Code: KeySpace, Text: " "},
+ },
+ },
+ {
+ "a alt+a",
+ []byte{'a', '\x1b', 'a'},
+ []Event{
+ KeyPressEvent{Code: 'a', Text: "a"},
+ KeyPressEvent{Code: 'a', Mod: ModAlt},
+ },
+ },
+ {
+ "a alt+a a",
+ []byte{'a', '\x1b', 'a', 'a'},
+ []Event{
+ KeyPressEvent{Code: 'a', Text: "a"},
+ KeyPressEvent{Code: 'a', Mod: ModAlt},
+ KeyPressEvent{Code: 'a', Text: "a"},
+ },
+ },
+ {
+ "ctrl+a",
+ []byte{byte(ansi.SOH)},
+ []Event{
+ KeyPressEvent{Code: 'a', Mod: ModCtrl},
+ },
+ },
+ {
+ "ctrl+a ctrl+b",
+ []byte{byte(ansi.SOH), byte(ansi.STX)},
+ []Event{
+ KeyPressEvent{Code: 'a', Mod: ModCtrl},
+ KeyPressEvent{Code: 'b', Mod: ModCtrl},
+ },
+ },
+ {
+ "alt+a",
+ []byte{byte(0x1b), 'a'},
+ []Event{
+ KeyPressEvent{Code: 'a', Mod: ModAlt},
+ },
+ },
+ {
+ "a b c d",
+ []byte{'a', 'b', 'c', 'd'},
+ []Event{
+ KeyPressEvent{Code: 'a', Text: "a"},
+ KeyPressEvent{Code: 'b', Text: "b"},
+ KeyPressEvent{Code: 'c', Text: "c"},
+ KeyPressEvent{Code: 'd', Text: "d"},
+ },
+ },
+ {
+ "up",
+ []byte("\x1b[A"),
+ []Event{
+ KeyPressEvent{Code: KeyUp},
+ },
+ },
+ {
+ "wheel up",
+ []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)},
+ []Event{
+ MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
+ },
+ },
+ {
+ "left motion release",
+ []byte{
+ '\x1b', '[', 'M', byte(32) + 0b0010_0000, byte(32 + 33), byte(16 + 33),
+ '\x1b', '[', 'M', byte(32) + 0b0000_0011, byte(64 + 33), byte(32 + 33),
+ },
+ []Event{
+ MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
+ MouseReleaseEvent{X: 64, Y: 32, Button: MouseNone},
+ },
+ },
+ {
+ "shift+tab",
+ []byte{'\x1b', '[', 'Z'},
+ []Event{
+ KeyPressEvent{Code: KeyTab, Mod: ModShift},
+ },
+ },
+ {
+ "enter",
+ []byte{'\r'},
+ []Event{KeyPressEvent{Code: KeyEnter}},
+ },
+ {
+ "alt+enter",
+ []byte{'\x1b', '\r'},
+ []Event{
+ KeyPressEvent{Code: KeyEnter, Mod: ModAlt},
+ },
+ },
+ {
+ "insert",
+ []byte{'\x1b', '[', '2', '~'},
+ []Event{
+ KeyPressEvent{Code: KeyInsert},
+ },
+ },
+ {
+ "ctrl+alt+a",
+ []byte{'\x1b', byte(ansi.SOH)},
+ []Event{
+ KeyPressEvent{Code: 'a', Mod: ModCtrl | ModAlt},
+ },
+ },
+ {
+ "CSI?----X?",
+ []byte{'\x1b', '[', '-', '-', '-', '-', 'X'},
+ []Event{UnknownEvent([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})},
+ },
+ // Powershell sequences.
+ {
+ "up",
+ []byte{'\x1b', 'O', 'A'},
+ []Event{KeyPressEvent{Code: KeyUp}},
+ },
+ {
+ "down",
+ []byte{'\x1b', 'O', 'B'},
+ []Event{KeyPressEvent{Code: KeyDown}},
+ },
+ {
+ "right",
+ []byte{'\x1b', 'O', 'C'},
+ []Event{KeyPressEvent{Code: KeyRight}},
+ },
+ {
+ "left",
+ []byte{'\x1b', 'O', 'D'},
+ []Event{KeyPressEvent{Code: KeyLeft}},
+ },
+ {
+ "alt+enter",
+ []byte{'\x1b', '\x0d'},
+ []Event{KeyPressEvent{Code: KeyEnter, Mod: ModAlt}},
+ },
+ {
+ "alt+backspace",
+ []byte{'\x1b', '\x7f'},
+ []Event{KeyPressEvent{Code: KeyBackspace, Mod: ModAlt}},
+ },
+ {
+ "ctrl+space",
+ []byte{'\x00'},
+ []Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl}},
+ },
+ {
+ "ctrl+alt+space",
+ []byte{'\x1b', '\x00'},
+ []Event{KeyPressEvent{Code: KeySpace, Mod: ModCtrl | ModAlt}},
+ },
+ {
+ "esc",
+ []byte{'\x1b'},
+ []Event{KeyPressEvent{Code: KeyEscape}},
+ },
+ {
+ "alt+esc",
+ []byte{'\x1b', '\x1b'},
+ []Event{KeyPressEvent{Code: KeyEscape, Mod: ModAlt}},
+ },
+ {
+ "a b o",
+ []byte{
+ '\x1b', '[', '2', '0', '0', '~',
+ 'a', ' ', 'b',
+ '\x1b', '[', '2', '0', '1', '~',
+ 'o',
+ },
+ []Event{
+ PasteStartEvent{},
+ PasteEvent("a b"),
+ PasteEndEvent{},
+ KeyPressEvent{Code: 'o', Text: "o"},
+ },
+ },
+ {
+ "a\x03\nb",
+ []byte{
+ '\x1b', '[', '2', '0', '0', '~',
+ 'a', '\x03', '\n', 'b',
+ '\x1b', '[', '2', '0', '1', '~',
+ },
+ []Event{
+ PasteStartEvent{},
+ PasteEvent("a\x03\nb"),
+ PasteEndEvent{},
+ },
+ },
+ {
+ "?0xfe?",
+ []byte{'\xfe'},
+ []Event{
+ UnknownEvent(rune(0xfe)),
+ },
+ },
+ {
+ "a ?0xfe? b",
+ []byte{'a', '\xfe', ' ', 'b'},
+ []Event{
+ KeyPressEvent{Code: 'a', Text: "a"},
+ UnknownEvent(rune(0xfe)),
+ KeyPressEvent{Code: KeySpace, Text: " "},
+ KeyPressEvent{Code: 'b', Text: "b"},
+ },
+ },
+ }
+
+ for i, td := range testData {
+ t.Run(fmt.Sprintf("%d: %s", i, td.keyname), func(t *testing.T) {
+ Events := testReadInputs(t, bytes.NewReader(td.in))
+ var buf strings.Builder
+ for i, Event := range Events {
+ if i > 0 {
+ buf.WriteByte(' ')
+ }
+ if s, ok := Event.(fmt.Stringer); ok {
+ buf.WriteString(s.String())
+ } else {
+ fmt.Fprintf(&buf, "%#v:%T", Event, Event)
+ }
+ }
+
+ if len(Events) != len(td.out) {
+ t.Fatalf("unexpected message list length: got %d, expected %d\n got: %#v\n expected: %#v\n", len(Events), len(td.out), Events, td.out)
+ }
+
+ if !reflect.DeepEqual(td.out, Events) {
+ t.Fatalf("expected:\n%#v\ngot:\n%#v", td.out, Events)
+ }
+ })
+ }
+}
+
+func testReadInputs(t *testing.T, input io.Reader) []Event {
+ // We'll check that the input reader finishes at the end
+ // without error.
+ var wg sync.WaitGroup
+ var inputErr error
+ ctx, cancel := context.WithCancel(context.Background())
+ defer func() {
+ cancel()
+ wg.Wait()
+ if inputErr != nil && !errors.Is(inputErr, io.EOF) {
+ t.Fatalf("unexpected input error: %v", inputErr)
+ }
+ }()
+
+ dr, err := NewReader(input, "dumb", 0)
+ if err != nil {
+ t.Fatalf("unexpected input driver error: %v", err)
+ }
+
+ // The messages we're consuming.
+ EventsC := make(chan Event)
+
+ // Start the reader in the background.
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ var events []Event
+ events, inputErr = dr.ReadEvents()
+ out:
+ for _, ev := range events {
+ select {
+ case EventsC <- ev:
+ case <-ctx.Done():
+ break out
+ }
+ }
+ EventsC <- nil
+ }()
+
+ var Events []Event
+loop:
+ for {
+ select {
+ case Event := <-EventsC:
+ if Event == nil {
+ // end of input marker for the test.
+ break loop
+ }
+ Events = append(Events, Event)
+ case <-time.After(2 * time.Second):
+ t.Errorf("timeout waiting for input event")
+ break loop
+ }
+ }
+ return Events
+}
+
+// randTest defines the test input and expected output for a sequence
+// of interleaved control sequences and control characters.
+type randTest struct {
+ data []byte
+ lengths []int
+ names []string
+}
+
+// seed is the random seed to randomize the input. This helps check
+// that all the sequences get ultimately exercised.
+var seed = flag.Int64("seed", 0, "random seed (0 to autoselect)")
+
+// genRandomData generates a randomized test, with a random seed unless
+// the seed flag was set.
+func genRandomData(logfn func(int64), length int) randTest {
+ // We'll use a random source. However, we give the user the option
+ // to override it to a specific value for reproduceability.
+ s := *seed
+ if s == 0 {
+ s = time.Now().UnixNano()
+ }
+ // Inform the user so they know what to reuse to get the same data.
+ logfn(s)
+ return genRandomDataWithSeed(s, length)
+}
+
+// genRandomDataWithSeed generates a randomized test with a fixed seed.
+func genRandomDataWithSeed(s int64, length int) randTest {
+ src := rand.NewSource(s)
+ r := rand.New(src)
+
+ // allseqs contains all the sequences, in sorted order. We sort
+ // to make the test deterministic (when the seed is also fixed).
+ type seqpair struct {
+ seq string
+ name string
+ }
+ var allseqs []seqpair
+ for seq, key := range sequences {
+ allseqs = append(allseqs, seqpair{seq, key.String()})
+ }
+ sort.Slice(allseqs, func(i, j int) bool { return allseqs[i].seq < allseqs[j].seq })
+
+ // res contains the computed test.
+ var res randTest
+
+ for len(res.data) < length {
+ alt := r.Intn(2)
+ prefix := ""
+ esclen := 0
+ if alt == 1 {
+ prefix = "alt+"
+ esclen = 1
+ }
+ kind := r.Intn(3)
+ switch kind {
+ case 0:
+ // A control character.
+ if alt == 1 {
+ res.data = append(res.data, '\x1b')
+ }
+ res.data = append(res.data, 1)
+ res.names = append(res.names, "ctrl+"+prefix+"a")
+ res.lengths = append(res.lengths, 1+esclen)
+
+ case 1, 2:
+ // A sequence.
+ seqi := r.Intn(len(allseqs))
+ s := allseqs[seqi]
+ if strings.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") {
+ esclen = 0
+ prefix = ""
+ alt = 0
+ }
+ if alt == 1 {
+ res.data = append(res.data, '\x1b')
+ }
+ res.data = append(res.data, s.seq...)
+ if strings.HasPrefix(s.name, "ctrl+") {
+ prefix = "ctrl+" + prefix
+ }
+ name := prefix + strings.TrimPrefix(s.name, "ctrl+")
+ res.names = append(res.names, name)
+ res.lengths = append(res.lengths, len(s.seq)+esclen)
+ }
+ }
+ return res
+}
+
+func FuzzParseSequence(f *testing.F) {
+ var p Parser
+ for seq := range sequences {
+ f.Add(seq)
+ }
+ f.Add("\x1b]52;?\x07") // OSC 52
+ f.Add("\x1b]11;rgb:0000/0000/0000\x1b\\") // OSC 11
+ f.Add("\x1bP>|charm terminal(0.1.2)\x1b\\") // DCS (XTVERSION)
+ f.Add("\x1b_Gi=123\x1b\\") // APC
+ f.Fuzz(func(t *testing.T, seq string) {
+ n, _ := p.parseSequence([]byte(seq))
+ if n == 0 && seq != "" {
+ t.Errorf("expected a non-zero width for %q", seq)
+ }
+ })
+}
+
+// BenchmarkDetectSequenceMap benchmarks the map-based sequence
+// detector.
+func BenchmarkDetectSequenceMap(b *testing.B) {
+ var p Parser
+ td := genRandomDataWithSeed(123, 10000)
+ for i := 0; i < b.N; i++ {
+ for j, w := 0, 0; j < len(td.data); j += w {
+ w, _ = p.parseSequence(td.data[j:])
+ }
+ }
+}
diff --git a/packages/tui/input/kitty.go b/packages/tui/input/kitty.go
new file mode 100644
index 000000000..4da00b502
--- /dev/null
+++ b/packages/tui/input/kitty.go
@@ -0,0 +1,353 @@
+package input
+
+import (
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/ansi/kitty"
+)
+
+// KittyGraphicsEvent represents a Kitty Graphics response event.
+//
+// See https://sw.kovidgoyal.net/kitty/graphics-protocol/
+type KittyGraphicsEvent struct {
+ Options kitty.Options
+ Payload []byte
+}
+
+// KittyEnhancementsEvent represents a Kitty enhancements event.
+type KittyEnhancementsEvent int
+
+// Kitty keyboard enhancement constants.
+// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
+const (
+ KittyDisambiguateEscapeCodes KittyEnhancementsEvent = 1 << iota
+ KittyReportEventTypes
+ KittyReportAlternateKeys
+ KittyReportAllKeysAsEscapeCodes
+ KittyReportAssociatedText
+)
+
+// Contains reports whether m contains the given enhancements.
+func (e KittyEnhancementsEvent) Contains(enhancements KittyEnhancementsEvent) bool {
+ return e&enhancements == enhancements
+}
+
+// Kitty Clipboard Control Sequences.
+var kittyKeyMap = map[int]Key{
+ ansi.BS: {Code: KeyBackspace},
+ ansi.HT: {Code: KeyTab},
+ ansi.CR: {Code: KeyEnter},
+ ansi.ESC: {Code: KeyEscape},
+ ansi.DEL: {Code: KeyBackspace},
+
+ 57344: {Code: KeyEscape},
+ 57345: {Code: KeyEnter},
+ 57346: {Code: KeyTab},
+ 57347: {Code: KeyBackspace},
+ 57348: {Code: KeyInsert},
+ 57349: {Code: KeyDelete},
+ 57350: {Code: KeyLeft},
+ 57351: {Code: KeyRight},
+ 57352: {Code: KeyUp},
+ 57353: {Code: KeyDown},
+ 57354: {Code: KeyPgUp},
+ 57355: {Code: KeyPgDown},
+ 57356: {Code: KeyHome},
+ 57357: {Code: KeyEnd},
+ 57358: {Code: KeyCapsLock},
+ 57359: {Code: KeyScrollLock},
+ 57360: {Code: KeyNumLock},
+ 57361: {Code: KeyPrintScreen},
+ 57362: {Code: KeyPause},
+ 57363: {Code: KeyMenu},
+ 57364: {Code: KeyF1},
+ 57365: {Code: KeyF2},
+ 57366: {Code: KeyF3},
+ 57367: {Code: KeyF4},
+ 57368: {Code: KeyF5},
+ 57369: {Code: KeyF6},
+ 57370: {Code: KeyF7},
+ 57371: {Code: KeyF8},
+ 57372: {Code: KeyF9},
+ 57373: {Code: KeyF10},
+ 57374: {Code: KeyF11},
+ 57375: {Code: KeyF12},
+ 57376: {Code: KeyF13},
+ 57377: {Code: KeyF14},
+ 57378: {Code: KeyF15},
+ 57379: {Code: KeyF16},
+ 57380: {Code: KeyF17},
+ 57381: {Code: KeyF18},
+ 57382: {Code: KeyF19},
+ 57383: {Code: KeyF20},
+ 57384: {Code: KeyF21},
+ 57385: {Code: KeyF22},
+ 57386: {Code: KeyF23},
+ 57387: {Code: KeyF24},
+ 57388: {Code: KeyF25},
+ 57389: {Code: KeyF26},
+ 57390: {Code: KeyF27},
+ 57391: {Code: KeyF28},
+ 57392: {Code: KeyF29},
+ 57393: {Code: KeyF30},
+ 57394: {Code: KeyF31},
+ 57395: {Code: KeyF32},
+ 57396: {Code: KeyF33},
+ 57397: {Code: KeyF34},
+ 57398: {Code: KeyF35},
+ 57399: {Code: KeyKp0},
+ 57400: {Code: KeyKp1},
+ 57401: {Code: KeyKp2},
+ 57402: {Code: KeyKp3},
+ 57403: {Code: KeyKp4},
+ 57404: {Code: KeyKp5},
+ 57405: {Code: KeyKp6},
+ 57406: {Code: KeyKp7},
+ 57407: {Code: KeyKp8},
+ 57408: {Code: KeyKp9},
+ 57409: {Code: KeyKpDecimal},
+ 57410: {Code: KeyKpDivide},
+ 57411: {Code: KeyKpMultiply},
+ 57412: {Code: KeyKpMinus},
+ 57413: {Code: KeyKpPlus},
+ 57414: {Code: KeyKpEnter},
+ 57415: {Code: KeyKpEqual},
+ 57416: {Code: KeyKpSep},
+ 57417: {Code: KeyKpLeft},
+ 57418: {Code: KeyKpRight},
+ 57419: {Code: KeyKpUp},
+ 57420: {Code: KeyKpDown},
+ 57421: {Code: KeyKpPgUp},
+ 57422: {Code: KeyKpPgDown},
+ 57423: {Code: KeyKpHome},
+ 57424: {Code: KeyKpEnd},
+ 57425: {Code: KeyKpInsert},
+ 57426: {Code: KeyKpDelete},
+ 57427: {Code: KeyKpBegin},
+ 57428: {Code: KeyMediaPlay},
+ 57429: {Code: KeyMediaPause},
+ 57430: {Code: KeyMediaPlayPause},
+ 57431: {Code: KeyMediaReverse},
+ 57432: {Code: KeyMediaStop},
+ 57433: {Code: KeyMediaFastForward},
+ 57434: {Code: KeyMediaRewind},
+ 57435: {Code: KeyMediaNext},
+ 57436: {Code: KeyMediaPrev},
+ 57437: {Code: KeyMediaRecord},
+ 57438: {Code: KeyLowerVol},
+ 57439: {Code: KeyRaiseVol},
+ 57440: {Code: KeyMute},
+ 57441: {Code: KeyLeftShift},
+ 57442: {Code: KeyLeftCtrl},
+ 57443: {Code: KeyLeftAlt},
+ 57444: {Code: KeyLeftSuper},
+ 57445: {Code: KeyLeftHyper},
+ 57446: {Code: KeyLeftMeta},
+ 57447: {Code: KeyRightShift},
+ 57448: {Code: KeyRightCtrl},
+ 57449: {Code: KeyRightAlt},
+ 57450: {Code: KeyRightSuper},
+ 57451: {Code: KeyRightHyper},
+ 57452: {Code: KeyRightMeta},
+ 57453: {Code: KeyIsoLevel3Shift},
+ 57454: {Code: KeyIsoLevel5Shift},
+}
+
+func init() {
+ // These are some faulty C0 mappings some terminals such as WezTerm have
+ // and doesn't follow the specs.
+ kittyKeyMap[ansi.NUL] = Key{Code: KeySpace, Mod: ModCtrl}
+ for i := ansi.SOH; i <= ansi.SUB; i++ {
+ if _, ok := kittyKeyMap[i]; !ok {
+ kittyKeyMap[i] = Key{Code: rune(i + 0x60), Mod: ModCtrl}
+ }
+ }
+ for i := ansi.FS; i <= ansi.US; i++ {
+ if _, ok := kittyKeyMap[i]; !ok {
+ kittyKeyMap[i] = Key{Code: rune(i + 0x40), Mod: ModCtrl}
+ }
+ }
+}
+
+const (
+ kittyShift = 1 << iota
+ kittyAlt
+ kittyCtrl
+ kittySuper
+ kittyHyper
+ kittyMeta
+ kittyCapsLock
+ kittyNumLock
+)
+
+func fromKittyMod(mod int) KeyMod {
+ var m KeyMod
+ if mod&kittyShift != 0 {
+ m |= ModShift
+ }
+ if mod&kittyAlt != 0 {
+ m |= ModAlt
+ }
+ if mod&kittyCtrl != 0 {
+ m |= ModCtrl
+ }
+ if mod&kittySuper != 0 {
+ m |= ModSuper
+ }
+ if mod&kittyHyper != 0 {
+ m |= ModHyper
+ }
+ if mod&kittyMeta != 0 {
+ m |= ModMeta
+ }
+ if mod&kittyCapsLock != 0 {
+ m |= ModCapsLock
+ }
+ if mod&kittyNumLock != 0 {
+ m |= ModNumLock
+ }
+ return m
+}
+
+// parseKittyKeyboard parses a Kitty Keyboard Protocol sequence.
+//
+// In `CSI u`, this is parsed as:
+//
+// CSI codepoint ; modifiers u
+// codepoint: ASCII Dec value
+//
+// The Kitty Keyboard Protocol extends this with optional components that can be
+// enabled progressively. The full sequence is parsed as:
+//
+// CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u
+//
+// See https://sw.kovidgoyal.net/kitty/keyboard-protocol/
+func parseKittyKeyboard(params ansi.Params) (Event Event) {
+ var isRelease bool
+ var key Key
+
+ // The index of parameters separated by semicolons ';'. Sub parameters are
+ // separated by colons ':'.
+ var paramIdx int
+ var sudIdx int // The sub parameter index
+ for _, p := range params {
+ // Kitty Keyboard Protocol has 3 optional components.
+ switch paramIdx {
+ case 0:
+ switch sudIdx {
+ case 0:
+ var foundKey bool
+ code := p.Param(1) // CSI u has a default value of 1
+ key, foundKey = kittyKeyMap[code]
+ if !foundKey {
+ r := rune(code)
+ if !utf8.ValidRune(r) {
+ r = utf8.RuneError
+ }
+
+ key.Code = r
+ }
+
+ case 2:
+ // shifted key + base key
+ if b := rune(p.Param(1)); unicode.IsPrint(b) {
+ // XXX: When alternate key reporting is enabled, the protocol
+ // can return 3 things, the unicode codepoint of the key,
+ // the shifted codepoint of the key, and the standard
+ // PC-101 key layout codepoint.
+ // This is useful to create an unambiguous mapping of keys
+ // when using a different language layout.
+ key.BaseCode = b
+ }
+ fallthrough
+
+ case 1:
+ // shifted key
+ if s := rune(p.Param(1)); unicode.IsPrint(s) {
+ // XXX: We swap keys here because we want the shifted key
+ // to be the Rune that is returned by the event.
+ // For example, shift+a should produce "A" not "a".
+ // In such a case, we set AltRune to the original key "a"
+ // and Rune to "A".
+ key.ShiftedCode = s
+ }
+ }
+ case 1:
+ switch sudIdx {
+ case 0:
+ mod := p.Param(1)
+ if mod > 1 {
+ key.Mod = fromKittyMod(mod - 1)
+ if key.Mod > ModShift {
+ // XXX: We need to clear the text if we have a modifier key
+ // other than a [ModShift] key.
+ key.Text = ""
+ }
+ }
+
+ case 1:
+ switch p.Param(1) {
+ case 2:
+ key.IsRepeat = true
+ case 3:
+ isRelease = true
+ }
+ case 2:
+ }
+ case 2:
+ if code := p.Param(0); code != 0 {
+ key.Text += string(rune(code))
+ }
+ }
+
+ sudIdx++
+ if !p.HasMore() {
+ paramIdx++
+ sudIdx = 0
+ }
+ }
+
+ //nolint:nestif
+ if len(key.Text) == 0 && unicode.IsPrint(key.Code) &&
+ (key.Mod <= ModShift || key.Mod == ModCapsLock || key.Mod == ModShift|ModCapsLock) {
+ if key.Mod == 0 {
+ key.Text = string(key.Code)
+ } else {
+ desiredCase := unicode.ToLower
+ if key.Mod.Contains(ModShift) || key.Mod.Contains(ModCapsLock) {
+ desiredCase = unicode.ToUpper
+ }
+ if key.ShiftedCode != 0 {
+ key.Text = string(key.ShiftedCode)
+ } else {
+ key.Text = string(desiredCase(key.Code))
+ }
+ }
+ }
+
+ if isRelease {
+ return KeyReleaseEvent(key)
+ }
+
+ return KeyPressEvent(key)
+}
+
+// parseKittyKeyboardExt parses a Kitty Keyboard Protocol sequence extensions
+// for non CSI u sequences. This includes things like CSI A, SS3 A and others,
+// and CSI ~.
+func parseKittyKeyboardExt(params ansi.Params, k KeyPressEvent) Event {
+ // Handle Kitty keyboard protocol
+ if len(params) > 2 && // We have at least 3 parameters
+ params[0].Param(1) == 1 && // The first parameter is 1 (defaults to 1)
+ params[1].HasMore() { // The second parameter is a subparameter (separated by a ":")
+ switch params[2].Param(1) { // The third parameter is the event type (defaults to 1)
+ case 2:
+ k.IsRepeat = true
+ case 3:
+ return KeyReleaseEvent(k)
+ }
+ }
+ return k
+}
diff --git a/packages/tui/input/mod.go b/packages/tui/input/mod.go
new file mode 100644
index 000000000..c00762769
--- /dev/null
+++ b/packages/tui/input/mod.go
@@ -0,0 +1,37 @@
+package input
+
+// KeyMod represents modifier keys.
+type KeyMod int
+
+// Modifier keys.
+const (
+ ModShift KeyMod = 1 << iota
+ ModAlt
+ ModCtrl
+ ModMeta
+
+ // These modifiers are used with the Kitty protocol.
+ // XXX: Meta and Super are swapped in the Kitty protocol,
+ // this is to preserve compatibility with XTerm modifiers.
+
+ ModHyper
+ ModSuper // Windows/Command keys
+
+ // These are key lock states.
+
+ ModCapsLock
+ ModNumLock
+ ModScrollLock // Defined in Windows API only
+)
+
+// Contains reports whether m contains the given modifiers.
+//
+// Example:
+//
+// m := ModAlt | ModCtrl
+// m.Contains(ModCtrl) // true
+// m.Contains(ModAlt | ModCtrl) // true
+// m.Contains(ModAlt | ModCtrl | ModShift) // false
+func (m KeyMod) Contains(mods KeyMod) bool {
+ return m&mods == mods
+}
diff --git a/packages/tui/input/mode.go b/packages/tui/input/mode.go
new file mode 100644
index 000000000..ea1ba571d
--- /dev/null
+++ b/packages/tui/input/mode.go
@@ -0,0 +1,14 @@
+package input
+
+import "github.com/charmbracelet/x/ansi"
+
+// ModeReportEvent is a message that represents a mode report event (DECRPM).
+//
+// See: https://vt100.net/docs/vt510-rm/DECRPM.html
+type ModeReportEvent struct {
+ // Mode is the mode number.
+ Mode ansi.Mode
+
+ // Value is the mode value.
+ Value ansi.ModeSetting
+}
diff --git a/packages/tui/input/mouse.go b/packages/tui/input/mouse.go
new file mode 100644
index 000000000..d97eb72ed
--- /dev/null
+++ b/packages/tui/input/mouse.go
@@ -0,0 +1,292 @@
+package input
+
+import (
+ "fmt"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+// MouseButton represents the button that was pressed during a mouse message.
+type MouseButton = ansi.MouseButton
+
+// 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 = ansi.MouseNone
+ MouseLeft = ansi.MouseLeft
+ MouseMiddle = ansi.MouseMiddle
+ MouseRight = ansi.MouseRight
+ MouseWheelUp = ansi.MouseWheelUp
+ MouseWheelDown = ansi.MouseWheelDown
+ MouseWheelLeft = ansi.MouseWheelLeft
+ MouseWheelRight = ansi.MouseWheelRight
+ MouseBackward = ansi.MouseBackward
+ MouseForward = ansi.MouseForward
+ MouseButton10 = ansi.MouseButton10
+ MouseButton11 = ansi.MouseButton11
+)
+
+// MouseEvent represents a mouse message. This is a generic mouse message that
+// can represent any kind of mouse event.
+type MouseEvent interface {
+ fmt.Stringer
+
+ // Mouse returns the underlying mouse event.
+ Mouse() Mouse
+}
+
+// Mouse represents a Mouse message. Use [MouseEvent] to represent all mouse
+// messages.
+//
+// The X and Y coordinates are zero-based, with (0,0) being the upper left
+// corner of the terminal.
+//
+// // Catch all mouse events
+// switch Event := Event.(type) {
+// case MouseEvent:
+// m := Event.Mouse()
+// fmt.Println("Mouse event:", m.X, m.Y, m)
+// }
+//
+// // Only catch mouse click events
+// switch Event := Event.(type) {
+// case MouseClickEvent:
+// fmt.Println("Mouse click event:", Event.X, Event.Y, Event)
+// }
+type Mouse struct {
+ X, Y int
+ Button MouseButton
+ Mod KeyMod
+}
+
+// String returns a string representation of the mouse message.
+func (m Mouse) String() (s string) {
+ if m.Mod.Contains(ModCtrl) {
+ s += "ctrl+"
+ }
+ if m.Mod.Contains(ModAlt) {
+ s += "alt+"
+ }
+ if m.Mod.Contains(ModShift) {
+ s += "shift+"
+ }
+
+ str := m.Button.String()
+ if str == "" {
+ s += "unknown"
+ } else if str != "none" { // motion events don't have a button
+ s += str
+ }
+
+ return s
+}
+
+// MouseClickEvent represents a mouse button click event.
+type MouseClickEvent Mouse
+
+// String returns a string representation of the mouse click event.
+func (e MouseClickEvent) String() string {
+ return Mouse(e).String()
+}
+
+// Mouse returns the underlying mouse event. This is a convenience method and
+// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
+// event to [Mouse].
+func (e MouseClickEvent) Mouse() Mouse {
+ return Mouse(e)
+}
+
+// MouseReleaseEvent represents a mouse button release event.
+type MouseReleaseEvent Mouse
+
+// String returns a string representation of the mouse release event.
+func (e MouseReleaseEvent) String() string {
+ return Mouse(e).String()
+}
+
+// Mouse returns the underlying mouse event. This is a convenience method and
+// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
+// event to [Mouse].
+func (e MouseReleaseEvent) Mouse() Mouse {
+ return Mouse(e)
+}
+
+// MouseWheelEvent represents a mouse wheel message event.
+type MouseWheelEvent Mouse
+
+// String returns a string representation of the mouse wheel event.
+func (e MouseWheelEvent) String() string {
+ return Mouse(e).String()
+}
+
+// Mouse returns the underlying mouse event. This is a convenience method and
+// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
+// event to [Mouse].
+func (e MouseWheelEvent) Mouse() Mouse {
+ return Mouse(e)
+}
+
+// MouseMotionEvent represents a mouse motion event.
+type MouseMotionEvent Mouse
+
+// String returns a string representation of the mouse motion event.
+func (e MouseMotionEvent) String() string {
+ m := Mouse(e)
+ if m.Button != 0 {
+ return m.String() + "+motion"
+ }
+ return m.String() + "motion"
+}
+
+// Mouse returns the underlying mouse event. This is a convenience method and
+// syntactic sugar to satisfy the [MouseEvent] interface, and cast the mouse
+// event to [Mouse].
+func (e MouseMotionEvent) Mouse() Mouse {
+ return Mouse(e)
+}
+
+// Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events
+// look like:
+//
+// ESC [ < Cb ; Cx ; Cy (M or m)
+//
+// where:
+//
+// Cb is the encoded button code
+// Cx is the x-coordinate of the mouse
+// Cy is the y-coordinate of the mouse
+// M is for button press, m is for button release
+//
+// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
+func parseSGRMouseEvent(cmd ansi.Cmd, params ansi.Params) Event {
+ x, _, ok := params.Param(1, 1)
+ if !ok {
+ x = 1
+ }
+ y, _, ok := params.Param(2, 1)
+ if !ok {
+ y = 1
+ }
+ release := cmd.Final() == 'm'
+ b, _, _ := params.Param(0, 0)
+ mod, btn, _, isMotion := parseMouseButton(b)
+
+ // (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
+ x--
+ y--
+
+ m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
+
+ // Wheel buttons don't have release events
+ // Motion can be reported as a release event in some terminals (Windows Terminal)
+ if isWheel(m.Button) {
+ return MouseWheelEvent(m)
+ } else if !isMotion && release {
+ return MouseReleaseEvent(m)
+ } else if isMotion {
+ return MouseMotionEvent(m)
+ }
+ return MouseClickEvent(m)
+}
+
+const x10MouseByteOffset = 32
+
+// Parse X10-encoded mouse events; the simplest kind. The last release of X10
+// was December 1986, by the way. The original X10 mouse protocol limits the Cx
+// and Cy coordinates to 223 (=255-032).
+//
+// X10 mouse events look like:
+//
+// ESC [M Cb Cx Cy
+//
+// See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking
+func parseX10MouseEvent(buf []byte) Event {
+ v := buf[3:6]
+ b := int(v[0])
+ if b >= x10MouseByteOffset {
+ // XXX: b < 32 should be impossible, but we're being defensive.
+ b -= x10MouseByteOffset
+ }
+
+ mod, btn, isRelease, isMotion := parseMouseButton(b)
+
+ // (1,1) is the upper left. We subtract 1 to normalize it to (0,0).
+ x := int(v[1]) - x10MouseByteOffset - 1
+ y := int(v[2]) - x10MouseByteOffset - 1
+
+ m := Mouse{X: x, Y: y, Button: btn, Mod: mod}
+ if isWheel(m.Button) {
+ return MouseWheelEvent(m)
+ } else if isMotion {
+ return MouseMotionEvent(m)
+ } else if isRelease {
+ return MouseReleaseEvent(m)
+ }
+ return MouseClickEvent(m)
+}
+
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates
+func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) {
+ // 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
+ )
+
+ // Modifiers
+ if b&bitAlt != 0 {
+ mod |= ModAlt
+ }
+ if b&bitCtrl != 0 {
+ mod |= ModCtrl
+ }
+ if b&bitShift != 0 {
+ mod |= ModShift
+ }
+
+ if b&bitAdd != 0 {
+ btn = MouseBackward + MouseButton(b&bitsMask)
+ } else if b&bitWheel != 0 {
+ btn = MouseWheelUp + MouseButton(b&bitsMask)
+ } else {
+ btn = MouseLeft + MouseButton(b&bitsMask)
+ // X10 reports a button release as 0b0000_0011 (3)
+ if b&bitsMask == bitsMask {
+ btn = MouseNone
+ isRelease = true
+ }
+ }
+
+ // Motion bit doesn't get reported for wheel events.
+ if b&bitMotion != 0 && !isWheel(btn) {
+ isMotion = true
+ }
+
+ return //nolint:nakedret
+}
+
+// isWheel returns true if the mouse event is a wheel event.
+func isWheel(btn MouseButton) bool {
+ return btn >= MouseWheelUp && btn <= MouseWheelRight
+}
diff --git a/packages/tui/input/mouse_test.go b/packages/tui/input/mouse_test.go
new file mode 100644
index 000000000..d55e41480
--- /dev/null
+++ b/packages/tui/input/mouse_test.go
@@ -0,0 +1,481 @@
+package input
+
+import (
+ "fmt"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/ansi/parser"
+)
+
+func TestMouseEvent_String(t *testing.T) {
+ tt := []struct {
+ name string
+ event Event
+ expected string
+ }{
+ {
+ name: "unknown",
+ event: MouseClickEvent{Button: MouseButton(0xff)},
+ expected: "unknown",
+ },
+ {
+ name: "left",
+ event: MouseClickEvent{Button: MouseLeft},
+ expected: "left",
+ },
+ {
+ name: "right",
+ event: MouseClickEvent{Button: MouseRight},
+ expected: "right",
+ },
+ {
+ name: "middle",
+ event: MouseClickEvent{Button: MouseMiddle},
+ expected: "middle",
+ },
+ {
+ name: "release",
+ event: MouseReleaseEvent{Button: MouseNone},
+ expected: "",
+ },
+ {
+ name: "wheelup",
+ event: MouseWheelEvent{Button: MouseWheelUp},
+ expected: "wheelup",
+ },
+ {
+ name: "wheeldown",
+ event: MouseWheelEvent{Button: MouseWheelDown},
+ expected: "wheeldown",
+ },
+ {
+ name: "wheelleft",
+ event: MouseWheelEvent{Button: MouseWheelLeft},
+ expected: "wheelleft",
+ },
+ {
+ name: "wheelright",
+ event: MouseWheelEvent{Button: MouseWheelRight},
+ expected: "wheelright",
+ },
+ {
+ name: "motion",
+ event: MouseMotionEvent{Button: MouseNone},
+ expected: "motion",
+ },
+ {
+ name: "shift+left",
+ event: MouseReleaseEvent{Button: MouseLeft, Mod: ModShift},
+ expected: "shift+left",
+ },
+ {
+ name: "shift+left", event: MouseClickEvent{Button: MouseLeft, Mod: ModShift},
+ expected: "shift+left",
+ },
+ {
+ name: "ctrl+shift+left",
+ event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl | ModShift},
+ expected: "ctrl+shift+left",
+ },
+ {
+ name: "alt+left",
+ event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt},
+ expected: "alt+left",
+ },
+ {
+ name: "ctrl+left",
+ event: MouseClickEvent{Button: MouseLeft, Mod: ModCtrl},
+ expected: "ctrl+left",
+ },
+ {
+ name: "ctrl+alt+left",
+ event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl},
+ expected: "ctrl+alt+left",
+ },
+ {
+ name: "ctrl+alt+shift+left",
+ event: MouseClickEvent{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift},
+ expected: "ctrl+alt+shift+left",
+ },
+ {
+ name: "ignore coordinates",
+ event: MouseClickEvent{X: 100, Y: 200, Button: MouseLeft},
+ expected: "left",
+ },
+ {
+ name: "broken type",
+ event: MouseClickEvent{Button: MouseButton(120)},
+ expected: "unknown",
+ },
+ }
+
+ for i := range tt {
+ tc := tt[i]
+
+ t.Run(tc.name, func(t *testing.T) {
+ actual := fmt.Sprint(tc.event)
+
+ if tc.expected != actual {
+ t.Fatalf("expected %q but got %q",
+ tc.expected,
+ actual,
+ )
+ }
+ })
+ }
+}
+
+func TestParseX10MouseDownEvent(t *testing.T) {
+ encode := func(b byte, x, y int) []byte {
+ return []byte{
+ '\x1b',
+ '[',
+ 'M',
+ byte(32) + b,
+ byte(x + 32 + 1),
+ byte(y + 32 + 1),
+ }
+ }
+
+ tt := []struct {
+ name string
+ buf []byte
+ expected Event
+ }{
+ // Position.
+ {
+ name: "zero position",
+ buf: encode(0b0000_0000, 0, 0),
+ expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
+ },
+ {
+ name: "max position",
+ buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1.
+ expected: MouseClickEvent{X: 222, Y: 222, Button: MouseLeft},
+ },
+ // Simple.
+ {
+ name: "left",
+ buf: encode(0b0000_0000, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
+ },
+ {
+ name: "left in motion",
+ buf: encode(0b0010_0000, 32, 16),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
+ },
+ {
+ name: "middle",
+ buf: encode(0b0000_0001, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
+ },
+ {
+ name: "middle in motion",
+ buf: encode(0b0010_0001, 32, 16),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
+ },
+ {
+ name: "right",
+ buf: encode(0b0000_0010, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
+ },
+ {
+ name: "right in motion",
+ buf: encode(0b0010_0010, 32, 16),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseRight},
+ },
+ {
+ name: "motion",
+ buf: encode(0b0010_0011, 32, 16),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
+ },
+ {
+ name: "wheel up",
+ buf: encode(0b0100_0000, 32, 16),
+ expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
+ },
+ {
+ name: "wheel down",
+ buf: encode(0b0100_0001, 32, 16),
+ expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
+ },
+ {
+ name: "wheel left",
+ buf: encode(0b0100_0010, 32, 16),
+ expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
+ },
+ {
+ name: "wheel right",
+ buf: encode(0b0100_0011, 32, 16),
+ expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
+ },
+ {
+ name: "release",
+ buf: encode(0b0000_0011, 32, 16),
+ expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseNone},
+ },
+ {
+ name: "backward",
+ buf: encode(0b1000_0000, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
+ },
+ {
+ name: "forward",
+ buf: encode(0b1000_0001, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
+ },
+ {
+ name: "button 10",
+ buf: encode(0b1000_0010, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton10},
+ },
+ {
+ name: "button 11",
+ buf: encode(0b1000_0011, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseButton11},
+ },
+ // Combinations.
+ {
+ name: "alt+right",
+ buf: encode(0b0000_1010, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+ },
+ {
+ name: "ctrl+right",
+ buf: encode(0b0001_0010, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+ },
+ {
+ name: "left in motion",
+ buf: encode(0b0010_0000, 32, 16),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
+ },
+ {
+ name: "alt+right in motion",
+ buf: encode(0b0010_1010, 32, 16),
+ expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+ },
+ {
+ name: "ctrl+right in motion",
+ buf: encode(0b0011_0010, 32, 16),
+ expected: MouseMotionEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+ },
+ {
+ name: "ctrl+alt+right",
+ buf: encode(0b0001_1010, 32, 16),
+ expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
+ },
+ {
+ name: "ctrl+wheel up",
+ buf: encode(0b0101_0000, 32, 16),
+ expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp},
+ },
+ {
+ name: "alt+wheel down",
+ buf: encode(0b0100_1001, 32, 16),
+ expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
+ },
+ {
+ name: "ctrl+alt+wheel down",
+ buf: encode(0b0101_1001, 32, 16),
+ expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
+ },
+ // Overflow position.
+ {
+ name: "overflow position",
+ buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1.
+ expected: MouseMotionEvent{X: -6, Y: -33, Button: MouseLeft},
+ },
+ }
+
+ for i := range tt {
+ tc := tt[i]
+
+ t.Run(tc.name, func(t *testing.T) {
+ actual := parseX10MouseEvent(tc.buf)
+
+ if tc.expected != actual {
+ t.Fatalf("expected %#v but got %#v",
+ tc.expected,
+ actual,
+ )
+ }
+ })
+ }
+}
+
+func TestParseSGRMouseEvent(t *testing.T) {
+ type csiSequence struct {
+ params []ansi.Param
+ cmd ansi.Cmd
+ }
+ encode := func(b, x, y int, r bool) *csiSequence {
+ re := 'M'
+ if r {
+ re = 'm'
+ }
+ return &csiSequence{
+ params: []ansi.Param{
+ ansi.Param(b),
+ ansi.Param(x + 1),
+ ansi.Param(y + 1),
+ },
+ cmd: ansi.Cmd(re) | ('<' << parser.PrefixShift),
+ }
+ }
+
+ tt := []struct {
+ name string
+ buf *csiSequence
+ expected Event
+ }{
+ // Position.
+ {
+ name: "zero position",
+ buf: encode(0, 0, 0, false),
+ expected: MouseClickEvent{X: 0, Y: 0, Button: MouseLeft},
+ },
+ {
+ name: "225 position",
+ buf: encode(0, 225, 225, false),
+ expected: MouseClickEvent{X: 225, Y: 225, Button: MouseLeft},
+ },
+ // Simple.
+ {
+ name: "left",
+ buf: encode(0, 32, 16, false),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseLeft},
+ },
+ {
+ name: "left in motion",
+ buf: encode(32, 32, 16, false),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseLeft},
+ },
+ {
+ name: "left",
+ buf: encode(0, 32, 16, true),
+ expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseLeft},
+ },
+ {
+ name: "middle",
+ buf: encode(1, 32, 16, false),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseMiddle},
+ },
+ {
+ name: "middle in motion",
+ buf: encode(33, 32, 16, false),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseMiddle},
+ },
+ {
+ name: "middle",
+ buf: encode(1, 32, 16, true),
+ expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseMiddle},
+ },
+ {
+ name: "right",
+ buf: encode(2, 32, 16, false),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseRight},
+ },
+ {
+ name: "right",
+ buf: encode(2, 32, 16, true),
+ expected: MouseReleaseEvent{X: 32, Y: 16, Button: MouseRight},
+ },
+ {
+ name: "motion",
+ buf: encode(35, 32, 16, false),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseNone},
+ },
+ {
+ name: "wheel up",
+ buf: encode(64, 32, 16, false),
+ expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelUp},
+ },
+ {
+ name: "wheel down",
+ buf: encode(65, 32, 16, false),
+ expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelDown},
+ },
+ {
+ name: "wheel left",
+ buf: encode(66, 32, 16, false),
+ expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelLeft},
+ },
+ {
+ name: "wheel right",
+ buf: encode(67, 32, 16, false),
+ expected: MouseWheelEvent{X: 32, Y: 16, Button: MouseWheelRight},
+ },
+ {
+ name: "backward",
+ buf: encode(128, 32, 16, false),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseBackward},
+ },
+ {
+ name: "backward in motion",
+ buf: encode(160, 32, 16, false),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseBackward},
+ },
+ {
+ name: "forward",
+ buf: encode(129, 32, 16, false),
+ expected: MouseClickEvent{X: 32, Y: 16, Button: MouseForward},
+ },
+ {
+ name: "forward in motion",
+ buf: encode(161, 32, 16, false),
+ expected: MouseMotionEvent{X: 32, Y: 16, Button: MouseForward},
+ },
+ // Combinations.
+ {
+ name: "alt+right",
+ buf: encode(10, 32, 16, false),
+ expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight},
+ },
+ {
+ name: "ctrl+right",
+ buf: encode(18, 32, 16, false),
+ expected: MouseClickEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight},
+ },
+ {
+ name: "ctrl+alt+right",
+ buf: encode(26, 32, 16, false),
+ expected: MouseClickEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight},
+ },
+ {
+ name: "alt+wheel",
+ buf: encode(73, 32, 16, false),
+ expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown},
+ },
+ {
+ name: "ctrl+wheel",
+ buf: encode(81, 32, 16, false),
+ expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown},
+ },
+ {
+ name: "ctrl+alt+wheel",
+ buf: encode(89, 32, 16, false),
+ expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown},
+ },
+ {
+ name: "ctrl+alt+shift+wheel",
+ buf: encode(93, 32, 16, false),
+ expected: MouseWheelEvent{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown},
+ },
+ }
+
+ for i := range tt {
+ tc := tt[i]
+
+ t.Run(tc.name, func(t *testing.T) {
+ actual := parseSGRMouseEvent(tc.buf.cmd, tc.buf.params)
+ if tc.expected != actual {
+ t.Fatalf("expected %#v but got %#v",
+ tc.expected,
+ actual,
+ )
+ }
+ })
+ }
+}
diff --git a/packages/tui/input/parse.go b/packages/tui/input/parse.go
new file mode 100644
index 000000000..13f564d67
--- /dev/null
+++ b/packages/tui/input/parse.go
@@ -0,0 +1,1029 @@
+package input
+
+import (
+ "bytes"
+ "encoding/base64"
+ "slices"
+ "strings"
+ "unicode"
+ "unicode/utf8"
+
+ "github.com/charmbracelet/x/ansi"
+ "github.com/charmbracelet/x/ansi/parser"
+ "github.com/rivo/uniseg"
+)
+
+// Flags to control the behavior of the parser.
+const (
+ // When this flag is set, the driver will treat both Ctrl+Space and Ctrl+@
+ // as the same key sequence.
+ //
+ // Historically, the ANSI specs generate NUL (0x00) on both the Ctrl+Space
+ // and Ctrl+@ key sequences. This flag allows the driver to treat both as
+ // the same key sequence.
+ FlagCtrlAt = 1 << iota
+
+ // When this flag is set, the driver will treat the Tab key and Ctrl+I as
+ // the same key sequence.
+ //
+ // Historically, the ANSI specs generate HT (0x09) on both the Tab key and
+ // Ctrl+I. This flag allows the driver to treat both as the same key
+ // sequence.
+ FlagCtrlI
+
+ // When this flag is set, the driver will treat the Enter key and Ctrl+M as
+ // the same key sequence.
+ //
+ // Historically, the ANSI specs generate CR (0x0D) on both the Enter key
+ // and Ctrl+M. This flag allows the driver to treat both as the same key.
+ FlagCtrlM
+
+ // When this flag is set, the driver will treat Escape and Ctrl+[ as
+ // the same key sequence.
+ //
+ // Historically, the ANSI specs generate ESC (0x1B) on both the Escape key
+ // and Ctrl+[. This flag allows the driver to treat both as the same key
+ // sequence.
+ FlagCtrlOpenBracket
+
+ // When this flag is set, the driver will send a BS (0x08 byte) character
+ // instead of a DEL (0x7F byte) character when the Backspace key is
+ // pressed.
+ //
+ // The VT100 terminal has both a Backspace and a Delete key. The VT220
+ // terminal dropped the Backspace key and replaced it with the Delete key.
+ // Both terminals send a DEL character when the Delete key is pressed.
+ // Modern terminals and PCs later readded the Delete key but used a
+ // different key sequence, and the Backspace key was standardized to send a
+ // DEL character.
+ FlagBackspace
+
+ // When this flag is set, the driver will recognize the Find key instead of
+ // treating it as a Home key.
+ //
+ // The Find key was part of the VT220 keyboard, and is no longer used in
+ // modern day PCs.
+ FlagFind
+
+ // When this flag is set, the driver will recognize the Select key instead
+ // of treating it as a End key.
+ //
+ // The Symbol key was part of the VT220 keyboard, and is no longer used in
+ // modern day PCs.
+ FlagSelect
+
+ // When this flag is set, the driver will use Terminfo databases to
+ // overwrite the default key sequences.
+ FlagTerminfo
+
+ // When this flag is set, the driver will preserve function keys (F13-F63)
+ // as symbols.
+ //
+ // Since these keys are not part of today's standard 20th century keyboard,
+ // we treat them as F1-F12 modifier keys i.e. ctrl/shift/alt + Fn combos.
+ // Key definitions come from Terminfo, this flag is only useful when
+ // FlagTerminfo is not set.
+ FlagFKeys
+
+ // When this flag is set, the driver will enable mouse mode on Windows.
+ // This is only useful on Windows and has no effect on other platforms.
+ FlagMouseMode
+)
+
+// Parser is a parser for input escape sequences.
+type Parser struct {
+ flags int
+}
+
+// NewParser returns a new input parser. This is a low-level parser that parses
+// escape sequences into human-readable events.
+// This differs from [ansi.Parser] and [ansi.DecodeSequence] in which it
+// recognizes incorrect sequences that some terminals may send.
+//
+// For instance, the X10 mouse protocol sends a `CSI M` sequence followed by 3
+// bytes. If the parser doesn't recognize the 3 bytes, they might be echoed to
+// the terminal output causing a mess.
+//
+// Another example is how URxvt sends invalid sequences for modified keys using
+// invalid CSI final characters like '$'.
+//
+// Use flags to control the behavior of ambiguous key sequences.
+func NewParser(flags int) *Parser {
+ return &Parser{flags: flags}
+}
+
+// parseSequence finds the first recognized event sequence and returns it along
+// with its length.
+//
+// It will return zero and nil no sequence is recognized or when the buffer is
+// empty. If a sequence is not supported, an UnknownEvent is returned.
+func (p *Parser) parseSequence(buf []byte) (n int, Event Event) {
+ if len(buf) == 0 {
+ return 0, nil
+ }
+
+ switch b := buf[0]; b {
+ case ansi.ESC:
+ if len(buf) == 1 {
+ // Escape key
+ return 1, KeyPressEvent{Code: KeyEscape}
+ }
+
+ switch bPrime := buf[1]; bPrime {
+ case 'O': // Esc-prefixed SS3
+ return p.parseSs3(buf)
+ case 'P': // Esc-prefixed DCS
+ return p.parseDcs(buf)
+ case '[': // Esc-prefixed CSI
+ return p.parseCsi(buf)
+ case ']': // Esc-prefixed OSC
+ return p.parseOsc(buf)
+ case '_': // Esc-prefixed APC
+ return p.parseApc(buf)
+ case '^': // Esc-prefixed PM
+ return p.parseStTerminated(ansi.PM, '^', nil)(buf)
+ case 'X': // Esc-prefixed SOS
+ return p.parseStTerminated(ansi.SOS, 'X', nil)(buf)
+ default:
+ n, e := p.parseSequence(buf[1:])
+ if k, ok := e.(KeyPressEvent); ok {
+ k.Text = ""
+ k.Mod |= ModAlt
+ return n + 1, k
+ }
+
+ // Not a key sequence, nor an alt modified key sequence. In that
+ // case, just report a single escape key.
+ return 1, KeyPressEvent{Code: KeyEscape}
+ }
+ case ansi.SS3:
+ return p.parseSs3(buf)
+ case ansi.DCS:
+ return p.parseDcs(buf)
+ case ansi.CSI:
+ return p.parseCsi(buf)
+ case ansi.OSC:
+ return p.parseOsc(buf)
+ case ansi.APC:
+ return p.parseApc(buf)
+ case ansi.PM:
+ return p.parseStTerminated(ansi.PM, '^', nil)(buf)
+ case ansi.SOS:
+ return p.parseStTerminated(ansi.SOS, 'X', nil)(buf)
+ default:
+ if b <= ansi.US || b == ansi.DEL || b == ansi.SP {
+ return 1, p.parseControl(b)
+ } else if b >= ansi.PAD && b <= ansi.APC {
+ // C1 control code
+ // UTF-8 never starts with a C1 control code
+ // Encode these as Ctrl+Alt+<code - 0x40>
+ code := rune(b) - 0x40
+ return 1, KeyPressEvent{Code: code, Mod: ModCtrl | ModAlt}
+ }
+ return p.parseUtf8(buf)
+ }
+}
+
+func (p *Parser) parseCsi(b []byte) (int, Event) {
+ if len(b) == 2 && b[0] == ansi.ESC {
+ // short cut if this is an alt+[ key
+ return 2, KeyPressEvent{Text: string(rune(b[1])), Mod: ModAlt}
+ }
+
+ var cmd ansi.Cmd
+ var params [parser.MaxParamsSize]ansi.Param
+ var paramsLen int
+
+ var i int
+ if b[i] == ansi.CSI || b[i] == ansi.ESC {
+ i++
+ }
+ if i < len(b) && b[i-1] == ansi.ESC && b[i] == '[' {
+ i++
+ }
+
+ // Initial CSI byte
+ if i < len(b) && b[i] >= '<' && b[i] <= '?' {
+ cmd |= ansi.Cmd(b[i]) << parser.PrefixShift
+ }
+
+ // Scan parameter bytes in the range 0x30-0x3F
+ var j int
+ for j = 0; i < len(b) && paramsLen < len(params) && b[i] >= 0x30 && b[i] <= 0x3F; i, j = i+1, j+1 {
+ if b[i] >= '0' && b[i] <= '9' {
+ if params[paramsLen] == parser.MissingParam {
+ params[paramsLen] = 0
+ }
+ params[paramsLen] *= 10
+ params[paramsLen] += ansi.Param(b[i]) - '0'
+ }
+ if b[i] == ':' {
+ params[paramsLen] |= parser.HasMoreFlag
+ }
+ if b[i] == ';' || b[i] == ':' {
+ paramsLen++
+ if paramsLen < len(params) {
+ // Don't overflow the params slice
+ params[paramsLen] = parser.MissingParam
+ }
+ }
+ }
+
+ if j > 0 && paramsLen < len(params) {
+ // has parameters
+ paramsLen++
+ }
+
+ // Scan intermediate bytes in the range 0x20-0x2F
+ var intermed byte
+ for ; i < len(b) && b[i] >= 0x20 && b[i] <= 0x2F; i++ {
+ intermed = b[i]
+ }
+
+ // Set the intermediate byte
+ cmd |= ansi.Cmd(intermed) << parser.IntermedShift
+
+ // Scan final byte in the range 0x40-0x7E
+ if i >= len(b) {
+ // Incomplete sequence
+ return 0, nil
+ }
+ if b[i] < 0x40 || b[i] > 0x7E {
+ // Special case for URxvt keys
+ // CSI <number> $ is an invalid sequence, but URxvt uses it for
+ // shift modified keys.
+ if b[i-1] == '$' {
+ n, ev := p.parseCsi(append(b[:i-1], '~'))
+ if k, ok := ev.(KeyPressEvent); ok {
+ k.Mod |= ModShift
+ return n, k
+ }
+ }
+ return i, UnknownEvent(b[:i-1])
+ }
+
+ // Add the final byte
+ cmd |= ansi.Cmd(b[i])
+ i++
+
+ pa := ansi.Params(params[:paramsLen])
+ switch cmd {
+ case 'y' | '?'<<parser.PrefixShift | '$'<<parser.IntermedShift:
+ // Report Mode (DECRPM)
+ mode, _, ok := pa.Param(0, -1)
+ if !ok || mode == -1 {
+ break
+ }
+ value, _, ok := pa.Param(1, -1)
+ if !ok || value == -1 {
+ break
+ }
+ return i, ModeReportEvent{Mode: ansi.DECMode(mode), Value: ansi.ModeSetting(value)}
+ case 'c' | '?'<<parser.PrefixShift:
+ // Primary Device Attributes
+ return i, parsePrimaryDevAttrs(pa)
+ case 'u' | '?'<<parser.PrefixShift:
+ // Kitty keyboard flags
+ flags, _, ok := pa.Param(0, -1)
+ if !ok || flags == -1 {
+ break
+ }
+ return i, KittyEnhancementsEvent(flags)
+ case 'R' | '?'<<parser.PrefixShift:
+ // This report may return a third parameter representing the page
+ // number, but we don't really need it.
+ row, _, ok := pa.Param(0, 1)
+ if !ok {
+ break
+ }
+ col, _, ok := pa.Param(1, 1)
+ if !ok {
+ break
+ }
+ return i, CursorPositionEvent{Y: row - 1, X: col - 1}
+ case 'm' | '<'<<parser.PrefixShift, 'M' | '<'<<parser.PrefixShift:
+ // Handle SGR mouse
+ if paramsLen == 3 {
+ return i, parseSGRMouseEvent(cmd, pa)
+ }
+ case 'm' | '>'<<parser.PrefixShift:
+ // XTerm modifyOtherKeys
+ mok, _, ok := pa.Param(0, 0)
+ if !ok || mok != 4 {
+ break
+ }
+ val, _, ok := pa.Param(1, -1)
+ if !ok || val == -1 {
+ break
+ }
+ return i, ModifyOtherKeysEvent(val) //nolint:gosec
+ case 'I':
+ return i, FocusEvent{}
+ case 'O':
+ return i, BlurEvent{}
+ case 'R':
+ // Cursor position report OR modified F3
+ row, _, rok := pa.Param(0, 1)
+ col, _, cok := pa.Param(1, 1)
+ if paramsLen == 2 && rok && cok {
+ m := CursorPositionEvent{Y: row - 1, X: col - 1}
+ if row == 1 && col-1 <= int(ModMeta|ModShift|ModAlt|ModCtrl) {
+ // XXX: We cannot differentiate between cursor position report and
+ // CSI 1 ; <mod> R (which is modified F3) when the cursor is at the
+ // row 1. In this case, we report both messages.
+ //
+ // For a non ambiguous cursor position report, use
+ // [ansi.RequestExtendedCursorPosition] (DECXCPR) instead.
+ return i, MultiEvent{KeyPressEvent{Code: KeyF3, Mod: KeyMod(col - 1)}, m}
+ }
+
+ return i, m
+ }
+
+ if paramsLen != 0 {
+ break
+ }
+
+ // Unmodified key F3 (CSI R)
+ fallthrough
+ case 'a', 'b', 'c', 'd', 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'S', 'Z':
+ var k KeyPressEvent
+ switch cmd {
+ case 'a', 'b', 'c', 'd':
+ k = KeyPressEvent{Code: KeyUp + rune(cmd-'a'), Mod: ModShift}
+ case 'A', 'B', 'C', 'D':
+ k = KeyPressEvent{Code: KeyUp + rune(cmd-'A')}
+ case 'E':
+ k = KeyPressEvent{Code: KeyBegin}
+ case 'F':
+ k = KeyPressEvent{Code: KeyEnd}
+ case 'H':
+ k = KeyPressEvent{Code: KeyHome}
+ case 'P', 'Q', 'R', 'S':
+ k = KeyPressEvent{Code: KeyF1 + rune(cmd-'P')}
+ case 'Z':
+ k = KeyPressEvent{Code: KeyTab, Mod: ModShift}
+ }
+ id, _, _ := pa.Param(0, 1)
+ if id == 0 {
+ id = 1
+ }
+ mod, _, _ := pa.Param(1, 1)
+ if mod == 0 {
+ mod = 1
+ }
+ if paramsLen > 1 && id == 1 && mod != -1 {
+ // CSI 1 ; <modifiers> A
+ k.Mod |= KeyMod(mod - 1)
+ }
+ // Don't forget to handle Kitty keyboard protocol
+ return i, parseKittyKeyboardExt(pa, k)
+ case 'M':
+ // Handle X10 mouse
+ if i+2 >= len(b) {
+ // Incomplete sequence
+ return 0, nil
+ }
+ // PERFORMANCE: Do not use append here, as it will allocate a new slice
+ // for every mouse event. Instead, pass a sub-slice of the original
+ // buffer.
+ return i + 3, parseX10MouseEvent(b[i-1 : i+3])
+ case 'y' | '$'<<parser.IntermedShift:
+ // Report Mode (DECRPM)
+ mode, _, ok := pa.Param(0, -1)
+ if !ok || mode == -1 {
+ break
+ }
+ val, _, ok := pa.Param(1, -1)
+ if !ok || val == -1 {
+ break
+ }
+ return i, ModeReportEvent{Mode: ansi.ANSIMode(mode), Value: ansi.ModeSetting(val)}
+ case 'u':
+ // Kitty keyboard protocol & CSI u (fixterms)
+ if paramsLen == 0 {
+ return i, UnknownEvent(b[:i])
+ }
+ return i, parseKittyKeyboard(pa)
+ case '_':
+ // Win32 Input Mode
+ if paramsLen != 6 {
+ return i, UnknownEvent(b[:i])
+ }
+
+ vrc, _, _ := pa.Param(5, 0)
+ rc := uint16(vrc) //nolint:gosec
+ if rc == 0 {
+ rc = 1
+ }
+
+ vk, _, _ := pa.Param(0, 0)
+ sc, _, _ := pa.Param(1, 0)
+ uc, _, _ := pa.Param(2, 0)
+ kd, _, _ := pa.Param(3, 0)
+ cs, _, _ := pa.Param(4, 0)
+ event := p.parseWin32InputKeyEvent(
+ nil,
+ uint16(vk), //nolint:gosec // Vk wVirtualKeyCode
+ uint16(sc), //nolint:gosec // Sc wVirtualScanCode
+ rune(uc), // Uc UnicodeChar
+ kd == 1, // Kd bKeyDown
+ uint32(cs), //nolint:gosec // Cs dwControlKeyState
+ rc, // Rc wRepeatCount
+ )
+
+ if event == nil {
+ return i, UnknownEvent(b[:])
+ }
+
+ return i, event
+ case '@', '^', '~':
+ if paramsLen == 0 {
+ return i, UnknownEvent(b[:i])
+ }
+
+ param, _, _ := pa.Param(0, 0)
+ switch cmd {
+ case '~':
+ switch param {
+ case 27:
+ // XTerm modifyOtherKeys 2
+ if paramsLen != 3 {
+ return i, UnknownEvent(b[:i])
+ }
+ return i, parseXTermModifyOtherKeys(pa)
+ case 200:
+ // bracketed-paste start
+ return i, PasteStartEvent{}
+ case 201:
+ // bracketed-paste end
+ return i, PasteEndEvent{}
+ }
+ }
+
+ switch param {
+ case 1, 2, 3, 4, 5, 6, 7, 8,
+ 11, 12, 13, 14, 15,
+ 17, 18, 19, 20, 21,
+ 23, 24, 25, 26,
+ 28, 29, 31, 32, 33, 34:
+ var k KeyPressEvent
+ switch param {
+ case 1:
+ if p.flags&FlagFind != 0 {
+ k = KeyPressEvent{Code: KeyFind}
+ } else {
+ k = KeyPressEvent{Code: KeyHome}
+ }
+ case 2:
+ k = KeyPressEvent{Code: KeyInsert}
+ case 3:
+ k = KeyPressEvent{Code: KeyDelete}
+ case 4:
+ if p.flags&FlagSelect != 0 {
+ k = KeyPressEvent{Code: KeySelect}
+ } else {
+ k = KeyPressEvent{Code: KeyEnd}
+ }
+ case 5:
+ k = KeyPressEvent{Code: KeyPgUp}
+ case 6:
+ k = KeyPressEvent{Code: KeyPgDown}
+ case 7:
+ k = KeyPressEvent{Code: KeyHome}
+ case 8:
+ k = KeyPressEvent{Code: KeyEnd}
+ case 11, 12, 13, 14, 15:
+ k = KeyPressEvent{Code: KeyF1 + rune(param-11)}
+ case 17, 18, 19, 20, 21:
+ k = KeyPressEvent{Code: KeyF6 + rune(param-17)}
+ case 23, 24, 25, 26:
+ k = KeyPressEvent{Code: KeyF11 + rune(param-23)}
+ case 28, 29:
+ k = KeyPressEvent{Code: KeyF15 + rune(param-28)}
+ case 31, 32, 33, 34:
+ k = KeyPressEvent{Code: KeyF17 + rune(param-31)}
+ }
+
+ // modifiers
+ mod, _, _ := pa.Param(1, -1)
+ if paramsLen > 1 && mod != -1 {
+ k.Mod |= KeyMod(mod - 1)
+ }
+
+ // Handle URxvt weird keys
+ switch cmd {
+ case '~':
+ // Don't forget to handle Kitty keyboard protocol
+ return i, parseKittyKeyboardExt(pa, k)
+ case '^':
+ k.Mod |= ModCtrl
+ case '@':
+ k.Mod |= ModCtrl | ModShift
+ }
+
+ return i, k
+ }
+
+ case 't':
+ param, _, ok := pa.Param(0, 0)
+ if !ok {
+ break
+ }
+
+ var winop WindowOpEvent
+ winop.Op = param
+ for j := 1; j < paramsLen; j++ {
+ val, _, ok := pa.Param(j, 0)
+ if ok {
+ winop.Args = append(winop.Args, val)
+ }
+ }
+
+ return i, winop
+ }
+ return i, UnknownEvent(b[:i])
+}
+
+// parseSs3 parses a SS3 sequence.
+// See https://vt100.net/docs/vt220-rm/chapter4.html#S4.4.4.2
+func (p *Parser) parseSs3(b []byte) (int, Event) {
+ if len(b) == 2 && b[0] == ansi.ESC {
+ // short cut if this is an alt+O key
+ return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+ }
+
+ var i int
+ if b[i] == ansi.SS3 || b[i] == ansi.ESC {
+ i++
+ }
+ if i < len(b) && b[i-1] == ansi.ESC && b[i] == 'O' {
+ i++
+ }
+
+ // Scan numbers from 0-9
+ var mod int
+ for ; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ {
+ mod *= 10
+ mod += int(b[i]) - '0'
+ }
+
+ // Scan a GL character
+ // A GL character is a single byte in the range 0x21-0x7E
+ // See https://vt100.net/docs/vt220-rm/chapter2.html#S2.3.2
+ if i >= len(b) {
+ // Incomplete sequence
+ return 0, nil
+ }
+ if b[i] < 0x21 || b[i] > 0x7E {
+ return i, UnknownEvent(b[:i])
+ }
+
+ // GL character(s)
+ gl := b[i]
+ i++
+
+ var k KeyPressEvent
+ switch gl {
+ case 'a', 'b', 'c', 'd':
+ k = KeyPressEvent{Code: KeyUp + rune(gl-'a'), Mod: ModCtrl}
+ case 'A', 'B', 'C', 'D':
+ k = KeyPressEvent{Code: KeyUp + rune(gl-'A')}
+ case 'E':
+ k = KeyPressEvent{Code: KeyBegin}
+ case 'F':
+ k = KeyPressEvent{Code: KeyEnd}
+ case 'H':
+ k = KeyPressEvent{Code: KeyHome}
+ case 'P', 'Q', 'R', 'S':
+ k = KeyPressEvent{Code: KeyF1 + rune(gl-'P')}
+ case 'M':
+ k = KeyPressEvent{Code: KeyKpEnter}
+ case 'X':
+ k = KeyPressEvent{Code: KeyKpEqual}
+ case 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y':
+ k = KeyPressEvent{Code: KeyKpMultiply + rune(gl-'j')}
+ default:
+ return i, UnknownEvent(b[:i])
+ }
+
+ // Handle weird SS3 <modifier> Func
+ if mod > 0 {
+ k.Mod |= KeyMod(mod - 1)
+ }
+
+ return i, k
+}
+
+func (p *Parser) parseOsc(b []byte) (int, Event) {
+ defaultKey := func() KeyPressEvent {
+ return KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+ }
+ if len(b) == 2 && b[0] == ansi.ESC {
+ // short cut if this is an alt+] key
+ return 2, defaultKey()
+ }
+
+ var i int
+ if b[i] == ansi.OSC || b[i] == ansi.ESC {
+ i++
+ }
+ if i < len(b) && b[i-1] == ansi.ESC && b[i] == ']' {
+ i++
+ }
+
+ // Parse OSC command
+ // An OSC sequence is terminated by a BEL, ESC, or ST character
+ var start, end int
+ cmd := -1
+ for ; i < len(b) && b[i] >= '0' && b[i] <= '9'; i++ {
+ if cmd == -1 {
+ cmd = 0
+ } else {
+ cmd *= 10
+ }
+ cmd += int(b[i]) - '0'
+ }
+
+ if i < len(b) && b[i] == ';' {
+ // mark the start of the sequence data
+ i++
+ start = i
+ }
+
+ for ; i < len(b); i++ {
+ // advance to the end of the sequence
+ if slices.Contains([]byte{ansi.BEL, ansi.ESC, ansi.ST, ansi.CAN, ansi.SUB}, b[i]) {
+ break
+ }
+ }
+
+ if i >= len(b) {
+ // Incomplete sequence
+ return 0, nil
+ }
+
+ end = i // end of the sequence data
+ i++
+
+ // Check 7-bit ST (string terminator) character
+ switch b[i-1] {
+ case ansi.CAN, ansi.SUB:
+ return i, UnknownEvent(b[:i])
+ case ansi.ESC:
+ if i >= len(b) || b[i] != '\\' {
+ if cmd == -1 || (start == 0 && end == 2) {
+ return 2, defaultKey()
+ }
+
+ // If we don't have a valid ST terminator, then this is a
+ // cancelled sequence and should be ignored.
+ return i, UnknownEvent(b[:i])
+ }
+
+ i++
+ }
+
+ if end <= start {
+ return i, UnknownEvent(b[:i])
+ }
+
+ // PERFORMANCE: Only allocate the data string if we know we have a handler
+ // for the command. This avoids allocations for unknown OSC sequences that
+ // can be sent in high frequency by trackpads.
+ switch cmd {
+ case 10, 11, 12:
+ data := string(b[start:end])
+ color := ansi.XParseColor(data)
+ switch cmd {
+ case 10:
+ return i, ForegroundColorEvent{color}
+ case 11:
+ return i, BackgroundColorEvent{color}
+ case 12:
+ return i, CursorColorEvent{color}
+ }
+ case 52:
+ data := string(b[start:end])
+ parts := strings.Split(data, ";")
+ if len(parts) == 0 {
+ return i, ClipboardEvent{}
+ }
+ if len(parts) != 2 || len(parts[0]) < 1 {
+ break
+ }
+
+ b64 := parts[1]
+ bts, err := base64.StdEncoding.DecodeString(b64)
+ if err != nil {
+ break
+ }
+
+ sel := ClipboardSelection(parts[0][0]) //nolint:unconvert
+ return i, ClipboardEvent{Selection: sel, Content: string(bts)}
+ }
+
+ return i, UnknownEvent(b[:i])
+}
+
+// parseStTerminated parses a control sequence that gets terminated by a ST character.
+func (p *Parser) parseStTerminated(
+ intro8, intro7 byte,
+ fn func([]byte) Event,
+) func([]byte) (int, Event) {
+ defaultKey := func(b []byte) (int, Event) {
+ switch intro8 {
+ case ansi.SOS:
+ return 2, KeyPressEvent{Code: 'x', Mod: ModShift | ModAlt}
+ case ansi.PM, ansi.APC:
+ return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+ }
+ return 0, nil
+ }
+ return func(b []byte) (int, Event) {
+ if len(b) == 2 && b[0] == ansi.ESC {
+ return defaultKey(b)
+ }
+
+ var i int
+ if b[i] == intro8 || b[i] == ansi.ESC {
+ i++
+ }
+ if i < len(b) && b[i-1] == ansi.ESC && b[i] == intro7 {
+ i++
+ }
+
+ // Scan control sequence
+ // Most common control sequence is terminated by a ST character
+ // ST is a 7-bit string terminator character is (ESC \)
+ start := i
+ for ; i < len(b); i++ {
+ if slices.Contains([]byte{ansi.ESC, ansi.ST, ansi.CAN, ansi.SUB}, b[i]) {
+ break
+ }
+ }
+
+ if i >= len(b) {
+ // Incomplete sequence
+ return 0, nil
+ }
+
+ end := i // end of the sequence data
+ i++
+
+ // Check 7-bit ST (string terminator) character
+ switch b[i-1] {
+ case ansi.CAN, ansi.SUB:
+ return i, UnknownEvent(b[:i])
+ case ansi.ESC:
+ if i >= len(b) || b[i] != '\\' {
+ if start == end {
+ return defaultKey(b)
+ }
+
+ // If we don't have a valid ST terminator, then this is a
+ // cancelled sequence and should be ignored.
+ return i, UnknownEvent(b[:i])
+ }
+
+ i++
+ }
+
+ // Call the function to parse the sequence and return the result
+ if fn != nil {
+ if e := fn(b[start:end]); e != nil {
+ return i, e
+ }
+ }
+
+ return i, UnknownEvent(b[:i])
+ }
+}
+
+func (p *Parser) parseDcs(b []byte) (int, Event) {
+ if len(b) == 2 && b[0] == ansi.ESC {
+ // short cut if this is an alt+P key
+ return 2, KeyPressEvent{Code: 'p', Mod: ModShift | ModAlt}
+ }
+
+ var params [16]ansi.Param
+ var paramsLen int
+ var cmd ansi.Cmd
+
+ // DCS sequences are introduced by DCS (0x90) or ESC P (0x1b 0x50)
+ var i int
+ if b[i] == ansi.DCS || b[i] == ansi.ESC {
+ i++
+ }
+ if i < len(b) && b[i-1] == ansi.ESC && b[i] == 'P' {
+ i++
+ }
+
+ // initial DCS byte
+ if i < len(b) && b[i] >= '<' && b[i] <= '?' {
+ cmd |= ansi.Cmd(b[i]) << parser.PrefixShift
+ }
+
+ // Scan parameter bytes in the range 0x30-0x3F
+ var j int
+ for j = 0; i < len(b) && paramsLen < len(params) && b[i] >= 0x30 && b[i] <= 0x3F; i, j = i+1, j+1 {
+ if b[i] >= '0' && b[i] <= '9' {
+ if params[paramsLen] == parser.MissingParam {
+ params[paramsLen] = 0
+ }
+ params[paramsLen] *= 10
+ params[paramsLen] += ansi.Param(b[i]) - '0'
+ }
+ if b[i] == ':' {
+ params[paramsLen] |= parser.HasMoreFlag
+ }
+ if b[i] == ';' || b[i] == ':' {
+ paramsLen++
+ if paramsLen < len(params) {
+ // Don't overflow the params slice
+ params[paramsLen] = parser.MissingParam
+ }
+ }
+ }
+
+ if j > 0 && paramsLen < len(params) {
+ // has parameters
+ paramsLen++
+ }
+
+ // Scan intermediate bytes in the range 0x20-0x2F
+ var intermed byte
+ for j := 0; i < len(b) && b[i] >= 0x20 && b[i] <= 0x2F; i, j = i+1, j+1 {
+ intermed = b[i]
+ }
+
+ // set intermediate byte
+ cmd |= ansi.Cmd(intermed) << parser.IntermedShift
+
+ // Scan final byte in the range 0x40-0x7E
+ if i >= len(b) {
+ // Incomplete sequence
+ return 0, nil
+ }
+ if b[i] < 0x40 || b[i] > 0x7E {
+ return i, UnknownEvent(b[:i])
+ }
+
+ // Add the final byte
+ cmd |= ansi.Cmd(b[i])
+ i++
+
+ start := i // start of the sequence data
+ for ; i < len(b); i++ {
+ if b[i] == ansi.ST || b[i] == ansi.ESC {
+ break
+ }
+ }
+
+ if i >= len(b) {
+ // Incomplete sequence
+ return 0, nil
+ }
+
+ end := i // end of the sequence data
+ i++
+
+ // Check 7-bit ST (string terminator) character
+ if i < len(b) && b[i-1] == ansi.ESC && b[i] == '\\' {
+ i++
+ }
+
+ pa := ansi.Params(params[:paramsLen])
+ switch cmd {
+ case 'r' | '+'<<parser.IntermedShift:
+ // XTGETTCAP responses
+ param, _, _ := pa.Param(0, 0)
+ switch param {
+ case 1: // 1 means valid response, 0 means invalid response
+ tc := parseTermcap(b[start:end])
+ // XXX: some terminals like KiTTY report invalid responses with
+ // their queries i.e. sending a query for "Tc" using "\x1bP+q5463\x1b\\"
+ // returns "\x1bP0+r5463\x1b\\".
+ // The specs says that invalid responses should be in the form of
+ // DCS 0 + r ST "\x1bP0+r\x1b\\"
+ // We ignore invalid responses and only send valid ones to the program.
+ //
+ // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+ return i, tc
+ }
+ case '|' | '>'<<parser.PrefixShift:
+ // XTVersion response
+ return i, TerminalVersionEvent(b[start:end])
+ }
+
+ return i, UnknownEvent(b[:i])
+}
+
+func (p *Parser) parseApc(b []byte) (int, Event) {
+ if len(b) == 2 && b[0] == ansi.ESC {
+ // short cut if this is an alt+_ key
+ return 2, KeyPressEvent{Code: rune(b[1]), Mod: ModAlt}
+ }
+
+ // APC sequences are introduced by APC (0x9f) or ESC _ (0x1b 0x5f)
+ return p.parseStTerminated(ansi.APC, '_', func(b []byte) Event {
+ if len(b) == 0 {
+ return nil
+ }
+
+ switch b[0] {
+ case 'G': // Kitty Graphics Protocol
+ var g KittyGraphicsEvent
+ parts := bytes.Split(b[1:], []byte{';'})
+ g.Options.UnmarshalText(parts[0]) //nolint:errcheck,gosec
+ if len(parts) > 1 {
+ g.Payload = parts[1]
+ }
+ return g
+ }
+
+ return nil
+ })(b)
+}
+
+func (p *Parser) parseUtf8(b []byte) (int, Event) {
+ if len(b) == 0 {
+ return 0, nil
+ }
+
+ c := b[0]
+ if c <= ansi.US || c == ansi.DEL || c == ansi.SP {
+ // Control codes get handled by parseControl
+ return 1, p.parseControl(c)
+ } else if c > ansi.US && c < ansi.DEL {
+ // ASCII printable characters
+ code := rune(c)
+ k := KeyPressEvent{Code: code, Text: string(code)}
+ if unicode.IsUpper(code) {
+ // Convert upper case letters to lower case + shift modifier
+ k.Code = unicode.ToLower(code)
+ k.ShiftedCode = code
+ k.Mod |= ModShift
+ }
+
+ return 1, k
+ }
+
+ code, _ := utf8.DecodeRune(b)
+ if code == utf8.RuneError {
+ return 1, UnknownEvent(b[0])
+ }
+
+ cluster, _, _, _ := uniseg.FirstGraphemeCluster(b, -1)
+ // PERFORMANCE: Use RuneCount to check for multi-rune graphemes instead of
+ // looping over the string representation.
+ if utf8.RuneCount(cluster) > 1 {
+ code = KeyExtended
+ }
+
+ return len(cluster), KeyPressEvent{Code: code, Text: string(cluster)}
+}
+
+func (p *Parser) parseControl(b byte) Event {
+ switch b {
+ case ansi.NUL:
+ if p.flags&FlagCtrlAt != 0 {
+ return KeyPressEvent{Code: '@', Mod: ModCtrl}
+ }
+ return KeyPressEvent{Code: KeySpace, Mod: ModCtrl}
+ case ansi.BS:
+ return KeyPressEvent{Code: 'h', Mod: ModCtrl}
+ case ansi.HT:
+ if p.flags&FlagCtrlI != 0 {
+ return KeyPressEvent{Code: 'i', Mod: ModCtrl}
+ }
+ return KeyPressEvent{Code: KeyTab}
+ case ansi.CR:
+ if p.flags&FlagCtrlM != 0 {
+ return KeyPressEvent{Code: 'm', Mod: ModCtrl}
+ }
+ return KeyPressEvent{Code: KeyEnter}
+ case ansi.ESC:
+ if p.flags&FlagCtrlOpenBracket != 0 {
+ return KeyPressEvent{Code: '[', Mod: ModCtrl}
+ }
+ return KeyPressEvent{Code: KeyEscape}
+ case ansi.DEL:
+ if p.flags&FlagBackspace != 0 {
+ return KeyPressEvent{Code: KeyDelete}
+ }
+ return KeyPressEvent{Code: KeyBackspace}
+ case ansi.SP:
+ return KeyPressEvent{Code: KeySpace, Text: " "}
+ default:
+ if b >= ansi.SOH && b <= ansi.SUB {
+ // Use lower case letters for control codes
+ code := rune(b + 0x60)
+ return KeyPressEvent{Code: code, Mod: ModCtrl}
+ } else if b >= ansi.FS && b <= ansi.US {
+ code := rune(b + 0x40)
+ return KeyPressEvent{Code: code, Mod: ModCtrl}
+ }
+ return UnknownEvent(b)
+ }
+}
diff --git a/packages/tui/input/parse_test.go b/packages/tui/input/parse_test.go
new file mode 100644
index 000000000..dc892e0cd
--- /dev/null
+++ b/packages/tui/input/parse_test.go
@@ -0,0 +1,47 @@
+package input
+
+import (
+ "image/color"
+ "reflect"
+ "testing"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+func TestParseSequence_Events(t *testing.T) {
+ input := []byte("\x1b\x1b[Ztest\x00\x1b]10;rgb:1234/1234/1234\x07\x1b[27;2;27~\x1b[?1049;2$y\x1b[4;1$y")
+ want := []Event{
+ KeyPressEvent{Code: KeyTab, Mod: ModShift | ModAlt},
+ KeyPressEvent{Code: 't', Text: "t"},
+ KeyPressEvent{Code: 'e', Text: "e"},
+ KeyPressEvent{Code: 's', Text: "s"},
+ KeyPressEvent{Code: 't', Text: "t"},
+ KeyPressEvent{Code: KeySpace, Mod: ModCtrl},
+ ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}},
+ KeyPressEvent{Code: KeyEscape, Mod: ModShift},
+ ModeReportEvent{Mode: ansi.AltScreenSaveCursorMode, Value: ansi.ModeReset},
+ ModeReportEvent{Mode: ansi.InsertReplaceMode, Value: ansi.ModeSet},
+ }
+
+ var p Parser
+ for i := 0; len(input) != 0; i++ {
+ if i >= len(want) {
+ t.Fatalf("reached end of want events")
+ }
+ n, got := p.parseSequence(input)
+ if !reflect.DeepEqual(got, want[i]) {
+ t.Errorf("got %#v (%T), want %#v (%T)", got, got, want[i], want[i])
+ }
+ input = input[n:]
+ }
+}
+
+func BenchmarkParseSequence(b *testing.B) {
+ var p Parser
+ input := []byte("\x1b\x1b[Ztest\x00\x1b]10;1234/1234/1234\x07\x1b[27;2;27~")
+ b.ReportAllocs()
+ b.ResetTimer()
+ for i := 0; i < b.N; i++ {
+ p.parseSequence(input)
+ }
+}
diff --git a/packages/tui/input/paste.go b/packages/tui/input/paste.go
new file mode 100644
index 000000000..4e8fe68c9
--- /dev/null
+++ b/packages/tui/input/paste.go
@@ -0,0 +1,13 @@
+package input
+
+// PasteEvent is an message that is emitted when a terminal receives pasted text
+// using bracketed-paste.
+type PasteEvent string
+
+// PasteStartEvent is an message that is emitted when the terminal starts the
+// bracketed-paste text.
+type PasteStartEvent struct{}
+
+// PasteEndEvent is an message that is emitted when the terminal ends the
+// bracketed-paste text.
+type PasteEndEvent struct{}
diff --git a/packages/tui/input/table.go b/packages/tui/input/table.go
new file mode 100644
index 000000000..d2373236b
--- /dev/null
+++ b/packages/tui/input/table.go
@@ -0,0 +1,389 @@
+package input
+
+import (
+ "maps"
+ "strconv"
+
+ "github.com/charmbracelet/x/ansi"
+)
+
+// buildKeysTable builds a table of key sequences and their corresponding key
+// events based on the VT100/VT200, XTerm, and Urxvt terminal specs.
+func buildKeysTable(flags int, term string) map[string]Key {
+ nul := Key{Code: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space
+ if flags&FlagCtrlAt != 0 {
+ nul = Key{Code: '@', Mod: ModCtrl}
+ }
+
+ tab := Key{Code: KeyTab} // ctrl+i or tab
+ if flags&FlagCtrlI != 0 {
+ tab = Key{Code: 'i', Mod: ModCtrl}
+ }
+
+ enter := Key{Code: KeyEnter} // ctrl+m or enter
+ if flags&FlagCtrlM != 0 {
+ enter = Key{Code: 'm', Mod: ModCtrl}
+ }
+
+ esc := Key{Code: KeyEscape} // ctrl+[ or escape
+ if flags&FlagCtrlOpenBracket != 0 {
+ esc = Key{Code: '[', Mod: ModCtrl} // ctrl+[ or escape
+ }
+
+ del := Key{Code: KeyBackspace}
+ if flags&FlagBackspace != 0 {
+ del.Code = KeyDelete
+ }
+
+ find := Key{Code: KeyHome}
+ if flags&FlagFind != 0 {
+ find.Code = KeyFind
+ }
+
+ sel := Key{Code: KeyEnd}
+ if flags&FlagSelect != 0 {
+ sel.Code = KeySelect
+ }
+
+ // The following is a table of key sequences and their corresponding key
+ // events based on the VT100/VT200 terminal specs.
+ //
+ // See: https://vt100.net/docs/vt100-ug/chapter3.html#S3.2
+ // See: https://vt100.net/docs/vt220-rm/chapter3.html
+ //
+ // XXX: These keys may be overwritten by other options like XTerm or
+ // Terminfo.
+ table := map[string]Key{
+ // C0 control characters
+ string(byte(ansi.NUL)): nul,
+ string(byte(ansi.SOH)): {Code: 'a', Mod: ModCtrl},
+ string(byte(ansi.STX)): {Code: 'b', Mod: ModCtrl},
+ string(byte(ansi.ETX)): {Code: 'c', Mod: ModCtrl},
+ string(byte(ansi.EOT)): {Code: 'd', Mod: ModCtrl},
+ string(byte(ansi.ENQ)): {Code: 'e', Mod: ModCtrl},
+ string(byte(ansi.ACK)): {Code: 'f', Mod: ModCtrl},
+ string(byte(ansi.BEL)): {Code: 'g', Mod: ModCtrl},
+ string(byte(ansi.BS)): {Code: 'h', Mod: ModCtrl},
+ string(byte(ansi.HT)): tab,
+ string(byte(ansi.LF)): {Code: 'j', Mod: ModCtrl},
+ string(byte(ansi.VT)): {Code: 'k', Mod: ModCtrl},
+ string(byte(ansi.FF)): {Code: 'l', Mod: ModCtrl},
+ string(byte(ansi.CR)): enter,
+ string(byte(ansi.SO)): {Code: 'n', Mod: ModCtrl},
+ string(byte(ansi.SI)): {Code: 'o', Mod: ModCtrl},
+ string(byte(ansi.DLE)): {Code: 'p', Mod: ModCtrl},
+ string(byte(ansi.DC1)): {Code: 'q', Mod: ModCtrl},
+ string(byte(ansi.DC2)): {Code: 'r', Mod: ModCtrl},
+ string(byte(ansi.DC3)): {Code: 's', Mod: ModCtrl},
+ string(byte(ansi.DC4)): {Code: 't', Mod: ModCtrl},
+ string(byte(ansi.NAK)): {Code: 'u', Mod: ModCtrl},
+ string(byte(ansi.SYN)): {Code: 'v', Mod: ModCtrl},
+ string(byte(ansi.ETB)): {Code: 'w', Mod: ModCtrl},
+ string(byte(ansi.CAN)): {Code: 'x', Mod: ModCtrl},
+ string(byte(ansi.EM)): {Code: 'y', Mod: ModCtrl},
+ string(byte(ansi.SUB)): {Code: 'z', Mod: ModCtrl},
+ string(byte(ansi.ESC)): esc,
+ string(byte(ansi.FS)): {Code: '\\', Mod: ModCtrl},
+ string(byte(ansi.GS)): {Code: ']', Mod: ModCtrl},
+ string(byte(ansi.RS)): {Code: '^', Mod: ModCtrl},
+ string(byte(ansi.US)): {Code: '_', Mod: ModCtrl},
+
+ // Special keys in G0
+ string(byte(ansi.SP)): {Code: KeySpace, Text: " "},
+ string(byte(ansi.DEL)): del,
+
+ // Special keys
+
+ "\x1b[Z": {Code: KeyTab, Mod: ModShift},
+
+ "\x1b[1~": find,
+ "\x1b[2~": {Code: KeyInsert},
+ "\x1b[3~": {Code: KeyDelete},
+ "\x1b[4~": sel,
+ "\x1b[5~": {Code: KeyPgUp},
+ "\x1b[6~": {Code: KeyPgDown},
+ "\x1b[7~": {Code: KeyHome},
+ "\x1b[8~": {Code: KeyEnd},
+
+ // Normal mode
+ "\x1b[A": {Code: KeyUp},
+ "\x1b[B": {Code: KeyDown},
+ "\x1b[C": {Code: KeyRight},
+ "\x1b[D": {Code: KeyLeft},
+ "\x1b[E": {Code: KeyBegin},
+ "\x1b[F": {Code: KeyEnd},
+ "\x1b[H": {Code: KeyHome},
+ "\x1b[P": {Code: KeyF1},
+ "\x1b[Q": {Code: KeyF2},
+ "\x1b[R": {Code: KeyF3},
+ "\x1b[S": {Code: KeyF4},
+
+ // Application Cursor Key Mode (DECCKM)
+ "\x1bOA": {Code: KeyUp},
+ "\x1bOB": {Code: KeyDown},
+ "\x1bOC": {Code: KeyRight},
+ "\x1bOD": {Code: KeyLeft},
+ "\x1bOE": {Code: KeyBegin},
+ "\x1bOF": {Code: KeyEnd},
+ "\x1bOH": {Code: KeyHome},
+ "\x1bOP": {Code: KeyF1},
+ "\x1bOQ": {Code: KeyF2},
+ "\x1bOR": {Code: KeyF3},
+ "\x1bOS": {Code: KeyF4},
+
+ // Keypad Application Mode (DECKPAM)
+
+ "\x1bOM": {Code: KeyKpEnter},
+ "\x1bOX": {Code: KeyKpEqual},
+ "\x1bOj": {Code: KeyKpMultiply},
+ "\x1bOk": {Code: KeyKpPlus},
+ "\x1bOl": {Code: KeyKpComma},
+ "\x1bOm": {Code: KeyKpMinus},
+ "\x1bOn": {Code: KeyKpDecimal},
+ "\x1bOo": {Code: KeyKpDivide},
+ "\x1bOp": {Code: KeyKp0},
+ "\x1bOq": {Code: KeyKp1},
+ "\x1bOr": {Code: KeyKp2},
+ "\x1bOs": {Code: KeyKp3},
+ "\x1bOt": {Code: KeyKp4},
+ "\x1bOu": {Code: KeyKp5},
+ "\x1bOv": {Code: KeyKp6},
+ "\x1bOw": {Code: KeyKp7},
+ "\x1bOx": {Code: KeyKp8},
+ "\x1bOy": {Code: KeyKp9},
+
+ // Function keys
+
+ "\x1b[11~": {Code: KeyF1},
+ "\x1b[12~": {Code: KeyF2},
+ "\x1b[13~": {Code: KeyF3},
+ "\x1b[14~": {Code: KeyF4},
+ "\x1b[15~": {Code: KeyF5},
+ "\x1b[17~": {Code: KeyF6},
+ "\x1b[18~": {Code: KeyF7},
+ "\x1b[19~": {Code: KeyF8},
+ "\x1b[20~": {Code: KeyF9},
+ "\x1b[21~": {Code: KeyF10},
+ "\x1b[23~": {Code: KeyF11},
+ "\x1b[24~": {Code: KeyF12},
+ "\x1b[25~": {Code: KeyF13},
+ "\x1b[26~": {Code: KeyF14},
+ "\x1b[28~": {Code: KeyF15},
+ "\x1b[29~": {Code: KeyF16},
+ "\x1b[31~": {Code: KeyF17},
+ "\x1b[32~": {Code: KeyF18},
+ "\x1b[33~": {Code: KeyF19},
+ "\x1b[34~": {Code: KeyF20},
+ }
+
+ // CSI ~ sequence keys
+ csiTildeKeys := map[string]Key{
+ "1": find, "2": {Code: KeyInsert},
+ "3": {Code: KeyDelete}, "4": sel,
+ "5": {Code: KeyPgUp}, "6": {Code: KeyPgDown},
+ "7": {Code: KeyHome}, "8": {Code: KeyEnd},
+ // There are no 9 and 10 keys
+ "11": {Code: KeyF1}, "12": {Code: KeyF2},
+ "13": {Code: KeyF3}, "14": {Code: KeyF4},
+ "15": {Code: KeyF5}, "17": {Code: KeyF6},
+ "18": {Code: KeyF7}, "19": {Code: KeyF8},
+ "20": {Code: KeyF9}, "21": {Code: KeyF10},
+ "23": {Code: KeyF11}, "24": {Code: KeyF12},
+ "25": {Code: KeyF13}, "26": {Code: KeyF14},
+ "28": {Code: KeyF15}, "29": {Code: KeyF16},
+ "31": {Code: KeyF17}, "32": {Code: KeyF18},
+ "33": {Code: KeyF19}, "34": {Code: KeyF20},
+ }
+
+ // URxvt keys
+ // See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
+ table["\x1b[a"] = Key{Code: KeyUp, Mod: ModShift}
+ table["\x1b[b"] = Key{Code: KeyDown, Mod: ModShift}
+ table["\x1b[c"] = Key{Code: KeyRight, Mod: ModShift}
+ table["\x1b[d"] = Key{Code: KeyLeft, Mod: ModShift}
+ table["\x1bOa"] = Key{Code: KeyUp, Mod: ModCtrl}
+ table["\x1bOb"] = Key{Code: KeyDown, Mod: ModCtrl}
+ table["\x1bOc"] = Key{Code: KeyRight, Mod: ModCtrl}
+ table["\x1bOd"] = Key{Code: KeyLeft, Mod: ModCtrl}
+ //nolint:godox
+ // TODO: invistigate if shift-ctrl arrow keys collide with DECCKM keys i.e.
+ // "\x1bOA", "\x1bOB", "\x1bOC", "\x1bOD"
+
+ // URxvt modifier CSI ~ keys
+ for k, v := range csiTildeKeys {
+ key := v
+ // Normal (no modifier) already defined part of VT100/VT200
+ // Shift modifier
+ key.Mod = ModShift
+ table["\x1b["+k+"$"] = key
+ // Ctrl modifier
+ key.Mod = ModCtrl
+ table["\x1b["+k+"^"] = key
+ // Shift-Ctrl modifier
+ key.Mod = ModShift | ModCtrl
+ table["\x1b["+k+"@"] = key
+ }
+
+ // URxvt F keys
+ // Note: Shift + F1-F10 generates F11-F20.
+ // This means Shift + F1 and Shift + F2 will generate F11 and F12, the same
+ // applies to Ctrl + Shift F1 & F2.
+ //
+ // P.S. Don't like this? Blame URxvt, configure your terminal to use
+ // different escapes like XTerm, or switch to a better terminal ¯\_(ツ)_/¯
+ //
+ // See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes
+ table["\x1b[23$"] = Key{Code: KeyF11, Mod: ModShift}
+ table["\x1b[24$"] = Key{Code: KeyF12, Mod: ModShift}
+ table["\x1b[25$"] = Key{Code: KeyF13, Mod: ModShift}
+ table["\x1b[26$"] = Key{Code: KeyF14, Mod: ModShift}
+ table["\x1b[28$"] = Key{Code: KeyF15, Mod: ModShift}
+ table["\x1b[29$"] = Key{Code: KeyF16, Mod: ModShift}
+ table["\x1b[31$"] = Key{Code: KeyF17, Mod: ModShift}
+ table["\x1b[32$"] = Key{Code: KeyF18, Mod: ModShift}
+ table["\x1b[33$"] = Key{Code: KeyF19, Mod: ModShift}
+ table["\x1b[34$"] = Key{Code: KeyF20, Mod: ModShift}
+ table["\x1b[11^"] = Key{Code: KeyF1, Mod: ModCtrl}
+ table["\x1b[12^"] = Key{Code: KeyF2, Mod: ModCtrl}
+ table["\x1b[13^"] = Key{Code: KeyF3, Mod: ModCtrl}
+ table["\x1b[14^"] = Key{Code: KeyF4, Mod: ModCtrl}
+ table["\x1b[15^"] = Key{Code: KeyF5, Mod: ModCtrl}
+ table["\x1b[17^"] = Key{Code: KeyF6, Mod: ModCtrl}
+ table["\x1b[18^"] = Key{Code: KeyF7, Mod: ModCtrl}
+ table["\x1b[19^"] = Key{Code: KeyF8, Mod: ModCtrl}
+ table["\x1b[20^"] = Key{Code: KeyF9, Mod: ModCtrl}
+ table["\x1b[21^"] = Key{Code: KeyF10, Mod: ModCtrl}
+ table["\x1b[23^"] = Key{Code: KeyF11, Mod: ModCtrl}
+ table["\x1b[24^"] = Key{Code: KeyF12, Mod: ModCtrl}
+ table["\x1b[25^"] = Key{Code: KeyF13, Mod: ModCtrl}
+ table["\x1b[26^"] = Key{Code: KeyF14, Mod: ModCtrl}
+ table["\x1b[28^"] = Key{Code: KeyF15, Mod: ModCtrl}
+ table["\x1b[29^"] = Key{Code: KeyF16, Mod: ModCtrl}
+ table["\x1b[31^"] = Key{Code: KeyF17, Mod: ModCtrl}
+ table["\x1b[32^"] = Key{Code: KeyF18, Mod: ModCtrl}
+ table["\x1b[33^"] = Key{Code: KeyF19, Mod: ModCtrl}
+ table["\x1b[34^"] = Key{Code: KeyF20, Mod: ModCtrl}
+ table["\x1b[23@"] = Key{Code: KeyF11, Mod: ModShift | ModCtrl}
+ table["\x1b[24@"] = Key{Code: KeyF12, Mod: ModShift | ModCtrl}
+ table["\x1b[25@"] = Key{Code: KeyF13, Mod: ModShift | ModCtrl}
+ table["\x1b[26@"] = Key{Code: KeyF14, Mod: ModShift | ModCtrl}
+ table["\x1b[28@"] = Key{Code: KeyF15, Mod: ModShift | ModCtrl}
+ table["\x1b[29@"] = Key{Code: KeyF16, Mod: ModShift | ModCtrl}
+ table["\x1b[31@"] = Key{Code: KeyF17, Mod: ModShift | ModCtrl}
+ table["\x1b[32@"] = Key{Code: KeyF18, Mod: ModShift | ModCtrl}
+ table["\x1b[33@"] = Key{Code: KeyF19, Mod: ModShift | ModCtrl}
+ table["\x1b[34@"] = Key{Code: KeyF20, Mod: ModShift | ModCtrl}
+
+ // Register Alt + <key> combinations
+ // XXX: this must come after URxvt but before XTerm keys to register URxvt
+ // keys with alt modifier
+ tmap := map[string]Key{}
+ for seq, key := range table {
+ key := key
+ key.Mod |= ModAlt
+ key.Text = "" // Clear runes
+ tmap["\x1b"+seq] = key
+ }
+ maps.Copy(table, tmap)
+
+ // XTerm modifiers
+ // These are offset by 1 to be compatible with our Mod type.
+ // See https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-PC-Style-Function-Keys
+ modifiers := []KeyMod{
+ ModShift, // 1
+ ModAlt, // 2
+ ModShift | ModAlt, // 3
+ ModCtrl, // 4
+ ModShift | ModCtrl, // 5
+ ModAlt | ModCtrl, // 6
+ ModShift | ModAlt | ModCtrl, // 7
+ ModMeta, // 8
+ ModMeta | ModShift, // 9
+ ModMeta | ModAlt, // 10
+ ModMeta | ModShift | ModAlt, // 11
+ ModMeta | ModCtrl, // 12
+ ModMeta | ModShift | ModCtrl, // 13
+ ModMeta | ModAlt | ModCtrl, // 14
+ ModMeta | ModShift | ModAlt | ModCtrl, // 15
+ }
+
+ // SS3 keypad function keys
+ ss3FuncKeys := map[string]Key{
+ // These are defined in XTerm
+ // Taken from Foot keymap.h and XTerm modifyOtherKeys
+ // https://codeberg.org/dnkl/foot/src/branch/master/keymap.h
+ "M": {Code: KeyKpEnter}, "X": {Code: KeyKpEqual},
+ "j": {Code: KeyKpMultiply}, "k": {Code: KeyKpPlus},
+ "l": {Code: KeyKpComma}, "m": {Code: KeyKpMinus},
+ "n": {Code: KeyKpDecimal}, "o": {Code: KeyKpDivide},
+ "p": {Code: KeyKp0}, "q": {Code: KeyKp1},
+ "r": {Code: KeyKp2}, "s": {Code: KeyKp3},
+ "t": {Code: KeyKp4}, "u": {Code: KeyKp5},
+ "v": {Code: KeyKp6}, "w": {Code: KeyKp7},
+ "x": {Code: KeyKp8}, "y": {Code: KeyKp9},
+ }
+
+ // XTerm keys
+ csiFuncKeys := map[string]Key{
+ "A": {Code: KeyUp}, "B": {Code: KeyDown},
+ "C": {Code: KeyRight}, "D": {Code: KeyLeft},
+ "E": {Code: KeyBegin}, "F": {Code: KeyEnd},
+ "H": {Code: KeyHome}, "P": {Code: KeyF1},
+ "Q": {Code: KeyF2}, "R": {Code: KeyF3},
+ "S": {Code: KeyF4},
+ }
+
+ // CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
+ modifyOtherKeys := map[int]Key{
+ ansi.BS: {Code: KeyBackspace},
+ ansi.HT: {Code: KeyTab},
+ ansi.CR: {Code: KeyEnter},
+ ansi.ESC: {Code: KeyEscape},
+ ansi.DEL: {Code: KeyBackspace},
+ }
+
+ for _, m := range modifiers {
+ // XTerm modifier offset +1
+ xtermMod := strconv.Itoa(int(m) + 1)
+
+ // CSI 1 ; <modifier> <func>
+ for k, v := range csiFuncKeys {
+ // Functions always have a leading 1 param
+ seq := "\x1b[1;" + xtermMod + k
+ key := v
+ key.Mod = m
+ table[seq] = key
+ }
+ // SS3 <modifier> <func>
+ for k, v := range ss3FuncKeys {
+ seq := "\x1bO" + xtermMod + k
+ key := v
+ key.Mod = m
+ table[seq] = key
+ }
+ // CSI <number> ; <modifier> ~
+ for k, v := range csiTildeKeys {
+ seq := "\x1b[" + k + ";" + xtermMod + "~"
+ key := v
+ key.Mod = m
+ table[seq] = key
+ }
+ // CSI 27 ; <modifier> ; <code> ~
+ for k, v := range modifyOtherKeys {
+ code := strconv.Itoa(k)
+ seq := "\x1b[27;" + xtermMod + ";" + code + "~"
+ key := v
+ key.Mod = m
+ table[seq] = key
+ }
+ }
+
+ // Register terminfo keys
+ // XXX: this might override keys already registered in table
+ if flags&FlagTerminfo != 0 {
+ titable := buildTerminfoKeys(flags, term)
+ maps.Copy(table, titable)
+ }
+
+ return table
+}
diff --git a/packages/tui/input/termcap.go b/packages/tui/input/termcap.go
new file mode 100644
index 000000000..3502189ff
--- /dev/null
+++ b/packages/tui/input/termcap.go
@@ -0,0 +1,54 @@
+package input
+
+import (
+ "bytes"
+ "encoding/hex"
+ "strings"
+)
+
+// CapabilityEvent represents a Termcap/Terminfo response event. Termcap
+// responses are generated by the terminal in response to RequestTermcap
+// (XTGETTCAP) requests.
+//
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
+type CapabilityEvent string
+
+func parseTermcap(data []byte) CapabilityEvent {
+ // XTGETTCAP
+ if len(data) == 0 {
+ return CapabilityEvent("")
+ }
+
+ var tc strings.Builder
+ split := bytes.Split(data, []byte{';'})
+ for _, s := range split {
+ parts := bytes.SplitN(s, []byte{'='}, 2)
+ if len(parts) == 0 {
+ return CapabilityEvent("")
+ }
+
+ name, err := hex.DecodeString(string(parts[0]))
+ if err != nil || len(name) == 0 {
+ continue
+ }
+
+ var value []byte
+ if len(parts) > 1 {
+ value, err = hex.DecodeString(string(parts[1]))
+ if err != nil {
+ continue
+ }
+ }
+
+ if tc.Len() > 0 {
+ tc.WriteByte(';')
+ }
+ tc.WriteString(string(name))
+ if len(value) > 0 {
+ tc.WriteByte('=')
+ tc.WriteString(string(value))
+ }
+ }
+
+ return CapabilityEvent(tc.String())
+}
diff --git a/packages/tui/input/terminfo.go b/packages/tui/input/terminfo.go
new file mode 100644
index 000000000..a54da2c3a
--- /dev/null
+++ b/packages/tui/input/terminfo.go
@@ -0,0 +1,277 @@
+package input
+
+import (
+ "strings"
+
+ "github.com/xo/terminfo"
+)
+
+func buildTerminfoKeys(flags int, term string) map[string]Key {
+ table := make(map[string]Key)
+ ti, _ := terminfo.Load(term)
+ if ti == nil {
+ return table
+ }
+
+ tiTable := defaultTerminfoKeys(flags)
+
+ // Default keys
+ for name, seq := range ti.StringCapsShort() {
+ if !strings.HasPrefix(name, "k") || len(seq) == 0 {
+ continue
+ }
+
+ if k, ok := tiTable[name]; ok {
+ table[string(seq)] = k
+ }
+ }
+
+ // Extended keys
+ for name, seq := range ti.ExtStringCapsShort() {
+ if !strings.HasPrefix(name, "k") || len(seq) == 0 {
+ continue
+ }
+
+ if k, ok := tiTable[name]; ok {
+ table[string(seq)] = k
+ }
+ }
+
+ return table
+}
+
+// This returns a map of terminfo keys to key events. It's a mix of ncurses
+// terminfo default and user-defined key capabilities.
+// Upper-case caps that are defined in the default terminfo database are
+// - kNXT
+// - kPRV
+// - kHOM
+// - kEND
+// - kDC
+// - kIC
+// - kLFT
+// - kRIT
+//
+// See https://man7.org/linux/man-pages/man5/terminfo.5.html
+// See https://github.com/mirror/ncurses/blob/master/include/Caps-ncurses
+func defaultTerminfoKeys(flags int) map[string]Key {
+ keys := map[string]Key{
+ "kcuu1": {Code: KeyUp},
+ "kUP": {Code: KeyUp, Mod: ModShift},
+ "kUP3": {Code: KeyUp, Mod: ModAlt},
+ "kUP4": {Code: KeyUp, Mod: ModShift | ModAlt},
+ "kUP5": {Code: KeyUp, Mod: ModCtrl},
+ "kUP6": {Code: KeyUp, Mod: ModShift | ModCtrl},
+ "kUP7": {Code: KeyUp, Mod: ModAlt | ModCtrl},
+ "kUP8": {Code: KeyUp, Mod: ModShift | ModAlt | ModCtrl},
+ "kcud1": {Code: KeyDown},
+ "kDN": {Code: KeyDown, Mod: ModShift},
+ "kDN3": {Code: KeyDown, Mod: ModAlt},
+ "kDN4": {Code: KeyDown, Mod: ModShift | ModAlt},
+ "kDN5": {Code: KeyDown, Mod: ModCtrl},
+ "kDN7": {Code: KeyDown, Mod: ModAlt | ModCtrl},
+ "kDN6": {Code: KeyDown, Mod: ModShift | ModCtrl},
+ "kDN8": {Code: KeyDown, Mod: ModShift | ModAlt | ModCtrl},
+ "kcub1": {Code: KeyLeft},
+ "kLFT": {Code: KeyLeft, Mod: ModShift},
+ "kLFT3": {Code: KeyLeft, Mod: ModAlt},
+ "kLFT4": {Code: KeyLeft, Mod: ModShift | ModAlt},
+ "kLFT5": {Code: KeyLeft, Mod: ModCtrl},
+ "kLFT6": {Code: KeyLeft, Mod: ModShift | ModCtrl},
+ "kLFT7": {Code: KeyLeft, Mod: ModAlt | ModCtrl},
+ "kLFT8": {Code: KeyLeft, Mod: ModShift | ModAlt | ModCtrl},
+ "kcuf1": {Code: KeyRight},
+ "kRIT": {Code: KeyRight, Mod: ModShift},
+ "kRIT3": {Code: KeyRight, Mod: ModAlt},
+ "kRIT4": {Code: KeyRight, Mod: ModShift | ModAlt},
+ "kRIT5": {Code: KeyRight, Mod: ModCtrl},
+ "kRIT6": {Code: KeyRight, Mod: ModShift | ModCtrl},
+ "kRIT7": {Code: KeyRight, Mod: ModAlt | ModCtrl},
+ "kRIT8": {Code: KeyRight, Mod: ModShift | ModAlt | ModCtrl},
+ "kich1": {Code: KeyInsert},
+ "kIC": {Code: KeyInsert, Mod: ModShift},
+ "kIC3": {Code: KeyInsert, Mod: ModAlt},
+ "kIC4": {Code: KeyInsert, Mod: ModShift | ModAlt},
+ "kIC5": {Code: KeyInsert, Mod: ModCtrl},
+ "kIC6": {Code: KeyInsert, Mod: ModShift | ModCtrl},
+ "kIC7": {Code: KeyInsert, Mod: ModAlt | ModCtrl},
+ "kIC8": {Code: KeyInsert, Mod: ModShift | ModAlt | ModCtrl},
+ "kdch1": {Code: KeyDelete},
+ "kDC": {Code: KeyDelete, Mod: ModShift},
+ "kDC3": {Code: KeyDelete, Mod: ModAlt},
+ "kDC4": {Code: KeyDelete, Mod: ModShift | ModAlt},
+ "kDC5": {Code: KeyDelete, Mod: ModCtrl},
+ "kDC6": {Code: KeyDelete, Mod: ModShift | ModCtrl},
+ "kDC7": {Code: KeyDelete, Mod: ModAlt | ModCtrl},
+ "kDC8": {Code: KeyDelete, Mod: ModShift | ModAlt | ModCtrl},
+ "khome": {Code: KeyHome},
+ "kHOM": {Code: KeyHome, Mod: ModShift},
+ "kHOM3": {Code: KeyHome, Mod: ModAlt},
+ "kHOM4": {Code: KeyHome, Mod: ModShift | ModAlt},
+ "kHOM5": {Code: KeyHome, Mod: ModCtrl},
+ "kHOM6": {Code: KeyHome, Mod: ModShift | ModCtrl},
+ "kHOM7": {Code: KeyHome, Mod: ModAlt | ModCtrl},
+ "kHOM8": {Code: KeyHome, Mod: ModShift | ModAlt | ModCtrl},
+ "kend": {Code: KeyEnd},
+ "kEND": {Code: KeyEnd, Mod: ModShift},
+ "kEND3": {Code: KeyEnd, Mod: ModAlt},
+ "kEND4": {Code: KeyEnd, Mod: ModShift | ModAlt},
+ "kEND5": {Code: KeyEnd, Mod: ModCtrl},
+ "kEND6": {Code: KeyEnd, Mod: ModShift | ModCtrl},
+ "kEND7": {Code: KeyEnd, Mod: ModAlt | ModCtrl},
+ "kEND8": {Code: KeyEnd, Mod: ModShift | ModAlt | ModCtrl},
+ "kpp": {Code: KeyPgUp},
+ "kprv": {Code: KeyPgUp},
+ "kPRV": {Code: KeyPgUp, Mod: ModShift},
+ "kPRV3": {Code: KeyPgUp, Mod: ModAlt},
+ "kPRV4": {Code: KeyPgUp, Mod: ModShift | ModAlt},
+ "kPRV5": {Code: KeyPgUp, Mod: ModCtrl},
+ "kPRV6": {Code: KeyPgUp, Mod: ModShift | ModCtrl},
+ "kPRV7": {Code: KeyPgUp, Mod: ModAlt | ModCtrl},
+ "kPRV8": {Code: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl},
+ "knp": {Code: KeyPgDown},
+ "knxt": {Code: KeyPgDown},
+ "kNXT": {Code: KeyPgDown, Mod: ModShift},
+ "kNXT3": {Code: KeyPgDown, Mod: ModAlt},
+ "kNXT4": {Code: KeyPgDown, Mod: ModShift | ModAlt},
+ "kNXT5": {Code: KeyPgDown, Mod: ModCtrl},
+ "kNXT6": {Code: KeyPgDown, Mod: ModShift | ModCtrl},
+ "kNXT7": {Code: KeyPgDown, Mod: ModAlt | ModCtrl},
+ "kNXT8": {Code: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl},
+
+ "kbs": {Code: KeyBackspace},
+ "kcbt": {Code: KeyTab, Mod: ModShift},
+
+ // Function keys
+ // This only includes the first 12 function keys. The rest are treated
+ // as modifiers of the first 12.
+ // Take a look at XTerm modifyFunctionKeys
+ //
+ // XXX: To use unambiguous function keys, use fixterms or kitty clipboard.
+ //
+ // See https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyFunctionKeys
+ // See https://invisible-island.net/xterm/terminfo.html
+
+ "kf1": {Code: KeyF1},
+ "kf2": {Code: KeyF2},
+ "kf3": {Code: KeyF3},
+ "kf4": {Code: KeyF4},
+ "kf5": {Code: KeyF5},
+ "kf6": {Code: KeyF6},
+ "kf7": {Code: KeyF7},
+ "kf8": {Code: KeyF8},
+ "kf9": {Code: KeyF9},
+ "kf10": {Code: KeyF10},
+ "kf11": {Code: KeyF11},
+ "kf12": {Code: KeyF12},
+ "kf13": {Code: KeyF1, Mod: ModShift},
+ "kf14": {Code: KeyF2, Mod: ModShift},
+ "kf15": {Code: KeyF3, Mod: ModShift},
+ "kf16": {Code: KeyF4, Mod: ModShift},
+ "kf17": {Code: KeyF5, Mod: ModShift},
+ "kf18": {Code: KeyF6, Mod: ModShift},
+ "kf19": {Code: KeyF7, Mod: ModShift},
+ "kf20": {Code: KeyF8, Mod: ModShift},
+ "kf21": {Code: KeyF9, Mod: ModShift},
+ "kf22": {Code: KeyF10, Mod: ModShift},
+ "kf23": {Code: KeyF11, Mod: ModShift},
+ "kf24": {Code: KeyF12, Mod: ModShift},
+ "kf25": {Code: KeyF1, Mod: ModCtrl},
+ "kf26": {Code: KeyF2, Mod: ModCtrl},
+ "kf27": {Code: KeyF3, Mod: ModCtrl},
+ "kf28": {Code: KeyF4, Mod: ModCtrl},
+ "kf29": {Code: KeyF5, Mod: ModCtrl},
+ "kf30": {Code: KeyF6, Mod: ModCtrl},
+ "kf31": {Code: KeyF7, Mod: ModCtrl},
+ "kf32": {Code: KeyF8, Mod: ModCtrl},
+ "kf33": {Code: KeyF9, Mod: ModCtrl},
+ "kf34": {Code: KeyF10, Mod: ModCtrl},
+ "kf35": {Code: KeyF11, Mod: ModCtrl},
+ "kf36": {Code: KeyF12, Mod: ModCtrl},
+ "kf37": {Code: KeyF1, Mod: ModShift | ModCtrl},
+ "kf38": {Code: KeyF2, Mod: ModShift | ModCtrl},
+ "kf39": {Code: KeyF3, Mod: ModShift | ModCtrl},
+ "kf40": {Code: KeyF4, Mod: ModShift | ModCtrl},
+ "kf41": {Code: KeyF5, Mod: ModShift | ModCtrl},
+ "kf42": {Code: KeyF6, Mod: ModShift | ModCtrl},
+ "kf43": {Code: KeyF7, Mod: ModShift | ModCtrl},
+ "kf44": {Code: KeyF8, Mod: ModShift | ModCtrl},
+ "kf45": {Code: KeyF9, Mod: ModShift | ModCtrl},
+ "kf46": {Code: KeyF10, Mod: ModShift | ModCtrl},
+ "kf47": {Code: KeyF11, Mod: ModShift | ModCtrl},
+ "kf48": {Code: KeyF12, Mod: ModShift | ModCtrl},
+ "kf49": {Code: KeyF1, Mod: ModAlt},
+ "kf50": {Code: KeyF2, Mod: ModAlt},
+ "kf51": {Code: KeyF3, Mod: ModAlt},
+ "kf52": {Code: KeyF4, Mod: ModAlt},
+ "kf53": {Code: KeyF5, Mod: ModAlt},
+ "kf54": {Code: KeyF6, Mod: ModAlt},
+ "kf55": {Code: KeyF7, Mod: ModAlt},
+ "kf56": {Code: KeyF8, Mod: ModAlt},
+ "kf57": {Code: KeyF9, Mod: ModAlt},
+ "kf58": {Code: KeyF10, Mod: ModAlt},
+ "kf59": {Code: KeyF11, Mod: ModAlt},
+ "kf60": {Code: KeyF12, Mod: ModAlt},
+ "kf61": {Code: KeyF1, Mod: ModShift | ModAlt},
+ "kf62": {Code: KeyF2, Mod: ModShift | ModAlt},
+ "kf63": {Code: KeyF3, Mod: ModShift | ModAlt},
+ }
+
+ // Preserve F keys from F13 to F63 instead of using them for F-keys
+ // modifiers.
+ if flags&FlagFKeys != 0 {
+ keys["kf13"] = Key{Code: KeyF13}
+ keys["kf14"] = Key{Code: KeyF14}
+ keys["kf15"] = Key{Code: KeyF15}
+ keys["kf16"] = Key{Code: KeyF16}
+ keys["kf17"] = Key{Code: KeyF17}
+ keys["kf18"] = Key{Code: KeyF18}
+ keys["kf19"] = Key{Code: KeyF19}
+ keys["kf20"] = Key{Code: KeyF20}
+ keys["kf21"] = Key{Code: KeyF21}
+ keys["kf22"] = Key{Code: KeyF22}
+ keys["kf23"] = Key{Code: KeyF23}
+ keys["kf24"] = Key{Code: KeyF24}
+ keys["kf25"] = Key{Code: KeyF25}
+ keys["kf26"] = Key{Code: KeyF26}
+ keys["kf27"] = Key{Code: KeyF27}
+ keys["kf28"] = Key{Code: KeyF28}
+ keys["kf29"] = Key{Code: KeyF29}
+ keys["kf30"] = Key{Code: KeyF30}
+ keys["kf31"] = Key{Code: KeyF31}
+ keys["kf32"] = Key{Code: KeyF32}
+ keys["kf33"] = Key{Code: KeyF33}
+ keys["kf34"] = Key{Code: KeyF34}
+ keys["kf35"] = Key{Code: KeyF35}
+ keys["kf36"] = Key{Code: KeyF36}
+ keys["kf37"] = Key{Code: KeyF37}
+ keys["kf38"] = Key{Code: KeyF38}
+ keys["kf39"] = Key{Code: KeyF39}
+ keys["kf40"] = Key{Code: KeyF40}
+ keys["kf41"] = Key{Code: KeyF41}
+ keys["kf42"] = Key{Code: KeyF42}
+ keys["kf43"] = Key{Code: KeyF43}
+ keys["kf44"] = Key{Code: KeyF44}
+ keys["kf45"] = Key{Code: KeyF45}
+ keys["kf46"] = Key{Code: KeyF46}
+ keys["kf47"] = Key{Code: KeyF47}
+ keys["kf48"] = Key{Code: KeyF48}
+ keys["kf49"] = Key{Code: KeyF49}
+ keys["kf50"] = Key{Code: KeyF50}
+ keys["kf51"] = Key{Code: KeyF51}
+ keys["kf52"] = Key{Code: KeyF52}
+ keys["kf53"] = Key{Code: KeyF53}
+ keys["kf54"] = Key{Code: KeyF54}
+ keys["kf55"] = Key{Code: KeyF55}
+ keys["kf56"] = Key{Code: KeyF56}
+ keys["kf57"] = Key{Code: KeyF57}
+ keys["kf58"] = Key{Code: KeyF58}
+ keys["kf59"] = Key{Code: KeyF59}
+ keys["kf60"] = Key{Code: KeyF60}
+ keys["kf61"] = Key{Code: KeyF61}
+ keys["kf62"] = Key{Code: KeyF62}
+ keys["kf63"] = Key{Code: KeyF63}
+ }
+
+ return keys
+}
diff --git a/packages/tui/input/xterm.go b/packages/tui/input/xterm.go
new file mode 100644
index 000000000..b3bbc3083
--- /dev/null
+++ b/packages/tui/input/xterm.go
@@ -0,0 +1,47 @@
+package input
+
+import (
+ "github.com/charmbracelet/x/ansi"
+)
+
+func parseXTermModifyOtherKeys(params ansi.Params) Event {
+ // XTerm modify other keys starts with ESC [ 27 ; <modifier> ; <code> ~
+ xmod, _, _ := params.Param(1, 1)
+ xrune, _, _ := params.Param(2, 1)
+ mod := KeyMod(xmod - 1)
+ r := rune(xrune)
+
+ switch r {
+ case ansi.BS:
+ return KeyPressEvent{Mod: mod, Code: KeyBackspace}
+ case ansi.HT:
+ return KeyPressEvent{Mod: mod, Code: KeyTab}
+ case ansi.CR:
+ return KeyPressEvent{Mod: mod, Code: KeyEnter}
+ case ansi.ESC:
+ return KeyPressEvent{Mod: mod, Code: KeyEscape}
+ case ansi.DEL:
+ return KeyPressEvent{Mod: mod, Code: KeyBackspace}
+ }
+
+ // CSI 27 ; <modifier> ; <code> ~ keys defined in XTerm modifyOtherKeys
+ k := KeyPressEvent{Code: r, Mod: mod}
+ if k.Mod <= ModShift {
+ k.Text = string(r)
+ }
+
+ return k
+}
+
+// TerminalVersionEvent is a message that represents the terminal version.
+type TerminalVersionEvent string
+
+// ModifyOtherKeysEvent represents a modifyOtherKeys event.
+//
+// 0: disable
+// 1: enable mode 1
+// 2: enable mode 2
+//
+// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_
+// See: https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyOtherKeys
+type ModifyOtherKeysEvent uint8
diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go
index ce6e72ff8..512107ffe 100644
--- a/packages/tui/internal/tui/tui.go
+++ b/packages/tui/internal/tui/tui.go
@@ -11,6 +11,7 @@ import (
"github.com/charmbracelet/bubbles/v2/key"
tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
+ "github.com/charmbracelet/x/input"
"github.com/sst/opencode-sdk-go"
"github.com/sst/opencode/internal/app"
@@ -74,7 +75,6 @@ type appModel struct {
toastManager *toast.ToastManager
interruptKeyState InterruptKeyState
exitKeyState ExitKeyState
- lastScroll time.Time
messagesRight bool
fileViewer fileviewer.Model
lastMouse tea.Mouse
@@ -107,44 +107,6 @@ func (a appModel) Init() tea.Cmd {
return tea.Batch(cmds...)
}
-var BUGGED_SCROLL_KEYS = map[string]bool{
- "0": true,
- "1": true,
- "2": true,
- "3": true,
- "4": true,
- "5": true,
- "6": true,
- "7": true,
- "8": true,
- "9": true,
- "M": true,
- "m": true,
- "[": true,
- ";": true,
- "<": true,
-}
-
-func isScrollRelatedInput(keyString string) bool {
- if len(keyString) == 0 {
- return false
- }
-
- for _, char := range keyString {
- charStr := string(char)
- if !BUGGED_SCROLL_KEYS[charStr] {
- return false
- }
- }
-
- if len(keyString) > 3 &&
- (keyString[len(keyString)-1] == 'M' || keyString[len(keyString)-1] == 'm') {
- return true
- }
-
- return len(keyString) > 1
-}
-
func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
var cmds []tea.Cmd
@@ -153,10 +115,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyPressMsg:
keyString := msg.String()
- if time.Since(a.lastScroll) < time.Millisecond*100 && (BUGGED_SCROLL_KEYS[keyString] || isScrollRelatedInput(keyString)) {
- return a, nil
- }
-
// 1. Handle active modal
if a.modal != nil {
switch keyString {
@@ -326,7 +284,6 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor = updatedEditor.(chat.EditorComponent)
return a, cmd
case tea.MouseWheelMsg:
- a.lastScroll = time.Now()
if a.modal != nil {
return a, nil
}
@@ -552,6 +509,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editor.SetExitKeyInDebounce(false)
case dialog.FindSelectedMsg:
return a.openFile(msg.FilePath)
+ case input.UnknownEvent:
+ return a, nil
}
s, cmd := a.status.Update(msg)