diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fabbf102f3..7f82f8c43a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,16 +31,19 @@ jobs: run: go test ./... - name: Build examples + if: ${{ matrix.go-version != '~1.18' }} run: | go mod tidy go build -v ./... working-directory: ./examples - name: Test examples + if: ${{ matrix.go-version != '~1.18' }} run: go test -v ./... working-directory: ./examples - name: Build tutorials + if: ${{ matrix.go-version != '~1.18' }} run: | go mod tidy go build -v ./... diff --git a/inputreader_other.go b/cancelreader_other.go similarity index 66% rename from inputreader_other.go rename to cancelreader_other.go index 8e63a87dc6..5c6e277cb9 100644 --- a/inputreader_other.go +++ b/cancelreader_other.go @@ -9,6 +9,6 @@ import ( "github.com/muesli/cancelreader" ) -func newInputReader(r io.Reader) (cancelreader.CancelReader, error) { +func newCancelreader(r io.Reader) (cancelreader.CancelReader, error) { return cancelreader.NewReader(r) } diff --git a/cancelreader_windows.go b/cancelreader_windows.go new file mode 100644 index 0000000000..15bc3f6254 --- /dev/null +++ b/cancelreader_windows.go @@ -0,0 +1,217 @@ +//go:build windows +// +build windows + +package tea + +import ( + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/erikgeiser/coninput" + "github.com/muesli/cancelreader" + "golang.org/x/sys/windows" +) + +type conInputReader struct { + cancelMixin + + conin windows.Handle + cancelEvent windows.Handle + + originalMode uint32 + + // blockingReadSignal is used to signal that a blocking read is in progress. + blockingReadSignal chan struct{} +} + +var _ cancelreader.CancelReader = &conInputReader{} + +func newCancelreader(r io.Reader) (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 := coninput.NewStdinHandle() + if err != nil { + return fallback(r) + } + + originalMode, err := prepareConsole(conin, + windows.ENABLE_MOUSE_INPUT, + windows.ENABLE_WINDOW_INPUT, + windows.ENABLE_EXTENDED_FLAGS, + ) + if err != nil { + return nil, fmt.Errorf("failed to prepare console input: %w", err) + } + + cancelEvent, err := windows.CreateEvent(nil, 0, 0, nil) + if err != nil { + return nil, fmt.Errorf("create stop event: %w", err) + } + + return &conInputReader{ + conin: conin, + cancelEvent: cancelEvent, + originalMode: originalMode, + blockingReadSignal: make(chan struct{}, 1), + }, nil +} + +// Cancel implements cancelreader.CancelReader. +func (r *conInputReader) Cancel() bool { + r.setCanceled() + + select { + case r.blockingReadSignal <- struct{}{}: + err := windows.SetEvent(r.cancelEvent) + if err != nil { + return false + } + <-r.blockingReadSignal + case <-time.After(100 * time.Millisecond): + // Read() hangs in a GetOverlappedResult which is likely due to + // WaitForMultipleObjects returning without input being available + // so we cannot cancel this ongoing read. + return false + } + + return true +} + +// Close implements cancelreader.CancelReader. +func (r *conInputReader) Close() error { + err := windows.CloseHandle(r.cancelEvent) + if err != nil { + return fmt.Errorf("closing cancel event handle: %w", err) + } + + 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) (n int, err error) { + if r.isCanceled() { + return 0, cancelreader.ErrCanceled + } + + err = waitForInput(r.conin, r.cancelEvent) + if err != nil { + return 0, err + } + + if r.isCanceled() { + return 0, cancelreader.ErrCanceled + } + + r.blockingReadSignal <- struct{}{} + n, err = overlappedReader(r.conin).Read(data) + <-r.blockingReadSignal + + return +} + +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) + } + + newMode := coninput.AddInputModes(0, modes...) + + err = windows.SetConsoleMode(input, newMode) + if err != nil { + return 0, fmt.Errorf("set console mode: %w", err) + } + + return originalMode, nil +} + +func waitForInput(conin, cancel windows.Handle) error { + event, err := windows.WaitForMultipleObjects([]windows.Handle{conin, cancel}, false, windows.INFINITE) + switch { + case windows.WAIT_OBJECT_0 <= event && event < windows.WAIT_OBJECT_0+2: + if event == windows.WAIT_OBJECT_0+1 { + return cancelreader.ErrCanceled + } + + if event == windows.WAIT_OBJECT_0 { + return nil + } + + return fmt.Errorf("unexpected wait object is ready: %d", event-windows.WAIT_OBJECT_0) + case windows.WAIT_ABANDONED <= event && event < windows.WAIT_ABANDONED+2: + return fmt.Errorf("abandoned") + case event == uint32(windows.WAIT_TIMEOUT): + return fmt.Errorf("timeout") + case event == windows.WAIT_FAILED: + return fmt.Errorf("failed") + default: + return fmt.Errorf("unexpected error: %w", err) + } +} + +// 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 +} + +type overlappedReader windows.Handle + +// Read performs an overlapping read fom a windows.Handle. +func (r overlappedReader) Read(data []byte) (int, error) { + hevent, err := windows.CreateEvent(nil, 0, 0, nil) + if err != nil { + return 0, fmt.Errorf("create event: %w", err) + } + + overlapped := windows.Overlapped{HEvent: hevent} + + var n uint32 + + err = windows.ReadFile(windows.Handle(r), data, &n, &overlapped) + if err != nil && err != windows.ERROR_IO_PENDING { + return int(n), err + } + + err = windows.GetOverlappedResult(windows.Handle(r), &overlapped, &n, true) + if err != nil { + return int(n), nil + } + + return int(n), nil +} diff --git a/clipboard.go b/clipboard.go new file mode 100644 index 0000000000..9963b34b5e --- /dev/null +++ b/clipboard.go @@ -0,0 +1,11 @@ +package tea + +// ClipboardMsg is a clipboard read message event. +// This message is emitted when a terminal receives an OSC52 clipboard read +// message event. +type ClipboardMsg string + +// String returns the string representation of the clipboard message. +func (e ClipboardMsg) String() string { + return string(e) +} diff --git a/color.go b/color.go new file mode 100644 index 0000000000..0b49185205 --- /dev/null +++ b/color.go @@ -0,0 +1,82 @@ +package tea + +import ( + "fmt" + "image/color" + "strconv" + "strings" +) + +// ForegroundColorMsg represents a foreground color message. +// This message is emitted when the program requests the terminal foreground +// color. +type ForegroundColorMsg struct{ color.Color } + +// String returns the hex representation of the color. +func (e ForegroundColorMsg) String() string { + return colorToHex(e) +} + +// BackgroundColorMsg represents a background color message. +// This message is emitted when the program requests the terminal background +// color. +type BackgroundColorMsg struct{ color.Color } + +// String returns the hex representation of the color. +func (e BackgroundColorMsg) String() string { + return colorToHex(e) +} + +// CursorColorMsg represents a cursor color change message. +// This message is emitted when the program requests the terminal cursor color. +type CursorColorMsg struct{ color.Color } + +// String returns the hex representation of the color. +func (e CursorColorMsg) String() string { + return colorToHex(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 { + r, g, b, _ := c.RGBA() + return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b)) +} + +func xParseColor(s string) color.Color { + switch { + case strings.HasPrefix(s, "rgb:"): + parts := strings.Split(s[4:], "/") + if len(parts) != 3 { + return color.Black + } + + r, _ := strconv.ParseUint(parts[0], 16, 32) + g, _ := strconv.ParseUint(parts[1], 16, 32) + b, _ := strconv.ParseUint(parts[2], 16, 32) + + return color.RGBA{uint8(shift(r)), uint8(shift(g)), uint8(shift(b)), 255} + case strings.HasPrefix(s, "rgba:"): + parts := strings.Split(s[5:], "/") + if len(parts) != 4 { + return color.Black + } + + r, _ := strconv.ParseUint(parts[0], 16, 32) + g, _ := strconv.ParseUint(parts[1], 16, 32) + b, _ := strconv.ParseUint(parts[2], 16, 32) + a, _ := strconv.ParseUint(parts[3], 16, 32) + + return color.RGBA{uint8(shift(r)), uint8(shift(g)), uint8(shift(b)), uint8(shift(a))} + } + return color.Black +} diff --git a/cursor.go b/cursor.go new file mode 100644 index 0000000000..17a580f327 --- /dev/null +++ b/cursor.go @@ -0,0 +1,10 @@ +package tea + +// CursorPositionMsg is a message that represents the terminal cursor position. +type CursorPositionMsg struct { + // Row is the row number. + Row int + + // Column is the column number. + Column int +} diff --git a/da1.go b/da1.go new file mode 100644 index 0000000000..dc42ee617f --- /dev/null +++ b/da1.go @@ -0,0 +1,19 @@ +package tea + +import "github.com/charmbracelet/x/ansi" + +// PrimaryDeviceAttributesMsg is a message that represents the terminal primary +// device attributes. +type PrimaryDeviceAttributesMsg []uint + +func parsePrimaryDevAttrs(csi *ansi.CsiSequence) Msg { + // Primary Device Attributes + da1 := make(PrimaryDeviceAttributesMsg, len(csi.Params)) + csi.Range(func(i int, p int, hasMore bool) bool { + if !hasMore { + da1[i] = uint(p) + } + return true + }) + return da1 +} diff --git a/driver.go b/driver.go new file mode 100644 index 0000000000..b31a7c2d7e --- /dev/null +++ b/driver.go @@ -0,0 +1,129 @@ +package tea + +import ( + "bytes" + "io" + "unicode/utf8" + + "github.com/erikgeiser/coninput" + "github.com/muesli/cancelreader" +) + +// driver represents an ANSI terminal input driver. +// It reads input events and parses ANSI sequences from the terminal input +// buffer. +type driver 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? + + // prevMouseState keeps track of the previous mouse state to determine mouse + // up button events. + prevMouseState coninput.ButtonState // nolint: unused + + // lastWinsizeEvent keeps track of the last window size event to prevent + // multiple size events from firing. + lastWinsizeEvent coninput.WindowBufferSizeEventRecord // nolint: unused + + flags int // control the behavior of the driver. +} + +// newDriver returns a new ANSI input driver. +// This driver uses ANSI control codes compatible with VT100/VT200 terminals, +// and XTerm. It supports reading Terminfo databases to overwrite the default +// key sequences. +func newDriver(r io.Reader, term string, flags int) (*driver, error) { + d := new(driver) + cr, err := newCancelreader(r) + if err != nil { + return nil, err + } + + d.rd = cr + d.table = buildKeysTable(flags, term) + d.term = term + d.flags = flags + return d, nil +} + +// Cancel cancels the underlying reader. +func (d *driver) Cancel() bool { + return d.rd.Cancel() +} + +// Close closes the underlying reader. +func (d *driver) Close() error { + return d.rd.Close() +} + +func (d *driver) readEvents() (msgs []Msg, err error) { + nb, err := d.rd.Read(d.buf[:]) + if err != nil { + return nil, err + } + + buf := d.buf[:nb] + + // Lookup table first + if bytes.HasPrefix(buf, []byte{'\x1b'}) { + if k, ok := d.table[string(buf)]; ok { + msgs = append(msgs, KeyPressMsg(k)) + return + } + } + + var i int + for i < len(buf) { + nb, ev := parseSequence(buf[i:]) + + // Handle bracketed-paste + if d.paste != nil { + if _, ok := ev.(PasteEndMsg); !ok { + d.paste = append(d.paste, buf[i]) + i++ + continue + } + } + + switch ev.(type) { + case UnknownMsg: + // 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 = KeyPressMsg(k) + } + case PasteStartMsg: + d.paste = []byte{} + case PasteEndMsg: + // 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 + msgs = append(msgs, PasteMsg(paste)) + case nil: + i++ + continue + } + + if mevs, ok := ev.(multiMsg); ok { + msgs = append(msgs, []Msg(mevs)...) + } else { + msgs = append(msgs, ev) + } + i += nb + } + + return +} diff --git a/driver_other.go b/driver_other.go new file mode 100644 index 0000000000..8e67ec1e4f --- /dev/null +++ b/driver_other.go @@ -0,0 +1,11 @@ +//go:build !windows +// +build !windows + +package tea + +// ReadEvents reads input events from the terminal. +// +// It reads the events available in the input buffer and returns them. +func (d *driver) ReadEvents() ([]Msg, error) { + return d.readEvents() +} diff --git a/driver_test.go b/driver_test.go new file mode 100644 index 0000000000..214d536510 --- /dev/null +++ b/driver_test.go @@ -0,0 +1,25 @@ +package tea + +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 := newDriver(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/driver_windows.go b/driver_windows.go new file mode 100644 index 0000000000..0f257edcff --- /dev/null +++ b/driver_windows.go @@ -0,0 +1,272 @@ +//go:build windows +// +build windows + +package tea + +import ( + "errors" + "fmt" + "unicode/utf16" + + "github.com/charmbracelet/x/ansi" + "github.com/erikgeiser/coninput" + "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 *driver) ReadEvents() ([]Msg, error) { + events, err := d.handleConInput(coninput.ReadConsoleInput) + if errors.Is(err, errNotConInputReader) { + return d.readEvents() + } + return events, err +} + +var errNotConInputReader = fmt.Errorf("handleConInput: not a conInputReader") + +func (d *driver) handleConInput( + finput func(windows.Handle, []coninput.InputRecord) (uint32, error), +) ([]Msg, error) { + cc, ok := d.rd.(*conInputReader) + if !ok { + return nil, errNotConInputReader + } + + // read up to 256 events, this is to allow for sequences events reported as + // key events. + var events [256]coninput.InputRecord + _, err := finput(cc.conin, events[:]) + if err != nil { + return nil, fmt.Errorf("read coninput events: %w", err) + } + + var evs []Msg + for _, event := range events { + if e := parseConInputEvent(event, &d.prevMouseState, &d.lastWinsizeEvent); e != nil { + evs = append(evs, e) + } + } + + return d.detectConInputQuerySequences(evs), nil +} + +// Using ConInput API, Windows Terminal responds to sequence query events with +// KEY_EVENT_RECORDs so we need to collect them and parse them as a single +// sequence. +// Is this a hack? +func (d *driver) detectConInputQuerySequences(events []Msg) []Msg { + var newEvents []Msg + start, end := -1, -1 + +loop: + for i, e := range events { + switch e := e.(type) { + case KeyPressMsg: + switch e.Rune() { + case ansi.ESC, ansi.CSI, ansi.OSC, ansi.DCS, ansi.APC: + // start of a sequence + if start == -1 { + start = i + } + } + default: + break loop + } + end = i + } + + if start == -1 || end <= start { + return events + } + + var seq []byte + for i := start; i <= end; i++ { + switch e := events[i].(type) { + case KeyPressMsg: + seq = append(seq, byte(e.Rune())) + } + } + + n, seqevent := parseSequence(seq) + switch seqevent.(type) { + case UnknownMsg: + // We're not interested in unknown events + default: + if start+n > len(events) { + return events + } + newEvents = events[:start] + newEvents = append(newEvents, seqevent) + newEvents = append(newEvents, events[start+n:]...) + return d.detectConInputQuerySequences(newEvents) + } + + return events +} + +func parseConInputEvent(event coninput.InputRecord, ps *coninput.ButtonState, ws *coninput.WindowBufferSizeEventRecord) Msg { + switch e := event.Unwrap().(type) { + case coninput.KeyEventRecord: + event := parseWin32InputKeyEvent(e.VirtualKeyCode, e.VirtualScanCode, + e.Char, e.KeyDown, e.ControlKeyState, e.RepeatCount) + + var key Key + switch event := event.(type) { + case KeyPressMsg: + key = Key(event) + case KeyReleaseMsg: + key = Key(event) + default: + return nil + } + + // If the key is not printable, return the event as is + // (e.g. function keys, arrows, etc.) + // Otherwise, try to translate it to a rune based on the active keyboard + // layout. + if len(key.Runes) == 0 { + return event + } + + // Always use US layout for translation + // This is to follow the behavior of the Kitty Keyboard base layout + // feature :eye_roll: + // https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/windows-language-pack-default-values?view=windows-11 + const usLayout = 0x409 + + // Translate key to rune + var keyState [256]byte + var utf16Buf [16]uint16 + const dontChangeKernelKeyboardLayout = 0x4 + ret := windows.ToUnicodeEx( + uint32(e.VirtualKeyCode), + uint32(e.VirtualScanCode), + &keyState[0], + &utf16Buf[0], + int32(len(utf16Buf)), + dontChangeKernelKeyboardLayout, + usLayout, + ) + + // -1 indicates a dead key + // 0 indicates no translation for this key + if ret < 1 { + return event + } + + runes := utf16.Decode(utf16Buf[:ret]) + if len(runes) != 1 { + // Key doesn't translate to a single rune + return event + } + + key.baseRune = runes[0] + if e.KeyDown { + return KeyPressMsg(key) + } + + return KeyReleaseMsg(key) + + case coninput.WindowBufferSizeEventRecord: + if e != *ws { + *ws = e + return WindowSizeMsg{ + Width: int(e.Size.X), + Height: int(e.Size.Y), + } + } + case coninput.MouseEventRecord: + mevent := mouseEvent(*ps, e) + *ps = e.ButtonState + return mevent + case coninput.FocusEventRecord, coninput.MenuEventRecord: + // ignore + } + return nil +} + +func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, isRelease bool) { + btn := p ^ s + if btn&s == 0 { + isRelease = true + } + + if btn == 0 { + switch { + case s&coninput.FROM_LEFT_1ST_BUTTON_PRESSED > 0: + button = MouseLeft + case s&coninput.FROM_LEFT_2ND_BUTTON_PRESSED > 0: + button = MouseMiddle + case s&coninput.RIGHTMOST_BUTTON_PRESSED > 0: + button = MouseRight + case s&coninput.FROM_LEFT_3RD_BUTTON_PRESSED > 0: + button = MouseBackward + case s&coninput.FROM_LEFT_4TH_BUTTON_PRESSED > 0: + button = MouseForward + } + return + } + + switch btn { + case coninput.FROM_LEFT_1ST_BUTTON_PRESSED: // left button + button = MouseLeft + case coninput.RIGHTMOST_BUTTON_PRESSED: // right button + button = MouseRight + case coninput.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button + button = MouseMiddle + case coninput.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward) + button = MouseBackward + case coninput.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward) + button = MouseForward + } + + return +} + +func mouseEvent(p coninput.ButtonState, e coninput.MouseEventRecord) (ev Msg) { + var mod KeyMod + var isRelease bool + if e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED) { + mod |= ModAlt + } + if e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED) { + mod |= ModCtrl + } + if e.ControlKeyState.Contains(coninput.SHIFT_PRESSED) { + mod |= ModShift + } + m := Mouse{ + X: int(e.MousePositon.X), + Y: int(e.MousePositon.Y), + Mod: mod, + } + switch e.EventFlags { + case coninput.CLICK, coninput.DOUBLE_CLICK: + m.Button, isRelease = mouseEventButton(p, e.ButtonState) + case coninput.MOUSE_WHEELED: + if e.WheelDirection > 0 { + m.Button = MouseWheelUp + } else { + m.Button = MouseWheelDown + } + case coninput.MOUSE_HWHEELED: + if e.WheelDirection > 0 { + m.Button = MouseWheelRight + } else { + m.Button = MouseWheelLeft + } + case coninput.MOUSE_MOVED: + m.Button, _ = mouseEventButton(p, e.ButtonState) + return MouseMotionMsg(m) + } + + if isWheel(m.Button) { + return MouseWheelMsg(m) + } else if isRelease { + return MouseReleaseMsg(m) + } + + return MouseClickMsg(m) +} diff --git a/examples/go.mod b/examples/go.mod index b33f5c9bda..40598a5479 100644 --- a/examples/go.mod +++ b/examples/go.mod @@ -1,6 +1,8 @@ module examples -go 1.18 +go 1.21 + +toolchain go1.22.5 require ( github.com/charmbracelet/bubbles v0.18.0 @@ -29,7 +31,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect @@ -43,9 +44,8 @@ require ( github.com/yuin/goldmark-emoji v1.0.3 // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.23.0 // indirect + golang.org/x/sys v0.24.0 // indirect golang.org/x/term v0.22.0 // indirect - golang.org/x/text v0.16.0 // indirect ) replace github.com/charmbracelet/bubbletea => ../ diff --git a/examples/go.sum b/examples/go.sum index 0ab3f68368..75ebdfe9b3 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -1,7 +1,9 @@ github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= +github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= +github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= @@ -41,13 +43,13 @@ github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOf github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -75,15 +77,14 @@ github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRla github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4= github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= -golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= diff --git a/examples/query-term/main.go b/examples/query-term/main.go new file mode 100644 index 0000000000..b42f36be95 --- /dev/null +++ b/examples/query-term/main.go @@ -0,0 +1,97 @@ +// This example uses a textinput to send the terminal ANSI sequences to query +// it for capabilities. +package main + +import ( + "fmt" + "io" + "log" + "os" + "strconv" + "strings" + "unicode" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +func newModel() model { + ti := textinput.NewModel() + ti.Focus() + ti.CharLimit = 156 + ti.Width = 20 + return model{input: ti} +} + +type model struct { + input textinput.Model + err error +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyPressMsg: + m.err = nil + switch msg.String() { + case "ctrl+c": + return m, tea.Quit + case "enter": + // Write the sequence to the terminal + val := m.input.Value() + val = "\"" + val + "\"" + + // unescape the sequence + seq, err := strconv.Unquote(val) + if err != nil { + m.err = err + return m, nil + } + + if !strings.HasPrefix(seq, "\x1b") { + m.err = fmt.Errorf("sequence is not an ANSI escape sequence") + return m, nil + } + + // write the sequence to the terminal + return m, func() tea.Msg { + io.WriteString(os.Stdout, seq) + return nil + } + } + default: + typ := strings.TrimPrefix(fmt.Sprintf("%T", msg), "tea.") + if len(typ) > 0 && unicode.IsUpper(rune(typ[0])) { + // Only log messages that are exported types + cmds = append(cmds, tea.Printf("Received message: %T\n", msg)) + } + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +func (m model) View() string { + var s strings.Builder + s.WriteString(m.input.View()) + if m.err != nil { + s.WriteString("\n\nError: " + m.err.Error()) + } + s.WriteString("\n\nPress ctrl+c to quit, enter to write the sequence to terminal") + return s.String() +} + +func main() { + p := tea.NewProgram(newModel()) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/focus.go b/focus.go new file mode 100644 index 0000000000..4d34bea6f8 --- /dev/null +++ b/focus.go @@ -0,0 +1,9 @@ +package tea + +// FocusMsg represents a terminal focus message. +// This occurs when the terminal gains focus. +type FocusMsg struct{} + +// BlurMsg represents a terminal blur message. +// This occurs when the terminal loses focus. +type BlurMsg struct{} diff --git a/focus_test.go b/focus_test.go new file mode 100644 index 0000000000..2871c3af1d --- /dev/null +++ b/focus_test.go @@ -0,0 +1,25 @@ +package tea + +import ( + "testing" +) + +func TestFocus(t *testing.T) { + _, e := parseSequence([]byte("\x1b[I")) + switch e.(type) { + case FocusMsg: + // ok + default: + t.Error("invalid sequence") + } +} + +func TestBlur(t *testing.T) { + _, e := parseSequence([]byte("\x1b[O")) + switch e.(type) { + case BlurMsg: + // ok + default: + t.Error("invalid sequence") + } +} diff --git a/go.mod b/go.mod index 837102c6a9..34bcfa4f9a 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,9 @@ require ( github.com/charmbracelet/x/ansi v0.1.4 github.com/charmbracelet/x/term v0.1.1 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f - github.com/mattn/go-localereader v0.0.1 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 github.com/muesli/cancelreader v0.2.2 + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e golang.org/x/sync v0.8.0 golang.org/x/sys v0.24.0 ) @@ -18,6 +18,4 @@ require ( github.com/charmbracelet/x/windows v0.1.0 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index d07e6adb09..afe8f921d0 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wp github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -27,5 +25,3 @@ golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/input.go b/input.go new file mode 100644 index 0000000000..69ac277b26 --- /dev/null +++ b/input.go @@ -0,0 +1,26 @@ +package tea + +import ( + "fmt" + "strings" +) + +// UnknownMsg represents an unknown message. +type UnknownMsg string + +// String returns a string representation of the unknown message. +func (e UnknownMsg) String() string { + return fmt.Sprintf("%q", string(e)) +} + +// multiMsg represents multiple messages event. +type multiMsg []Msg + +// String returns a string representation of the multiple messages event. +func (e multiMsg) String() string { + var sb strings.Builder + for _, ev := range e { + sb.WriteString(fmt.Sprintf("%v\n", ev)) + } + return sb.String() +} diff --git a/inputreader_windows.go b/inputreader_windows.go deleted file mode 100644 index 449df4790c..0000000000 --- a/inputreader_windows.go +++ /dev/null @@ -1,107 +0,0 @@ -//go:build windows -// +build windows - -package tea - -import ( - "fmt" - "io" - "os" - "sync" - - "github.com/charmbracelet/x/term" - "github.com/erikgeiser/coninput" - "github.com/muesli/cancelreader" - "golang.org/x/sys/windows" -) - -type conInputReader struct { - cancelMixin - - conin windows.Handle - - originalMode uint32 -} - -var _ cancelreader.CancelReader = &conInputReader{} - -func newInputReader(r io.Reader) (cancelreader.CancelReader, error) { - fallback := func(io.Reader) (cancelreader.CancelReader, error) { - return cancelreader.NewReader(r) - } - if f, ok := r.(term.File); !ok || f.Fd() != os.Stdin.Fd() { - return fallback(r) - } - - conin, err := coninput.NewStdinHandle() - if err != nil { - return fallback(r) - } - - originalMode, err := prepareConsole(conin, - windows.ENABLE_MOUSE_INPUT, - windows.ENABLE_WINDOW_INPUT, - windows.ENABLE_EXTENDED_FLAGS, - ) - 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.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 (*conInputReader) Read(_ []byte) (n int, err error) { - return 0, 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) - } - - newMode := coninput.AddInputModes(0, modes...) - - 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 -} diff --git a/key.go b/key.go index 89a588aec0..33679414f2 100644 --- a/key.go +++ b/key.go @@ -1,240 +1,92 @@ package tea -import ( - "context" - "fmt" - "io" - "regexp" - "strings" - "unicode/utf8" -) - -// KeyMsg contains information about a keypress. KeyMsgs are always sent to -// the program's update function. There are a couple general patterns you could -// use to check for keypresses: -// -// // Switch on the string representation of the key (shorter) -// switch msg := msg.(type) { -// case KeyMsg: -// switch msg.String() { -// case "enter": -// fmt.Println("you pressed enter!") -// case "a": -// fmt.Println("you pressed a!") -// } -// } -// -// // Switch on the key type (more foolproof) -// switch msg := msg.(type) { -// case KeyMsg: -// switch msg.Type { -// case KeyEnter: -// fmt.Println("you pressed enter!") -// case KeyRunes: -// switch string(msg.Runes) { -// case "a": -// fmt.Println("you pressed a!") -// } -// } -// } +// KeyType indicates whether the key is a special key or runes. Special +// keys are things like KeyEnter, KeyBackspace, and so on. Runes keys are just +// regular characters like 'a', '你', 'ض', '🦄', and so on. // -// Note that Key.Runes will always contain at least one character, so you can -// always safely call Key.Runes[0]. In most cases Key.Runes will only contain -// one character, though certain input method editors (most notably Chinese -// IMEs) can input multiple runes at once. -type KeyMsg Key - -// String returns a string representation for a key message. It's safe (and -// encouraged) for use in key comparison. -func (k KeyMsg) String() (str string) { - return Key(k).String() -} - -// Key contains information about a keypress. -type Key struct { - Type KeyType - Runes []rune - Alt bool - Paste bool -} - -// String returns a friendly string representation for a key. It's safe (and -// encouraged) for use in key comparison. -// -// k := Key{Type: KeyEnter} -// fmt.Println(k) -// // Output: enter -func (k Key) String() (str string) { - var buf strings.Builder - if k.Alt { - buf.WriteString("alt+") - } - if k.Type == KeyRunes { - if k.Paste { - // Note: bubbles/keys bindings currently do string compares to - // recognize shortcuts. Since pasted text should never activate - // shortcuts, we need to ensure that the binding code doesn't - // match Key events that result from pastes. We achieve this - // here by enclosing pastes in '[...]' so that the string - // comparison in Matches() fails in that case. - buf.WriteByte('[') - } - buf.WriteString(string(k.Runes)) - if k.Paste { - buf.WriteByte(']') - } - return buf.String() - } else if s, ok := keyNames[k.Type]; ok { - buf.WriteString(s) - return buf.String() - } - return "" -} - -// KeyType indicates the key pressed, such as KeyEnter or KeyBreak or KeyCtrlC. -// All other keys will be type KeyRunes. To get the rune value, check the Rune -// method on a Key struct, or use the Key.String() method: -// -// k := Key{Type: KeyRunes, Runes: []rune{'a'}, Alt: true} +// k := Key{Type: KeyRunes, Runes: []rune{'A'}, Mod: ModShift} // if k.Type == KeyRunes { // // fmt.Println(k.Runes) -// // Output: a +// // Output: A // // fmt.Println(k.String()) -// // Output: alt+a +// // Output: shift+a // // } type KeyType int -func (k KeyType) String() (str string) { - if s, ok := keyNames[k]; ok { - return s - } - return "" -} - -// Control keys. We could do this with an iota, but the values are very -// specific, so we set the values explicitly to avoid any confusion. -// -// See also: -// https://en.wikipedia.org/wiki/C0_and_C1_control_codes +// Special key symbols. const ( - keyNUL KeyType = 0 // null, \0 - keySOH KeyType = 1 // start of heading - keySTX KeyType = 2 // start of text - keyETX KeyType = 3 // break, ctrl+c - keyEOT KeyType = 4 // end of transmission - keyENQ KeyType = 5 // enquiry - keyACK KeyType = 6 // acknowledge - keyBEL KeyType = 7 // bell, \a - keyBS KeyType = 8 // backspace - keyHT KeyType = 9 // horizontal tabulation, \t - keyLF KeyType = 10 // line feed, \n - keyVT KeyType = 11 // vertical tabulation \v - keyFF KeyType = 12 // form feed \f - keyCR KeyType = 13 // carriage return, \r - keySO KeyType = 14 // shift out - keySI KeyType = 15 // shift in - keyDLE KeyType = 16 // data link escape - keyDC1 KeyType = 17 // device control one - keyDC2 KeyType = 18 // device control two - keyDC3 KeyType = 19 // device control three - keyDC4 KeyType = 20 // device control four - keyNAK KeyType = 21 // negative acknowledge - keySYN KeyType = 22 // synchronous idle - keyETB KeyType = 23 // end of transmission block - keyCAN KeyType = 24 // cancel - keyEM KeyType = 25 // end of medium - keySUB KeyType = 26 // substitution - keyESC KeyType = 27 // escape, \e - keyFS KeyType = 28 // file separator - keyGS KeyType = 29 // group separator - keyRS KeyType = 30 // record separator - keyUS KeyType = 31 // unit separator - keyDEL KeyType = 127 // delete. on most systems this is mapped to backspace, I hear -) + // KeyRunes indicates that the key represents rune(s), like 'a', 'b', 'c', + // and so on. + KeyRunes KeyType = iota -// Control key aliases. -const ( - KeyNull KeyType = keyNUL - KeyBreak KeyType = keyETX - KeyEnter KeyType = keyCR - KeyBackspace KeyType = keyDEL - KeyTab KeyType = keyHT - KeyEsc KeyType = keyESC - KeyEscape KeyType = keyESC - - KeyCtrlAt KeyType = keyNUL // ctrl+@ - KeyCtrlA KeyType = keySOH - KeyCtrlB KeyType = keySTX - KeyCtrlC KeyType = keyETX - KeyCtrlD KeyType = keyEOT - KeyCtrlE KeyType = keyENQ - KeyCtrlF KeyType = keyACK - KeyCtrlG KeyType = keyBEL - KeyCtrlH KeyType = keyBS - KeyCtrlI KeyType = keyHT - KeyCtrlJ KeyType = keyLF - KeyCtrlK KeyType = keyVT - KeyCtrlL KeyType = keyFF - KeyCtrlM KeyType = keyCR - KeyCtrlN KeyType = keySO - KeyCtrlO KeyType = keySI - KeyCtrlP KeyType = keyDLE - KeyCtrlQ KeyType = keyDC1 - KeyCtrlR KeyType = keyDC2 - KeyCtrlS KeyType = keyDC3 - KeyCtrlT KeyType = keyDC4 - KeyCtrlU KeyType = keyNAK - KeyCtrlV KeyType = keySYN - KeyCtrlW KeyType = keyETB - KeyCtrlX KeyType = keyCAN - KeyCtrlY KeyType = keyEM - KeyCtrlZ KeyType = keySUB - KeyCtrlOpenBracket KeyType = keyESC // ctrl+[ - KeyCtrlBackslash KeyType = keyFS // ctrl+\ - KeyCtrlCloseBracket KeyType = keyGS // ctrl+] - KeyCtrlCaret KeyType = keyRS // ctrl+^ - KeyCtrlUnderscore KeyType = keyUS // ctrl+_ - KeyCtrlQuestionMark KeyType = keyDEL // ctrl+? -) + // Special names in C0 + + KeyBackspace + KeyTab + KeyEnter + KeyEscape + + // Special names in G0 + + KeySpace + KeyDelete + + // Special keys -// Other keys. -const ( - KeyRunes KeyType = -(iota + 1) KeyUp KeyDown KeyRight KeyLeft - KeyShiftTab - KeyHome - KeyEnd + KeyBegin + KeyFind + KeyInsert + KeySelect KeyPgUp KeyPgDown - KeyCtrlPgUp - KeyCtrlPgDown - KeyDelete - KeyInsert - KeySpace - KeyCtrlUp - KeyCtrlDown - KeyCtrlRight - KeyCtrlLeft - KeyCtrlHome - KeyCtrlEnd - KeyShiftUp - KeyShiftDown - KeyShiftRight - KeyShiftLeft - KeyShiftHome - KeyShiftEnd - KeyCtrlShiftUp - KeyCtrlShiftDown - KeyCtrlShiftLeft - KeyCtrlShiftRight - KeyCtrlShiftHome - KeyCtrlShiftEnd + KeyHome + KeyEnd + + // Keypad keys + + KeyKpEnter + KeyKpEqual + KeyKpMultiply + KeyKpPlus + KeyKpComma + KeyKpMinus + KeyKpDecimal + KeyKpDivide + KeyKp0 + KeyKp1 + KeyKp2 + KeyKp3 + KeyKp4 + KeyKp5 + KeyKp6 + KeyKp7 + KeyKp8 + KeyKp9 + + // The following are keys defined in the Kitty keyboard protocol. + // TODO: Investigate the names of these keys + KeyKpSep + KeyKpUp + KeyKpDown + KeyKpLeft + KeyKpRight + KeyKpPgUp + KeyKpPgDown + KeyKpHome + KeyKpEnd + KeyKpInsert + KeyKpDelete + KeyKpBegin + + // Function keys + KeyF1 KeyF2 KeyF3 @@ -255,454 +107,425 @@ const ( KeyF18 KeyF19 KeyF20 + KeyF21 + KeyF22 + KeyF23 + KeyF24 + KeyF25 + KeyF26 + KeyF27 + KeyF28 + KeyF29 + KeyF30 + KeyF31 + KeyF32 + KeyF33 + KeyF34 + KeyF35 + KeyF36 + KeyF37 + KeyF38 + KeyF39 + KeyF40 + KeyF41 + KeyF42 + KeyF43 + KeyF44 + KeyF45 + KeyF46 + KeyF47 + KeyF48 + KeyF49 + KeyF50 + KeyF51 + KeyF52 + KeyF53 + KeyF54 + KeyF55 + KeyF56 + KeyF57 + KeyF58 + KeyF59 + KeyF60 + KeyF61 + KeyF62 + KeyF63 + + // The following are keys defined in the Kitty keyboard protocol. + // TODO: Investigate the names of these keys + + KeyCapsLock + KeyScrollLock + KeyNumLock + KeyPrintScreen + KeyPause + KeyMenu + + KeyMediaPlay + KeyMediaPause + KeyMediaPlayPause + KeyMediaReverse + KeyMediaStop + KeyMediaFastForward + KeyMediaRewind + KeyMediaNext + KeyMediaPrev + KeyMediaRecord + + KeyLowerVol + KeyRaiseVol + KeyMute + + KeyLeftShift + KeyLeftAlt + KeyLeftCtrl + KeyLeftSuper + KeyLeftHyper + KeyLeftMeta + KeyRightShift + KeyRightAlt + KeyRightCtrl + KeyRightSuper + KeyRightHyper + KeyRightMeta + KeyIsoLevel3Shift + KeyIsoLevel5Shift ) -// Mappings for control keys and other special keys to friendly consts. -var keyNames = map[KeyType]string{ - // Control keys. - keyNUL: "ctrl+@", // also ctrl+` (that's ctrl+backtick) - keySOH: "ctrl+a", - keySTX: "ctrl+b", - keyETX: "ctrl+c", - keyEOT: "ctrl+d", - keyENQ: "ctrl+e", - keyACK: "ctrl+f", - keyBEL: "ctrl+g", - keyBS: "ctrl+h", - keyHT: "tab", // also ctrl+i - keyLF: "ctrl+j", - keyVT: "ctrl+k", - keyFF: "ctrl+l", - keyCR: "enter", - keySO: "ctrl+n", - keySI: "ctrl+o", - keyDLE: "ctrl+p", - keyDC1: "ctrl+q", - keyDC2: "ctrl+r", - keyDC3: "ctrl+s", - keyDC4: "ctrl+t", - keyNAK: "ctrl+u", - keySYN: "ctrl+v", - keyETB: "ctrl+w", - keyCAN: "ctrl+x", - keyEM: "ctrl+y", - keySUB: "ctrl+z", - keyESC: "esc", - keyFS: "ctrl+\\", - keyGS: "ctrl+]", - keyRS: "ctrl+^", - keyUS: "ctrl+_", - keyDEL: "backspace", - - // Other keys. - KeyRunes: "runes", - KeyUp: "up", - KeyDown: "down", - KeyRight: "right", - KeySpace: " ", // for backwards compatibility - KeyLeft: "left", - KeyShiftTab: "shift+tab", - KeyHome: "home", - KeyEnd: "end", - KeyCtrlHome: "ctrl+home", - KeyCtrlEnd: "ctrl+end", - KeyShiftHome: "shift+home", - KeyShiftEnd: "shift+end", - KeyCtrlShiftHome: "ctrl+shift+home", - KeyCtrlShiftEnd: "ctrl+shift+end", - KeyPgUp: "pgup", - KeyPgDown: "pgdown", - KeyCtrlPgUp: "ctrl+pgup", - KeyCtrlPgDown: "ctrl+pgdown", - KeyDelete: "delete", - KeyInsert: "insert", - KeyCtrlUp: "ctrl+up", - KeyCtrlDown: "ctrl+down", - KeyCtrlRight: "ctrl+right", - KeyCtrlLeft: "ctrl+left", - KeyShiftUp: "shift+up", - KeyShiftDown: "shift+down", - KeyShiftRight: "shift+right", - KeyShiftLeft: "shift+left", - KeyCtrlShiftUp: "ctrl+shift+up", - KeyCtrlShiftDown: "ctrl+shift+down", - KeyCtrlShiftLeft: "ctrl+shift+left", - KeyCtrlShiftRight: "ctrl+shift+right", - 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", -} +// Key contains information about a key or release. Keys are always sent to the +// program's update function. 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 msg := msg.(type) { +// case KeyPressMsg: +// switch msg.String() { +// case "enter": +// fmt.Println("you pressed enter!") +// case "a": +// fmt.Println("you pressed a!") +// } +// } +// +// // Switch on the key type (more foolproof) +// switch msg := msg.(type) { +// case KeyReleaseMsg: +// switch msg.Sym { +// case KeyEnter: +// fmt.Println("you pressed enter!") +// case KeyRunes: +// switch string(msg.Runes) { +// case "a": +// fmt.Println("you pressed a!") +// } +// } +// } +// +// Note that Key.Runes will always contain at least one character, so you can +// always safely call Key.Runes[0]. In most cases Key.Runes will only contain +// one character, though certain input method editors (most notably Chinese +// IMEs) can input multiple runes at once. +type Key struct { + // Runes contains the actual characters received. This usually has a length + // of 1. Use [Rune()] to get the first key rune received. If the user + // presses shift+a, the Runes will be `[]rune{'A'}`. + Runes []rune -// Sequence mappings. -var sequences = map[string]Key{ - // Arrow keys - "\x1b[A": {Type: KeyUp}, - "\x1b[B": {Type: KeyDown}, - "\x1b[C": {Type: KeyRight}, - "\x1b[D": {Type: KeyLeft}, - "\x1b[1;2A": {Type: KeyShiftUp}, - "\x1b[1;2B": {Type: KeyShiftDown}, - "\x1b[1;2C": {Type: KeyShiftRight}, - "\x1b[1;2D": {Type: KeyShiftLeft}, - "\x1b[OA": {Type: KeyShiftUp}, // DECCKM - "\x1b[OB": {Type: KeyShiftDown}, // DECCKM - "\x1b[OC": {Type: KeyShiftRight}, // DECCKM - "\x1b[OD": {Type: KeyShiftLeft}, // DECCKM - "\x1b[a": {Type: KeyShiftUp}, // urxvt - "\x1b[b": {Type: KeyShiftDown}, // urxvt - "\x1b[c": {Type: KeyShiftRight}, // urxvt - "\x1b[d": {Type: KeyShiftLeft}, // urxvt - "\x1b[1;3A": {Type: KeyUp, Alt: true}, - "\x1b[1;3B": {Type: KeyDown, Alt: true}, - "\x1b[1;3C": {Type: KeyRight, Alt: true}, - "\x1b[1;3D": {Type: KeyLeft, Alt: true}, - - "\x1b[1;4A": {Type: KeyShiftUp, Alt: true}, - "\x1b[1;4B": {Type: KeyShiftDown, Alt: true}, - "\x1b[1;4C": {Type: KeyShiftRight, Alt: true}, - "\x1b[1;4D": {Type: KeyShiftLeft, Alt: true}, - - "\x1b[1;5A": {Type: KeyCtrlUp}, - "\x1b[1;5B": {Type: KeyCtrlDown}, - "\x1b[1;5C": {Type: KeyCtrlRight}, - "\x1b[1;5D": {Type: KeyCtrlLeft}, - "\x1b[Oa": {Type: KeyCtrlUp, Alt: true}, // urxvt - "\x1b[Ob": {Type: KeyCtrlDown, Alt: true}, // urxvt - "\x1b[Oc": {Type: KeyCtrlRight, Alt: true}, // urxvt - "\x1b[Od": {Type: KeyCtrlLeft, Alt: true}, // urxvt - "\x1b[1;6A": {Type: KeyCtrlShiftUp}, - "\x1b[1;6B": {Type: KeyCtrlShiftDown}, - "\x1b[1;6C": {Type: KeyCtrlShiftRight}, - "\x1b[1;6D": {Type: KeyCtrlShiftLeft}, - "\x1b[1;7A": {Type: KeyCtrlUp, Alt: true}, - "\x1b[1;7B": {Type: KeyCtrlDown, Alt: true}, - "\x1b[1;7C": {Type: KeyCtrlRight, Alt: true}, - "\x1b[1;7D": {Type: KeyCtrlLeft, Alt: true}, - "\x1b[1;8A": {Type: KeyCtrlShiftUp, Alt: true}, - "\x1b[1;8B": {Type: KeyCtrlShiftDown, Alt: true}, - "\x1b[1;8C": {Type: KeyCtrlShiftRight, Alt: true}, - "\x1b[1;8D": {Type: KeyCtrlShiftLeft, Alt: true}, - - // Miscellaneous keys - "\x1b[Z": {Type: KeyShiftTab}, - - "\x1b[2~": {Type: KeyInsert}, - "\x1b[3;2~": {Type: KeyInsert, Alt: true}, - - "\x1b[3~": {Type: KeyDelete}, - "\x1b[3;3~": {Type: KeyDelete, Alt: true}, - - "\x1b[5~": {Type: KeyPgUp}, - "\x1b[5;3~": {Type: KeyPgUp, Alt: true}, - "\x1b[5;5~": {Type: KeyCtrlPgUp}, - "\x1b[5^": {Type: KeyCtrlPgUp}, // urxvt - "\x1b[5;7~": {Type: KeyCtrlPgUp, Alt: true}, - - "\x1b[6~": {Type: KeyPgDown}, - "\x1b[6;3~": {Type: KeyPgDown, Alt: true}, - "\x1b[6;5~": {Type: KeyCtrlPgDown}, - "\x1b[6^": {Type: KeyCtrlPgDown}, // urxvt - "\x1b[6;7~": {Type: KeyCtrlPgDown, Alt: true}, - - "\x1b[1~": {Type: KeyHome}, - "\x1b[H": {Type: KeyHome}, // xterm, lxterm - "\x1b[1;3H": {Type: KeyHome, Alt: true}, // xterm, lxterm - "\x1b[1;5H": {Type: KeyCtrlHome}, // xterm, lxterm - "\x1b[1;7H": {Type: KeyCtrlHome, Alt: true}, // xterm, lxterm - "\x1b[1;2H": {Type: KeyShiftHome}, // xterm, lxterm - "\x1b[1;4H": {Type: KeyShiftHome, Alt: true}, // xterm, lxterm - "\x1b[1;6H": {Type: KeyCtrlShiftHome}, // xterm, lxterm - "\x1b[1;8H": {Type: KeyCtrlShiftHome, Alt: true}, // xterm, lxterm - - "\x1b[4~": {Type: KeyEnd}, - "\x1b[F": {Type: KeyEnd}, // xterm, lxterm - "\x1b[1;3F": {Type: KeyEnd, Alt: true}, // xterm, lxterm - "\x1b[1;5F": {Type: KeyCtrlEnd}, // xterm, lxterm - "\x1b[1;7F": {Type: KeyCtrlEnd, Alt: true}, // xterm, lxterm - "\x1b[1;2F": {Type: KeyShiftEnd}, // xterm, lxterm - "\x1b[1;4F": {Type: KeyShiftEnd, Alt: true}, // xterm, lxterm - "\x1b[1;6F": {Type: KeyCtrlShiftEnd}, // xterm, lxterm - "\x1b[1;8F": {Type: KeyCtrlShiftEnd, Alt: true}, // xterm, lxterm - - "\x1b[7~": {Type: KeyHome}, // urxvt - "\x1b[7^": {Type: KeyCtrlHome}, // urxvt - "\x1b[7$": {Type: KeyShiftHome}, // urxvt - "\x1b[7@": {Type: KeyCtrlShiftHome}, // urxvt - - "\x1b[8~": {Type: KeyEnd}, // urxvt - "\x1b[8^": {Type: KeyCtrlEnd}, // urxvt - "\x1b[8$": {Type: KeyShiftEnd}, // urxvt - "\x1b[8@": {Type: KeyCtrlShiftEnd}, // urxvt - - // Function keys, Linux console - "\x1b[[A": {Type: KeyF1}, // linux console - "\x1b[[B": {Type: KeyF2}, // linux console - "\x1b[[C": {Type: KeyF3}, // linux console - "\x1b[[D": {Type: KeyF4}, // linux console - "\x1b[[E": {Type: KeyF5}, // linux console - - // Function keys, X11 - "\x1bOP": {Type: KeyF1}, // vt100, xterm - "\x1bOQ": {Type: KeyF2}, // vt100, xterm - "\x1bOR": {Type: KeyF3}, // vt100, xterm - "\x1bOS": {Type: KeyF4}, // vt100, xterm - - "\x1b[1;3P": {Type: KeyF1, Alt: true}, // vt100, xterm - "\x1b[1;3Q": {Type: KeyF2, Alt: true}, // vt100, xterm - "\x1b[1;3R": {Type: KeyF3, Alt: true}, // vt100, xterm - "\x1b[1;3S": {Type: KeyF4, Alt: true}, // vt100, xterm - - "\x1b[11~": {Type: KeyF1}, // urxvt - "\x1b[12~": {Type: KeyF2}, // urxvt - "\x1b[13~": {Type: KeyF3}, // urxvt - "\x1b[14~": {Type: KeyF4}, // urxvt - - "\x1b[15~": {Type: KeyF5}, // vt100, xterm, also urxvt - - "\x1b[15;3~": {Type: KeyF5, Alt: true}, // vt100, xterm, also urxvt - - "\x1b[17~": {Type: KeyF6}, // vt100, xterm, also urxvt - "\x1b[18~": {Type: KeyF7}, // vt100, xterm, also urxvt - "\x1b[19~": {Type: KeyF8}, // vt100, xterm, also urxvt - "\x1b[20~": {Type: KeyF9}, // vt100, xterm, also urxvt - "\x1b[21~": {Type: KeyF10}, // vt100, xterm, also urxvt - - "\x1b[17;3~": {Type: KeyF6, Alt: true}, // vt100, xterm - "\x1b[18;3~": {Type: KeyF7, Alt: true}, // vt100, xterm - "\x1b[19;3~": {Type: KeyF8, Alt: true}, // vt100, xterm - "\x1b[20;3~": {Type: KeyF9, Alt: true}, // vt100, xterm - "\x1b[21;3~": {Type: KeyF10, Alt: true}, // vt100, xterm - - "\x1b[23~": {Type: KeyF11}, // vt100, xterm, also urxvt - "\x1b[24~": {Type: KeyF12}, // vt100, xterm, also urxvt - - "\x1b[23;3~": {Type: KeyF11, Alt: true}, // vt100, xterm - "\x1b[24;3~": {Type: KeyF12, Alt: true}, // vt100, xterm - - "\x1b[1;2P": {Type: KeyF13}, - "\x1b[1;2Q": {Type: KeyF14}, - - "\x1b[25~": {Type: KeyF13}, // vt100, xterm, also urxvt - "\x1b[26~": {Type: KeyF14}, // vt100, xterm, also urxvt - - "\x1b[25;3~": {Type: KeyF13, Alt: true}, // vt100, xterm - "\x1b[26;3~": {Type: KeyF14, Alt: true}, // vt100, xterm - - "\x1b[1;2R": {Type: KeyF15}, - "\x1b[1;2S": {Type: KeyF16}, - - "\x1b[28~": {Type: KeyF15}, // vt100, xterm, also urxvt - "\x1b[29~": {Type: KeyF16}, // vt100, xterm, also urxvt - - "\x1b[28;3~": {Type: KeyF15, Alt: true}, // vt100, xterm - "\x1b[29;3~": {Type: KeyF16, Alt: true}, // vt100, xterm - - "\x1b[15;2~": {Type: KeyF17}, - "\x1b[17;2~": {Type: KeyF18}, - "\x1b[18;2~": {Type: KeyF19}, - "\x1b[19;2~": {Type: KeyF20}, - - "\x1b[31~": {Type: KeyF17}, - "\x1b[32~": {Type: KeyF18}, - "\x1b[33~": {Type: KeyF19}, - "\x1b[34~": {Type: KeyF20}, - - // Powershell sequences. - "\x1bOA": {Type: KeyUp, Alt: false}, - "\x1bOB": {Type: KeyDown, Alt: false}, - "\x1bOC": {Type: KeyRight, Alt: false}, - "\x1bOD": {Type: KeyLeft, Alt: false}, + // Type is a special key, like enter, tab, backspace, and so on. + Type KeyType + + // altRune is the actual, unshifted key pressed by the user. For example, + // if the user presses shift+a, or caps lock is on, the altRune will be + // 'a'. + // + // In the case of non-latin keyboards, like Arabic, altRune is the + // unshifted key on the keyboard. + // + // This is only available with the Kitty Keyboard Protocol or the Windows + // Console API. + altRune rune + + // baseRune is the key pressed according to the standard PC-101 key layout. + // On internaltional keyboards, this is the key that would be pressed if + // the keyboard was set to US layout. + // + // For example, if the user presses 'q' on a French AZERTY keyboard, the + // baseRune will be 'q'. + // + // This is only available with the Kitty Keyboard Protocol or the Windows + // Console API. + baseRune rune + + // Mod is a modifier key, like ctrl, alt, and so on. + Mod KeyMod + + // 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 } -// unknownInputByteMsg is reported by the input reader when an invalid -// utf-8 byte is detected on the input. Currently, it is not handled -// further by bubbletea. However, having this event makes it possible -// to troubleshoot invalid inputs. -type unknownInputByteMsg byte +// KeyPressMsg represents a key press message. +type KeyPressMsg Key -func (u unknownInputByteMsg) String() string { - return fmt.Sprintf("?%#02x?", int(u)) +// String implements fmt.Stringer and is quite useful for matching key +// events. For details, on what this returns see [Key.String]. +func (k KeyPressMsg) String() string { + return Key(k).String() } -// unknownCSISequenceMsg is reported by the input reader when an -// unrecognized CSI sequence is detected on the input. Currently, it -// is not handled further by bubbletea. However, having this event -// makes it possible to troubleshoot invalid inputs. -type unknownCSISequenceMsg []byte - -func (u unknownCSISequenceMsg) String() string { - return fmt.Sprintf("?CSI%+v?", []byte(u)[2:]) +// Rune returns the first rune in the Runes field. If the Runes field is empty, +// it returns 0. +func (k KeyPressMsg) Rune() rune { + return Key(k).Rune() } -var spaceRunes = []rune{' '} +// KeyReleaseMsg represents a key release message. +type KeyReleaseMsg Key -// readAnsiInputs reads keypress and mouse inputs from a TTY and produces messages -// containing information about the key or mouse events accordingly. -func readAnsiInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { - var buf [256]byte +// String implements fmt.Stringer and is quite useful for matching complex key +// events. For details, on what this returns see [Key.String]. +func (k KeyReleaseMsg) String() string { + return Key(k).String() +} - var leftOverFromPrevIteration []byte -loop: - for { - // Read and block. - numBytes, err := input.Read(buf[:]) - if err != nil { - return fmt.Errorf("error reading input: %w", err) - } - b := buf[:numBytes] - if leftOverFromPrevIteration != nil { - b = append(leftOverFromPrevIteration, b...) - } +// Rune returns the first rune in the Runes field. If the Runes field is empty, +// it returns 0. +func (k KeyReleaseMsg) Rune() rune { + return Key(k).Rune() +} - // If we had a short read (numBytes < len(buf)), we're sure that - // the end of this read is an event boundary, so there is no doubt - // if we are encountering the end of the buffer while parsing a message. - // However, if we've succeeded in filling up the buffer, there may - // be more data in the OS buffer ready to be read in, to complete - // the last message in the input. In that case, we will retry with - // the left over data in the next iteration. - canHaveMoreData := numBytes == len(buf) - - var i, w int - for i, w = 0, 0; i < len(b); i += w { - var msg Msg - w, msg = detectOneMsg(b[i:], canHaveMoreData) - if w == 0 { - // Expecting more bytes beyond the current buffer. Try waiting - // for more input. - leftOverFromPrevIteration = make([]byte, 0, len(b[i:])+len(buf)) - leftOverFromPrevIteration = append(leftOverFromPrevIteration, b[i:]...) - continue loop - } - - select { - case msgs <- msg: - case <-ctx.Done(): - err := ctx.Err() - if err != nil { - err = fmt.Errorf("found context error while reading input: %w", err) - } - return err - } - } - leftOverFromPrevIteration = nil +// Rune returns the first rune in the Runes field. If the Runes field is empty, +// it returns 0. +func (k Key) Rune() rune { + if len(k.Runes) == 0 { + return 0 } + return k.Runes[0] } -var ( - unknownCSIRe = regexp.MustCompile(`^\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]`) - mouseSGRRegex = regexp.MustCompile(`(\d+);(\d+);(\d+)([Mm])`) -) - -func detectOneMsg(b []byte, canHaveMoreData bool) (w int, msg Msg) { - // Detect mouse events. - // X10 mouse events have a length of 6 bytes - const mouseEventX10Len = 6 - if len(b) >= mouseEventX10Len && b[0] == '\x1b' && b[1] == '[' { - switch b[2] { - case 'M': - return mouseEventX10Len, MouseMsg(parseX10MouseEvent(b)) - case '<': - if matchIndices := mouseSGRRegex.FindSubmatchIndex(b[3:]); matchIndices != nil { - // SGR mouse events length is the length of the match plus the length of the escape sequence - mouseEventSGRLen := matchIndices[1] + 3 //nolint:gomnd - return mouseEventSGRLen, MouseMsg(parseSGRMouseEvent(b)) - } - } +// String implements fmt.Stringer and is used to convert a key to a string. +// 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) String() string { + var s string + if k.Mod.Contains(ModCtrl) && k.Type != KeyLeftCtrl && k.Type != KeyRightCtrl { + s += "ctrl+" } - - // Detect bracketed paste. - var foundbp bool - foundbp, w, msg = detectBracketedPaste(b) - if foundbp { - return w, msg + if k.Mod.Contains(ModAlt) && k.Type != KeyLeftAlt && k.Type != KeyRightAlt { + s += "alt+" } - - // Detect escape sequence and control characters other than NUL, - // possibly with an escape character in front to mark the Alt - // modifier. - var foundSeq bool - foundSeq, w, msg = detectSequence(b) - if foundSeq { - return w, msg + if k.Mod.Contains(ModShift) && k.Type != KeyLeftShift && k.Type != KeyRightShift { + s += "shift+" } - - // No non-NUL control character or escape sequence. - // If we are seeing at least an escape character, remember it for later below. - alt := false - i := 0 - if b[0] == '\x1b' { - alt = true - i++ + if k.Mod.Contains(ModMeta) && k.Type != KeyLeftMeta && k.Type != KeyRightMeta { + s += "meta+" } - - // Are we seeing a standalone NUL? This is not handled by detectSequence(). - if i < len(b) && b[i] == 0 { - return i + 1, KeyMsg{Type: keyNUL, Alt: alt} + if k.Mod.Contains(ModHyper) && k.Type != KeyLeftHyper && k.Type != KeyRightHyper { + s += "hyper+" + } + if k.Mod.Contains(ModSuper) && k.Type != KeyLeftSuper && k.Type != KeyRightSuper { + s += "super+" } - // Find the longest sequence of runes that are not control - // characters from this point. - var runes []rune - for rw := 0; i < len(b); i += rw { - var r rune - r, rw = utf8.DecodeRune(b[i:]) - if r == utf8.RuneError || r <= rune(keyUS) || r == rune(keyDEL) || r == ' ' { - // Rune errors are handled below; control characters and spaces will - // be handled by detectSequence in the next call to detectOneMsg. - break + runeStr := func(r rune) string { + // Space is the only invisible printable character. + if r == ' ' { + return "space" } - runes = append(runes, r) - if alt { - // We only support a single rune after an escape alt modifier. - i += rw - break - } - } - if i >= len(b) && canHaveMoreData { - // We have encountered the end of the input buffer. Alas, we can't - // be sure whether the data in the remainder of the buffer is - // complete (maybe there was a short read). Instead of sending anything - // dumb to the message channel, do a short read. The outer loop will - // handle this case by extending the buffer as necessary. - return 0, nil + return string(r) } - - // If we found at least one rune, we report the bunch of them as - // a single KeyRunes or KeySpace event. - if len(runes) > 0 { - k := Key{Type: KeyRunes, Runes: runes, Alt: alt} - if len(runes) == 1 && runes[0] == ' ' { - k.Type = KeySpace + if k.baseRune != 0 { + // If a baseRune is present, use it to represent a key using the standard + // PC-101 key layout. + s += runeStr(k.baseRune) + } else if k.altRune != 0 { + // Otherwise, use the AltRune aka the non-shifted one if present. + s += runeStr(k.altRune) + } else if len(k.Runes) > 0 { + // Else, just print the rune. + if len(k.Runes) > 1 { + s += string(k.Runes) + } else { + s += runeStr(k.Rune()) } - return i, KeyMsg(k) + } else { + s += k.Type.String() } + return s +} - // We didn't find an escape sequence, nor a valid rune. Was this a - // lone escape character at the end of the input? - if alt && len(b) == 1 { - return 1, KeyMsg(Key{Type: KeyEscape}) +// String returns the string representation of the key type. +func (k KeyType) String() string { + if s, ok := keyTypeString[k]; ok { + return s } + return "" +} - // The character at the current position is neither an escape - // sequence, a valid rune start or a sole escape character. Report - // it as an invalid byte. - return 1, unknownInputByteMsg(b[0]) +var keyTypeString = map[KeyType]string{ + KeyRunes: "runes", + 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/key_deprecated.go b/key_deprecated.go new file mode 100644 index 0000000000..ab1c9de1a8 --- /dev/null +++ b/key_deprecated.go @@ -0,0 +1,240 @@ +package tea + +import ( + "strings" +) + +// KeyMsg contains information about a keypress. KeyMsgs are always sent to +// the program's update function. There are a couple general patterns you could +// use to check for keypresses: +// +// // Switch on the string representation of the key (shorter) +// switch msg := msg.(type) { +// case KeyMsg: +// switch msg.String() { +// case "enter": +// fmt.Println("you pressed enter!") +// case "a": +// fmt.Println("you pressed a!") +// } +// } +// +// // Switch on the key type (more foolproof) +// switch msg := msg.(type) { +// case KeyMsg: +// switch msg.Type { +// case KeyEnter: +// fmt.Println("you pressed enter!") +// case KeyRunes: +// switch string(msg.Runes) { +// case "a": +// fmt.Println("you pressed a!") +// } +// } +// } +// +// Note that Key.Runes will always contain at least one character, so you can +// always safely call Key.Runes[0]. In most cases Key.Runes will only contain +// one character, though certain input method editors (most notably Chinese +// IMEs) can input multiple runes at once. +// +// Deprecated: KeyMsg is deprecated in favor of KeyPressMsg and KeyReleaseMsg. +type KeyMsg struct { + Type KeyType + Runes []rune + Alt bool + Paste bool +} + +// String returns a friendly string representation for a key. It's safe (and +// encouraged) for use in key comparison. +// +// k := Key{Type: KeyEnter} +// fmt.Println(k) +// // Output: enter +func (k KeyMsg) String() (str string) { + var buf strings.Builder + if k.Alt { + buf.WriteString("alt+") + } + if k.Type == KeyRunes { + if k.Paste { + // Note: bubbles/keys bindings currently do string compares to + // recognize shortcuts. Since pasted text should never activate + // shortcuts, we need to ensure that the binding code doesn't + // match Key events that result from pastes. We achieve this + // here by enclosing pastes in '[...]' so that the string + // comparison in Matches() fails in that case. + buf.WriteByte('[') + } + buf.WriteString(string(k.Runes)) + if k.Paste { + buf.WriteByte(']') + } + return buf.String() + } else if s, ok := keyNames[k.Type]; ok { + buf.WriteString(s) + return buf.String() + } + return "" +} + +// Control key aliases. +const ( + KeyNull KeyType = -iota - 10 + KeyBreak + + KeyCtrlAt // ctrl+@ + KeyCtrlA + KeyCtrlB + KeyCtrlC + KeyCtrlD + KeyCtrlE + KeyCtrlF + KeyCtrlG + KeyCtrlH + KeyCtrlI + KeyCtrlJ + KeyCtrlK + KeyCtrlL + KeyCtrlM + KeyCtrlN + KeyCtrlO + KeyCtrlP + KeyCtrlQ + KeyCtrlR + KeyCtrlS + KeyCtrlT + KeyCtrlU + KeyCtrlV + KeyCtrlW + KeyCtrlX + KeyCtrlY + KeyCtrlZ + KeyCtrlOpenBracket // ctrl+[ + KeyCtrlBackslash // ctrl+\ + KeyCtrlCloseBracket // ctrl+] + KeyCtrlCaret // ctrl+^ + KeyCtrlUnderscore // ctrl+_ + KeyCtrlQuestionMark // ctrl+? + KeyCtrlUp + KeyCtrlDown + KeyCtrlRight + KeyCtrlLeft + KeyCtrlPgUp + KeyCtrlPgDown + KeyCtrlHome + KeyCtrlEnd + + KeyShiftTab + KeyShiftUp + KeyShiftDown + KeyShiftRight + KeyShiftLeft + KeyShiftHome + KeyShiftEnd + + KeyCtrlShiftUp + KeyCtrlShiftDown + KeyCtrlShiftLeft + KeyCtrlShiftRight + KeyCtrlShiftHome + KeyCtrlShiftEnd + + // Deprecated: Use KeyEscape instead. + KeyEsc = KeyEscape +) + +// Mappings for control keys and other special keys to friendly consts. +var keyNames = map[KeyType]string{ + // Control keys. + KeyCtrlAt: "ctrl+@", // also ctrl+` (that's ctrl+backtick) + KeyCtrlA: "ctrl+a", + KeyCtrlB: "ctrl+b", + KeyCtrlC: "ctrl+c", + KeyCtrlD: "ctrl+d", + KeyCtrlE: "ctrl+e", + KeyCtrlF: "ctrl+f", + KeyCtrlG: "ctrl+g", + KeyCtrlH: "ctrl+h", + KeyTab: "tab", // also ctrl+i + KeyCtrlJ: "ctrl+j", + KeyCtrlK: "ctrl+k", + KeyCtrlL: "ctrl+l", + KeyEnter: "enter", + KeyCtrlN: "ctrl+n", + KeyCtrlO: "ctrl+o", + KeyCtrlP: "ctrl+p", + KeyCtrlQ: "ctrl+q", + KeyCtrlR: "ctrl+r", + KeyCtrlS: "ctrl+s", + KeyCtrlT: "ctrl+t", + KeyCtrlU: "ctrl+u", + KeyCtrlV: "ctrl+v", + KeyCtrlW: "ctrl+w", + KeyCtrlX: "ctrl+x", + KeyCtrlY: "ctrl+y", + KeyCtrlZ: "ctrl+z", + KeyEscape: "esc", + KeyCtrlOpenBracket: "ctrl+[", + KeyCtrlBackslash: "ctrl+\\", + KeyCtrlCloseBracket: "ctrl+]", + KeyCtrlCaret: "ctrl+^", + KeyCtrlUnderscore: "ctrl+_", + KeyBackspace: "backspace", + + // Other keys. + KeyRunes: "runes", + KeyUp: "up", + KeyDown: "down", + KeyRight: "right", + KeySpace: " ", // for backwards compatibility + KeyLeft: "left", + KeyShiftTab: "shift+tab", + KeyHome: "home", + KeyEnd: "end", + KeyCtrlHome: "ctrl+home", + KeyCtrlEnd: "ctrl+end", + KeyShiftHome: "shift+home", + KeyShiftEnd: "shift+end", + KeyCtrlShiftHome: "ctrl+shift+home", + KeyCtrlShiftEnd: "ctrl+shift+end", + KeyPgUp: "pgup", + KeyPgDown: "pgdown", + KeyCtrlPgUp: "ctrl+pgup", + KeyCtrlPgDown: "ctrl+pgdown", + KeyDelete: "delete", + KeyInsert: "insert", + KeyCtrlUp: "ctrl+up", + KeyCtrlDown: "ctrl+down", + KeyCtrlRight: "ctrl+right", + KeyCtrlLeft: "ctrl+left", + KeyShiftUp: "shift+up", + KeyShiftDown: "shift+down", + KeyShiftRight: "shift+right", + KeyShiftLeft: "shift+left", + KeyCtrlShiftUp: "ctrl+shift+up", + KeyCtrlShiftDown: "ctrl+shift+down", + KeyCtrlShiftLeft: "ctrl+shift+left", + KeyCtrlShiftRight: "ctrl+shift+right", + 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", +} diff --git a/key_other.go b/key_other.go deleted file mode 100644 index b8c46082f8..0000000000 --- a/key_other.go +++ /dev/null @@ -1,13 +0,0 @@ -//go:build !windows -// +build !windows - -package tea - -import ( - "context" - "io" -) - -func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { - return readAnsiInputs(ctx, msgs, input) -} diff --git a/key_sequences.go b/key_sequences.go deleted file mode 100644 index 4ba0f79e34..0000000000 --- a/key_sequences.go +++ /dev/null @@ -1,119 +0,0 @@ -package tea - -import ( - "bytes" - "sort" - "unicode/utf8" -) - -// extSequences is used by the map-based algorithm below. It contains -// the sequences plus their alternatives with an escape character -// prefixed, plus the control chars, plus the space. -// It does not contain the NUL character, which is handled specially -// by detectOneMsg. -var extSequences = func() map[string]Key { - s := map[string]Key{} - for seq, key := range sequences { - key := key - s[seq] = key - if !key.Alt { - key.Alt = true - s["\x1b"+seq] = key - } - } - for i := keyNUL + 1; i <= keyDEL; i++ { - if i == keyESC { - continue - } - s[string([]byte{byte(i)})] = Key{Type: i} - s[string([]byte{'\x1b', byte(i)})] = Key{Type: i, Alt: true} - if i == keyUS { - i = keyDEL - 1 - } - } - s[" "] = Key{Type: KeySpace, Runes: spaceRunes} - s["\x1b "] = Key{Type: KeySpace, Alt: true, Runes: spaceRunes} - s["\x1b\x1b"] = Key{Type: KeyEscape, Alt: true} - return s -}() - -// seqLengths is the sizes of valid sequences, starting with the -// largest size. -var seqLengths = func() []int { - sizes := map[int]struct{}{} - for seq := range extSequences { - sizes[len(seq)] = struct{}{} - } - lsizes := make([]int, 0, len(sizes)) - for sz := range sizes { - lsizes = append(lsizes, sz) - } - sort.Slice(lsizes, func(i, j int) bool { return lsizes[i] > lsizes[j] }) - return lsizes -}() - -// detectSequence uses a longest prefix match over the input -// sequence and a hash map. -func detectSequence(input []byte) (hasSeq bool, width int, msg Msg) { - seqs := extSequences - for _, sz := range seqLengths { - if sz > len(input) { - continue - } - prefix := input[:sz] - key, ok := seqs[string(prefix)] - if ok { - return true, sz, KeyMsg(key) - } - } - // Is this an unknown CSI sequence? - if loc := unknownCSIRe.FindIndex(input); loc != nil { - return true, loc[1], unknownCSISequenceMsg(input[:loc[1]]) - } - - return false, 0, nil -} - -// detectBracketedPaste detects an input pasted while bracketed -// paste mode was enabled. -// -// Note: this function is a no-op if bracketed paste was not enabled -// on the terminal, since in that case we'd never see this -// particular escape sequence. -func detectBracketedPaste(input []byte) (hasBp bool, width int, msg Msg) { - // Detect the start sequence. - const bpStart = "\x1b[200~" - if len(input) < len(bpStart) || string(input[:len(bpStart)]) != bpStart { - return false, 0, nil - } - - // Skip over the start sequence. - input = input[len(bpStart):] - - // If we saw the start sequence, then we must have an end sequence - // as well. Find it. - const bpEnd = "\x1b[201~" - idx := bytes.Index(input, []byte(bpEnd)) - inputLen := len(bpStart) + idx + len(bpEnd) - if idx == -1 { - // We have encountered the end of the input buffer without seeing - // the marker for the end of the bracketed paste. - // Tell the outer loop we have done a short read and we want more. - return true, 0, nil - } - - // The paste is everything in-between. - paste := input[:idx] - - // All there is in-between is runes, not to be interpreted further. - k := Key{Type: KeyRunes, Paste: true} - for len(paste) > 0 { - r, w := utf8.DecodeRune(paste) - if r != utf8.RuneError { - k.Runes = append(k.Runes, r) - } - paste = paste[w:] - } - - return true, inputLen, KeyMsg(k) -} diff --git a/key_test.go b/key_test.go index 67b0c50ed5..d46da05379 100644 --- a/key_test.go +++ b/key_test.go @@ -9,85 +9,78 @@ import ( "io" "math/rand" "reflect" + "regexp" "runtime" "sort" "strings" "sync" "testing" "time" + + "github.com/charmbracelet/x/ansi" ) +var sequences = buildKeysTable(_FlagTerminfo, "dumb") + func TestKeyString(t *testing.T) { t.Run("alt+space", func(t *testing.T) { - if got := KeyMsg(Key{ - Type: KeySpace, - Alt: true, - }).String(); got != "alt+ " { - t.Fatalf(`expected a "alt+ ", got %q`, got) + k := KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, 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) { - if got := KeyMsg(Key{ - Type: KeyRunes, - Runes: []rune{'a'}, - }).String(); got != "a" { + k := KeyPressMsg{Runes: []rune{'a'}} + if got := k.String(); got != "a" { t.Fatalf(`expected an "a", got %q`, got) } }) t.Run("invalid", func(t *testing.T) { - if got := KeyMsg(Key{ - Type: KeyType(99999), - }).String(); got != "" { - t.Fatalf(`expected a "", got %q`, got) + k := KeyPressMsg{Type: 99999} + if got := k.String(); got != "" { + t.Fatalf(`expected a "unknown", got %q`, got) } }) } func TestKeyTypeString(t *testing.T) { t.Run("space", func(t *testing.T) { - if got := KeySpace.String(); got != " " { - t.Fatalf(`expected a " ", got %q`, got) + if got := KeySpace.String(); got != "space" { + t.Fatalf(`expected a "space", got %q`, got) } }) t.Run("invalid", func(t *testing.T) { if got := KeyType(99999).String(); got != "" { - t.Fatalf(`expected a "", got %q`, got) + t.Fatalf(`expected a "unknown", got %q`, got) } }) } type seqTest struct { - seq []byte - msg Msg + seq []byte + msgs []Msg } +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 { - key := key - td = append(td, seqTest{[]byte(seq), KeyMsg(key)}) - if !key.Alt { - key.Alt = true - td = append(td, seqTest{[]byte("\x1b" + seq), KeyMsg(key)}) - } - } - // Add all the control characters. - for i := keyNUL + 1; i <= keyDEL; i++ { - if i == keyESC { - // Not handled in detectSequence(), so not part of the base test - // suite. - continue - } - td = append(td, seqTest{[]byte{byte(i)}, KeyMsg{Type: i}}) - td = append(td, seqTest{[]byte{'\x1b', byte(i)}, KeyMsg{Type: i, Alt: true}}) - if i == keyUS { - i = keyDEL - 1 + k := KeyPressMsg(key) + st := seqTest{seq: []byte(seq), msgs: []Msg{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.msgs = []Msg{k, CursorPositionMsg{Row: 1, Column: int(key.Mod) + 1}} } + td = append(td, st) } // Additional special cases. @@ -95,102 +88,129 @@ func buildBaseSeqTests() []seqTest { // Unrecognized CSI sequence. seqTest{ []byte{'\x1b', '[', '-', '-', '-', '-', 'X'}, - unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}), + []Msg{ + UnknownMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'}), + }, }, // A lone space character. seqTest{ []byte{' '}, - KeyMsg{Type: KeySpace, Runes: []rune(" ")}, + []Msg{ + KeyPressMsg{Type: KeySpace, Runes: []rune{' '}}, + }, }, // An escape character with the alt modifier. seqTest{ []byte{'\x1b', ' '}, - KeyMsg{Type: KeySpace, Runes: []rune(" "), Alt: true}, + []Msg{ + KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModAlt}, + }, }, ) return td } -func TestDetectSequence(t *testing.T) { +func TestParseSequence(t *testing.T) { td := buildBaseSeqTests() - for _, tc := range td { - t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) { - hasSeq, width, msg := detectSequence(tc.seq) - if !hasSeq { - t.Fatalf("no sequence found") - } - if width != len(tc.seq) { - t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq)) - } - if !reflect.DeepEqual(tc.msg, msg) { - t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg) - } - }) - } -} - -func TestDetectOneMsg(t *testing.T) { - td := buildBaseSeqTests() - // Add tests for the inputs that detectOneMsg() can parse, but - // detectSequence() cannot. td = append(td, + // focus/blur + seqTest{ + []byte{'\x1b', '[', 'I'}, + []Msg{ + FocusMsg{}, + }, + }, + seqTest{ + []byte{'\x1b', '[', 'O'}, + []Msg{ + BlurMsg{}, + }, + }, // Mouse event. seqTest{ []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, - MouseMsg{X: 32, Y: 16, Type: MouseWheelUp, Button: MouseButtonWheelUp, Action: MouseActionPress}, + []Msg{ + MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelUp}, + }, }, // SGR Mouse event. seqTest{ []byte("\x1b[<0;33;17M"), - MouseMsg{X: 32, Y: 16, Type: MouseLeft, Button: MouseButtonLeft, Action: MouseActionPress}, + []Msg{ + MouseClickMsg{X: 32, Y: 16, Button: MouseLeft}, + }, }, // Runes. seqTest{ []byte{'a'}, - KeyMsg{Type: KeyRunes, Runes: []rune("a")}, + []Msg{ + KeyPressMsg{Runes: []rune{'a'}}, + }, }, seqTest{ []byte{'\x1b', 'a'}, - KeyMsg{Type: KeyRunes, Runes: []rune("a"), Alt: true}, + []Msg{ + KeyPressMsg{Runes: []rune{'a'}, Mod: ModAlt}, + }, }, seqTest{ []byte{'a', 'a', 'a'}, - KeyMsg{Type: KeyRunes, Runes: []rune("aaa")}, + []Msg{ + KeyPressMsg{Runes: []rune{'a'}}, + KeyPressMsg{Runes: []rune{'a'}}, + KeyPressMsg{Runes: []rune{'a'}}, + }, }, // Multi-byte rune. seqTest{ []byte("☃"), - KeyMsg{Type: KeyRunes, Runes: []rune("☃")}, + []Msg{ + KeyPressMsg{Runes: []rune{'☃'}}, + }, }, seqTest{ []byte("\x1b☃"), - KeyMsg{Type: KeyRunes, Runes: []rune("☃"), Alt: true}, + []Msg{ + KeyPressMsg{Runes: []rune{'☃'}, Mod: ModAlt}, + }, }, // Standalone control chacters. seqTest{ []byte{'\x1b'}, - KeyMsg{Type: KeyEscape}, + []Msg{ + KeyPressMsg{Type: KeyEscape}, + }, }, seqTest{ - []byte{byte(keySOH)}, - KeyMsg{Type: KeyCtrlA}, + []byte{ansi.SOH}, + []Msg{ + KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl}, + }, }, seqTest{ - []byte{'\x1b', byte(keySOH)}, - KeyMsg{Type: KeyCtrlA, Alt: true}, + []byte{'\x1b', ansi.SOH}, + []Msg{ + KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl | ModAlt}, + }, }, seqTest{ - []byte{byte(keyNUL)}, - KeyMsg{Type: KeyCtrlAt}, + []byte{ansi.NUL}, + []Msg{ + KeyPressMsg{Runes: []rune{' '}, Type: KeySpace, Mod: ModCtrl}, + }, }, seqTest{ - []byte{'\x1b', byte(keyNUL)}, - KeyMsg{Type: KeyCtrlAt, Alt: true}, + []byte{'\x1b', ansi.NUL}, + []Msg{ + KeyPressMsg{Runes: []rune{' '}, Type: KeySpace, Mod: ModCtrl | ModAlt}, + }, }, - // Invalid characters. + // C1 control characters. seqTest{ []byte{'\x80'}, - unknownInputByteMsg(0x80), + []Msg{ + KeyPressMsg{Runes: []rune{0x80 - '@'}, Mod: ModCtrl | ModAlt}, + }, }, ) @@ -199,39 +219,58 @@ func TestDetectOneMsg(t *testing.T) { // This is incorrect, but it makes our test fail if we try it out. td = append(td, seqTest{ []byte{'\xfe'}, - unknownInputByteMsg(0xfe), + []Msg{ + UnknownMsg(rune(0xfe)), + }, }) } for _, tc := range td { t.Run(fmt.Sprintf("%q", string(tc.seq)), func(t *testing.T) { - width, msg := detectOneMsg(tc.seq, false /* canHaveMoreData */) - if width != len(tc.seq) { - t.Errorf("parser did not consume the entire input: got %d, expected %d", width, len(tc.seq)) + var events []Msg + buf := tc.seq + for len(buf) > 0 { + width, msg := parseSequence(buf) + switch msg := msg.(type) { + case multiMsg: + events = append(events, msg...) + default: + events = append(events, msg) + } + buf = buf[width:] } - if !reflect.DeepEqual(tc.msg, msg) { - t.Errorf("expected event %#v (%T), got %#v (%T)", tc.msg, tc.msg, msg, msg) + if !reflect.DeepEqual(tc.msgs, events) { + t.Errorf("\nexpected event:\n %#v\ngot:\n %#v", tc.msgs, events) } }) } } func TestReadLongInput(t *testing.T) { - input := strings.Repeat("a", 1000) - msgs := testReadInputs(t, bytes.NewReader([]byte(input))) - if len(msgs) != 1 { - t.Errorf("expected 1 messages, got %d", len(msgs)) + expect := make([]Msg, 1000) + for i := 0; i < 1000; i++ { + expect[i] = KeyPressMsg{Runes: []rune{'a'}} } - km := msgs[0] - k := Key(km.(KeyMsg)) - if k.Type != KeyRunes { - t.Errorf("expected key runes, got %d", k.Type) + input := strings.Repeat("a", 1000) + drv, err := newDriver(strings.NewReader(input), "dumb", 0) + if err != nil { + t.Fatalf("unexpected input driver error: %v", err) } - if len(k.Runes) != 1000 || !reflect.DeepEqual(k.Runes, []rune(input)) { - t.Errorf("unexpected runes: %+v", k) + + var msgs []Msg + for { + events, err := drv.ReadEvents() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("unexpected input error: %v", err) + } + msgs = append(msgs, events...) } - if k.Alt { - t.Errorf("unexpected alt") + + if !reflect.DeepEqual(expect, msgs) { + t.Errorf("unexpected messages, expected:\n %+v\ngot:\n %+v", expect, msgs) } } @@ -242,199 +281,185 @@ func TestReadInput(t *testing.T) { out []Msg } testData := []test{ - {"a", + { + "a", []byte{'a'}, []Msg{ - KeyMsg{ - Type: KeyRunes, - Runes: []rune{'a'}, - }, + KeyPressMsg{Runes: []rune{'a'}}, }, }, - {" ", + { + "space", []byte{' '}, []Msg{ - KeyMsg{ - Type: KeySpace, - Runes: []rune{' '}, - }, + KeyPressMsg{Type: KeySpace, Runes: []rune{' '}}, }, }, - {"a alt+a", + { + "a alt+a", []byte{'a', '\x1b', 'a'}, []Msg{ - KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, - KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}, + KeyPressMsg{Runes: []rune{'a'}}, + KeyPressMsg{Runes: []rune{'a'}, Mod: ModAlt}, }, }, - {"a alt+a a", + { + "a alt+a a", []byte{'a', '\x1b', 'a', 'a'}, []Msg{ - KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, - KeyMsg{Type: KeyRunes, Runes: []rune{'a'}, Alt: true}, - KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, + KeyPressMsg{Runes: []rune{'a'}}, + KeyPressMsg{Runes: []rune{'a'}, Mod: ModAlt}, + KeyPressMsg{Runes: []rune{'a'}}, }, }, - {"ctrl+a", - []byte{byte(keySOH)}, + { + "ctrl+a", + []byte{byte(ansi.SOH)}, []Msg{ - KeyMsg{ - Type: KeyCtrlA, - }, + KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl}, }, }, - {"ctrl+a ctrl+b", - []byte{byte(keySOH), byte(keySTX)}, + { + "ctrl+a ctrl+b", + []byte{byte(ansi.SOH), byte(ansi.STX)}, []Msg{ - KeyMsg{Type: KeyCtrlA}, - KeyMsg{Type: KeyCtrlB}, + KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl}, + KeyPressMsg{Runes: []rune{'b'}, Mod: ModCtrl}, }, }, - {"alt+a", + { + "alt+a", []byte{byte(0x1b), 'a'}, []Msg{ - KeyMsg{ - Type: KeyRunes, - Alt: true, - Runes: []rune{'a'}, - }, + KeyPressMsg{Mod: ModAlt, Runes: []rune{'a'}}, }, }, - {"abcd", + { + "a b c d", []byte{'a', 'b', 'c', 'd'}, []Msg{ - KeyMsg{ - Type: KeyRunes, - Runes: []rune{'a', 'b', 'c', 'd'}, - }, + KeyPressMsg{Runes: []rune{'a'}}, + KeyPressMsg{Runes: []rune{'b'}}, + KeyPressMsg{Runes: []rune{'c'}}, + KeyPressMsg{Runes: []rune{'d'}}, }, }, - {"up", + { + "up", []byte("\x1b[A"), []Msg{ - KeyMsg{ - Type: KeyUp, - }, + KeyPressMsg{Type: KeyUp}, }, }, - {"wheel up", + { + "wheel up", []byte{'\x1b', '[', 'M', byte(32) + 0b0100_0000, byte(65), byte(49)}, []Msg{ - MouseMsg{ - X: 32, - Y: 16, - Type: MouseWheelUp, - Button: MouseButtonWheelUp, - Action: MouseActionPress, - }, + MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelUp}, }, }, - {"left motion release", + { + "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), }, []Msg{ - MouseMsg(MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, - Button: MouseButtonLeft, - Action: MouseActionMotion, - }), - MouseMsg(MouseEvent{ - X: 64, - Y: 32, - Type: MouseRelease, - Button: MouseButtonNone, - Action: MouseActionRelease, - }), + MouseMotionMsg{X: 32, Y: 16, Button: MouseLeft}, + MouseReleaseMsg{X: 64, Y: 32, Button: MouseNone}, }, }, - {"shift+tab", + { + "shift+tab", []byte{'\x1b', '[', 'Z'}, []Msg{ - KeyMsg{ - Type: KeyShiftTab, - }, + KeyPressMsg{Type: KeyTab, Mod: ModShift}, }, }, - {"enter", + { + "enter", []byte{'\r'}, - []Msg{KeyMsg{Type: KeyEnter}}, + []Msg{KeyPressMsg{Type: KeyEnter}}, }, - {"alt+enter", + { + "alt+enter", []byte{'\x1b', '\r'}, []Msg{ - KeyMsg{ - Type: KeyEnter, - Alt: true, - }, + KeyPressMsg{Type: KeyEnter, Mod: ModAlt}, }, }, - {"insert", + { + "insert", []byte{'\x1b', '[', '2', '~'}, []Msg{ - KeyMsg{ - Type: KeyInsert, - }, + KeyPressMsg{Type: KeyInsert}, }, }, - {"alt+ctrl+a", - []byte{'\x1b', byte(keySOH)}, + { + "ctrl+alt+a", + []byte{'\x1b', byte(ansi.SOH)}, []Msg{ - KeyMsg{ - Type: KeyCtrlA, - Alt: true, - }, + KeyPressMsg{Runes: []rune{'a'}, Mod: ModCtrl | ModAlt}, }, }, - {"?CSI[45 45 45 45 88]?", + { + "CSI?----X?", []byte{'\x1b', '[', '-', '-', '-', '-', 'X'}, - []Msg{unknownCSISequenceMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})}, + []Msg{UnknownMsg([]byte{'\x1b', '[', '-', '-', '-', '-', 'X'})}, }, // Powershell sequences. - {"up", + { + "up", []byte{'\x1b', 'O', 'A'}, - []Msg{KeyMsg{Type: KeyUp}}, + []Msg{KeyPressMsg{Type: KeyUp}}, }, - {"down", + { + "down", []byte{'\x1b', 'O', 'B'}, - []Msg{KeyMsg{Type: KeyDown}}, + []Msg{KeyPressMsg{Type: KeyDown}}, }, - {"right", + { + "right", []byte{'\x1b', 'O', 'C'}, - []Msg{KeyMsg{Type: KeyRight}}, + []Msg{KeyPressMsg{Type: KeyRight}}, }, - {"left", + { + "left", []byte{'\x1b', 'O', 'D'}, - []Msg{KeyMsg{Type: KeyLeft}}, + []Msg{KeyPressMsg{Type: KeyLeft}}, }, - {"alt+enter", + { + "alt+enter", []byte{'\x1b', '\x0d'}, - []Msg{KeyMsg{Type: KeyEnter, Alt: true}}, + []Msg{KeyPressMsg{Type: KeyEnter, Mod: ModAlt}}, }, - {"alt+backspace", + { + "alt+backspace", []byte{'\x1b', '\x7f'}, - []Msg{KeyMsg{Type: KeyBackspace, Alt: true}}, + []Msg{KeyPressMsg{Type: KeyBackspace, Mod: ModAlt}}, }, - {"ctrl+@", + { + "ctrl+space", []byte{'\x00'}, - []Msg{KeyMsg{Type: KeyCtrlAt}}, + []Msg{KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModCtrl}}, }, - {"alt+ctrl+@", + { + "ctrl+alt+space", []byte{'\x1b', '\x00'}, - []Msg{KeyMsg{Type: KeyCtrlAt, Alt: true}}, + []Msg{KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModCtrl | ModAlt}}, }, - {"esc", + { + "esc", []byte{'\x1b'}, - []Msg{KeyMsg{Type: KeyEsc}}, + []Msg{KeyPressMsg{Type: KeyEscape}}, }, - {"alt+esc", + { + "alt+esc", []byte{'\x1b', '\x1b'}, - []Msg{KeyMsg{Type: KeyEsc, Alt: true}}, + []Msg{KeyPressMsg{Type: KeyEscape, Mod: ModAlt}}, }, - {"[a b] o", + { + "a b o", []byte{ '\x1b', '[', '2', '0', '0', '~', 'a', ' ', 'b', @@ -442,38 +467,42 @@ func TestReadInput(t *testing.T) { 'o', }, []Msg{ - KeyMsg{Type: KeyRunes, Runes: []rune("a b"), Paste: true}, - KeyMsg{Type: KeyRunes, Runes: []rune("o")}, + PasteStartMsg{}, + PasteMsg("a b"), + PasteEndMsg{}, + KeyPressMsg{Runes: []rune{'o'}}, }, }, - {"[a\x03\nb]", + { + "a\x03\nb", []byte{ '\x1b', '[', '2', '0', '0', '~', 'a', '\x03', '\n', 'b', - '\x1b', '[', '2', '0', '1', '~'}, + '\x1b', '[', '2', '0', '1', '~', + }, []Msg{ - KeyMsg{Type: KeyRunes, Runes: []rune("a\x03\nb"), Paste: true}, + PasteStartMsg{}, + PasteMsg("a\x03\nb"), + PasteEndMsg{}, + }, + }, + { + "?0xfe?", + []byte{'\xfe'}, + []Msg{ + UnknownMsg(rune(0xfe)), + }, + }, + { + "a ?0xfe? b", + []byte{'a', '\xfe', ' ', 'b'}, + []Msg{ + KeyPressMsg{Runes: []rune{'a'}}, + UnknownMsg(rune(0xfe)), + KeyPressMsg{Type: KeySpace, Runes: []rune{' '}}, + KeyPressMsg{Runes: []rune{'b'}}, }, }, - } - 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. - testData = append(testData, - test{"?0xfe?", - []byte{'\xfe'}, - []Msg{unknownInputByteMsg(0xfe)}, - }, - test{"a ?0xfe? b", - []byte{'a', '\xfe', ' ', 'b'}, - []Msg{ - KeyMsg{Type: KeyRunes, Runes: []rune{'a'}}, - unknownInputByteMsg(0xfe), - KeyMsg{Type: KeySpace, Runes: []rune{' '}}, - KeyMsg{Type: KeyRunes, Runes: []rune{'b'}}, - }, - }, - ) } for i, td := range testData { @@ -491,13 +520,8 @@ func TestReadInput(t *testing.T) { } } - title := buf.String() - if title != td.keyname { - t.Errorf("expected message titles:\n %s\ngot:\n %s", td.keyname, title) - } - if len(msgs) != len(td.out) { - t.Fatalf("unexpected message list length: got %d, expected %d\n%#v", len(msgs), len(td.out), msgs) + t.Fatalf("unexpected message list length: got %d, expected %d\n got: %#v\n expected: %#v\n", len(msgs), len(td.out), msgs, td.out) } if !reflect.DeepEqual(td.out, msgs) { @@ -521,6 +545,11 @@ func testReadInputs(t *testing.T, input io.Reader) []Msg { } }() + dr, err := newDriver(input, "dumb", 0) + if err != nil { + t.Fatalf("unexpected input driver error: %v", err) + } + // The messages we're consuming. msgsC := make(chan Msg) @@ -528,7 +557,16 @@ func testReadInputs(t *testing.T, input io.Reader) []Msg { wg.Add(1) go func() { defer wg.Done() - inputErr = readAnsiInputs(ctx, msgsC, input) + var events []Msg + events, inputErr = dr.ReadEvents() + out: + for _, ev := range events { + select { + case msgsC <- ev: + case <-ctx.Done(): + break out + } + } msgsC <- nil }() @@ -612,14 +650,14 @@ func genRandomDataWithSeed(s int64, length int) randTest { res.data = append(res.data, '\x1b') } res.data = append(res.data, 1) - res.names = append(res.names, prefix+"ctrl+a") + 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.HasPrefix(s.name, "alt+") { + if strings.Contains(s.name, "alt+") || strings.Contains(s.name, "meta+") { esclen = 0 prefix = "" alt = 0 @@ -628,58 +666,31 @@ func genRandomDataWithSeed(s int64, length int) randTest { res.data = append(res.data, '\x1b') } res.data = append(res.data, s.seq...) - res.names = append(res.names, prefix+s.name) + 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 } -// TestDetectRandomSequencesLex checks that the lex-generated sequence -// detector works over concatenations of random sequences. -func TestDetectRandomSequencesLex(t *testing.T) { - runTestDetectSequence(t, detectSequence) -} - -func runTestDetectSequence( - t *testing.T, detectSequence func(input []byte) (hasSeq bool, width int, msg Msg), -) { - for i := 0; i < 10; i++ { - t.Run("", func(t *testing.T) { - td := genRandomData(func(s int64) { t.Logf("using random seed: %d", s) }, 1000) - - t.Logf("%#v", td) - - // tn is the event number in td. - // i is the cursor in the input data. - // w is the length of the last sequence detected. - for tn, i, w := 0, 0, 0; i < len(td.data); tn, i = tn+1, i+w { - hasSequence, width, msg := detectSequence(td.data[i:]) - if !hasSequence { - t.Fatalf("at %d (ev %d): failed to find sequence", i, tn) - } - if width != td.lengths[tn] { - t.Errorf("at %d (ev %d): expected width %d, got %d", i, tn, td.lengths[tn], width) - } - w = width - - s, ok := msg.(fmt.Stringer) - if !ok { - t.Errorf("at %d (ev %d): expected stringer event, got %T", i, tn, msg) - } else { - if td.names[tn] != s.String() { - t.Errorf("at %d (ev %d): expected event %q, got %q", i, tn, td.names[tn], s.String()) - } - } - } - }) +func FuzzParseSequence(f *testing.F) { + for seq := range sequences { + f.Add(seq) } -} - -// TestDetectRandomSequencesMap checks that the map-based sequence -// detector works over concatenations of random sequences. -func TestDetectRandomSequencesMap(t *testing.T) { - runTestDetectSequence(t, detectSequence) + 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, _ := parseSequence([]byte(seq)) + if n == 0 && seq != "" { + t.Errorf("expected a non-zero width for %q", seq) + } + }) } // BenchmarkDetectSequenceMap benchmarks the map-based sequence @@ -688,7 +699,7 @@ func BenchmarkDetectSequenceMap(b *testing.B) { td := genRandomDataWithSeed(123, 10000) for i := 0; i < b.N; i++ { for j, w := 0, 0; j < len(td.data); j += w { - _, w, _ = detectSequence(td.data[j:]) + w, _ = parseSequence(td.data[j:]) } } } diff --git a/key_windows.go b/key_windows.go deleted file mode 100644 index b693efd655..0000000000 --- a/key_windows.go +++ /dev/null @@ -1,351 +0,0 @@ -//go:build windows -// +build windows - -package tea - -import ( - "context" - "fmt" - "io" - - "github.com/erikgeiser/coninput" - localereader "github.com/mattn/go-localereader" - "golang.org/x/sys/windows" -) - -func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error { - if coninReader, ok := input.(*conInputReader); ok { - return readConInputs(ctx, msgs, coninReader.conin) - } - - return readAnsiInputs(ctx, msgs, localereader.NewReader(input)) -} - -func readConInputs(ctx context.Context, msgsch chan<- Msg, con windows.Handle) error { - var ps coninput.ButtonState // keep track of previous mouse state - var ws coninput.WindowBufferSizeEventRecord // keep track of the last window size event - for { - events, err := coninput.ReadNConsoleInputs(con, 16) - if err != nil { - return fmt.Errorf("read coninput events: %w", err) - } - - for _, event := range events { - var msgs []Msg - switch e := event.Unwrap().(type) { - case coninput.KeyEventRecord: - if !e.KeyDown || e.VirtualKeyCode == coninput.VK_SHIFT { - continue - } - - for i := 0; i < int(e.RepeatCount); i++ { - eventKeyType := keyType(e) - var runes []rune - - // Add the character only if the key type is an actual character and not a control sequence. - // This mimics the behavior in readAnsiInputs where the character is also removed. - // We don't need to handle KeySpace here. See the comment in keyType(). - if eventKeyType == KeyRunes { - runes = []rune{e.Char} - } - - msgs = append(msgs, KeyMsg{ - Type: eventKeyType, - Runes: runes, - Alt: e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED), - }) - } - case coninput.WindowBufferSizeEventRecord: - if e != ws { - ws = e - msgs = append(msgs, WindowSizeMsg{ - Width: int(e.Size.X), - Height: int(e.Size.Y), - }) - } - case coninput.MouseEventRecord: - event := mouseEvent(ps, e) - if event.Type != MouseUnknown { - msgs = append(msgs, event) - } - ps = e.ButtonState - case coninput.FocusEventRecord, coninput.MenuEventRecord: - // ignore - default: // unknown event - continue - } - - // Send all messages to the channel - for _, msg := range msgs { - select { - case msgsch <- msg: - case <-ctx.Done(): - err := ctx.Err() - if err != nil { - return fmt.Errorf("coninput context error: %w", err) - } - return err - } - } - } - } -} - -func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, action MouseAction) { - btn := p ^ s - action = MouseActionPress - if btn&s == 0 { - action = MouseActionRelease - } - - if btn == 0 { - switch { - case s&coninput.FROM_LEFT_1ST_BUTTON_PRESSED > 0: - button = MouseButtonLeft - case s&coninput.FROM_LEFT_2ND_BUTTON_PRESSED > 0: - button = MouseButtonMiddle - case s&coninput.RIGHTMOST_BUTTON_PRESSED > 0: - button = MouseButtonRight - case s&coninput.FROM_LEFT_3RD_BUTTON_PRESSED > 0: - button = MouseButtonBackward - case s&coninput.FROM_LEFT_4TH_BUTTON_PRESSED > 0: - button = MouseButtonForward - } - return - } - - switch { - case btn == coninput.FROM_LEFT_1ST_BUTTON_PRESSED: // left button - button = MouseButtonLeft - case btn == coninput.RIGHTMOST_BUTTON_PRESSED: // right button - button = MouseButtonRight - case btn == coninput.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button - button = MouseButtonMiddle - case btn == coninput.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward) - button = MouseButtonBackward - case btn == coninput.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward) - button = MouseButtonForward - } - - return button, action -} - -func mouseEvent(p coninput.ButtonState, e coninput.MouseEventRecord) MouseMsg { - ev := MouseMsg{ - X: int(e.MousePositon.X), - Y: int(e.MousePositon.Y), - Alt: e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED), - Ctrl: e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED), - Shift: e.ControlKeyState.Contains(coninput.SHIFT_PRESSED), - } - switch e.EventFlags { - case coninput.CLICK, coninput.DOUBLE_CLICK: - ev.Button, ev.Action = mouseEventButton(p, e.ButtonState) - if ev.Action == MouseActionRelease { - ev.Type = MouseRelease - } - switch ev.Button { - case MouseButtonLeft: - ev.Type = MouseLeft - case MouseButtonMiddle: - ev.Type = MouseMiddle - case MouseButtonRight: - ev.Type = MouseRight - case MouseButtonBackward: - ev.Type = MouseBackward - case MouseButtonForward: - ev.Type = MouseForward - } - case coninput.MOUSE_WHEELED: - if e.WheelDirection > 0 { - ev.Button = MouseButtonWheelUp - ev.Type = MouseWheelUp - } else { - ev.Button = MouseButtonWheelDown - ev.Type = MouseWheelDown - } - case coninput.MOUSE_HWHEELED: - if e.WheelDirection > 0 { - ev.Button = MouseButtonWheelRight - ev.Type = MouseWheelRight - } else { - ev.Button = MouseButtonWheelLeft - ev.Type = MouseWheelLeft - } - case coninput.MOUSE_MOVED: - ev.Button, _ = mouseEventButton(p, e.ButtonState) - ev.Action = MouseActionMotion - ev.Type = MouseMotion - } - - return ev -} - -func keyType(e coninput.KeyEventRecord) KeyType { - code := e.VirtualKeyCode - - shiftPressed := e.ControlKeyState.Contains(coninput.SHIFT_PRESSED) - ctrlPressed := e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED) - - switch code { - case coninput.VK_RETURN: - return KeyEnter - case coninput.VK_BACK: - return KeyBackspace - case coninput.VK_TAB: - if shiftPressed { - return KeyShiftTab - } - return KeyTab - case coninput.VK_SPACE: - return KeyRunes // this could be KeySpace but on unix space also produces KeyRunes - case coninput.VK_ESCAPE: - return KeyEscape - case coninput.VK_UP: - switch { - case shiftPressed && ctrlPressed: - return KeyCtrlShiftUp - case shiftPressed: - return KeyShiftUp - case ctrlPressed: - return KeyCtrlUp - default: - return KeyUp - } - case coninput.VK_DOWN: - switch { - case shiftPressed && ctrlPressed: - return KeyCtrlShiftDown - case shiftPressed: - return KeyShiftDown - case ctrlPressed: - return KeyCtrlDown - default: - return KeyDown - } - case coninput.VK_RIGHT: - switch { - case shiftPressed && ctrlPressed: - return KeyCtrlShiftRight - case shiftPressed: - return KeyShiftRight - case ctrlPressed: - return KeyCtrlRight - default: - return KeyRight - } - case coninput.VK_LEFT: - switch { - case shiftPressed && ctrlPressed: - return KeyCtrlShiftLeft - case shiftPressed: - return KeyShiftLeft - case ctrlPressed: - return KeyCtrlLeft - default: - return KeyLeft - } - case coninput.VK_HOME: - switch { - case shiftPressed && ctrlPressed: - return KeyCtrlShiftHome - case shiftPressed: - return KeyShiftHome - case ctrlPressed: - return KeyCtrlHome - default: - return KeyHome - } - case coninput.VK_END: - switch { - case shiftPressed && ctrlPressed: - return KeyCtrlShiftEnd - case shiftPressed: - return KeyShiftEnd - case ctrlPressed: - return KeyCtrlEnd - default: - return KeyEnd - } - case coninput.VK_PRIOR: - return KeyPgUp - case coninput.VK_NEXT: - return KeyPgDown - case coninput.VK_DELETE: - return KeyDelete - default: - if e.ControlKeyState&(coninput.LEFT_CTRL_PRESSED|coninput.RIGHT_CTRL_PRESSED) == 0 { - return KeyRunes - } - - switch e.Char { - case '@': - return KeyCtrlAt - case '\x01': - return KeyCtrlA - case '\x02': - return KeyCtrlB - case '\x03': - return KeyCtrlC - case '\x04': - return KeyCtrlD - case '\x05': - return KeyCtrlE - case '\x06': - return KeyCtrlF - case '\a': - return KeyCtrlG - case '\b': - return KeyCtrlH - case '\t': - return KeyCtrlI - case '\n': - return KeyCtrlJ - case '\v': - return KeyCtrlK - case '\f': - return KeyCtrlL - case '\r': - return KeyCtrlM - case '\x0e': - return KeyCtrlN - case '\x0f': - return KeyCtrlO - case '\x10': - return KeyCtrlP - case '\x11': - return KeyCtrlQ - case '\x12': - return KeyCtrlR - case '\x13': - return KeyCtrlS - case '\x14': - return KeyCtrlT - case '\x15': - return KeyCtrlU - case '\x16': - return KeyCtrlV - case '\x17': - return KeyCtrlW - case '\x18': - return KeyCtrlX - case '\x19': - return KeyCtrlY - case '\x1a': - return KeyCtrlZ - case '\x1b': - return KeyCtrlCloseBracket - case '\x1c': - return KeyCtrlBackslash - case '\x1f': - return KeyCtrlUnderscore - } - - switch code { - case coninput.VK_OEM_4: - return KeyCtrlOpenBracket - } - - return KeyRunes - } -} diff --git a/kitty.go b/kitty.go new file mode 100644 index 0000000000..ff6279d78d --- /dev/null +++ b/kitty.go @@ -0,0 +1,282 @@ +package tea + +import ( + "unicode" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" +) + +// KittyKeyboardMsg represents Kitty keyboard progressive enhancement flags message. +type KittyKeyboardMsg int + +// IsDisambiguateEscapeCodes returns true if the DisambiguateEscapeCodes flag is set. +func (e KittyKeyboardMsg) IsDisambiguateEscapeCodes() bool { + return e&ansi.KittyDisambiguateEscapeCodes != 0 +} + +// IsReportEventTypes returns true if the ReportEventTypes flag is set. +func (e KittyKeyboardMsg) IsReportEventTypes() bool { + return e&ansi.KittyReportEventTypes != 0 +} + +// IsReportAlternateKeys returns true if the ReportAlternateKeys flag is set. +func (e KittyKeyboardMsg) IsReportAlternateKeys() bool { + return e&ansi.KittyReportAlternateKeys != 0 +} + +// IsReportAllKeys returns true if the ReportAllKeys flag is set. +func (e KittyKeyboardMsg) IsReportAllKeys() bool { + return e&ansi.KittyReportAllKeys != 0 +} + +// IsReportAssociatedKeys returns true if the ReportAssociatedKeys flag is set. +func (e KittyKeyboardMsg) IsReportAssociatedKeys() bool { + return e&ansi.KittyReportAssociatedKeys != 0 +} + +// Kitty Clipboard Control Sequences +var kittyKeyMap = map[int]KeyType{ + ansi.BS: KeyBackspace, + ansi.HT: KeyTab, + ansi.CR: KeyEnter, + ansi.ESC: KeyEscape, + ansi.DEL: KeyBackspace, + + 57344: KeyEscape, + 57345: KeyEnter, + 57346: KeyTab, + 57347: KeyBackspace, + 57348: KeyInsert, + 57349: KeyDelete, + 57350: KeyLeft, + 57351: KeyRight, + 57352: KeyUp, + 57353: KeyDown, + 57354: KeyPgUp, + 57355: KeyPgDown, + 57356: KeyHome, + 57357: KeyEnd, + 57358: KeyCapsLock, + 57359: KeyScrollLock, + 57360: KeyNumLock, + 57361: KeyPrintScreen, + 57362: KeyPause, + 57363: KeyMenu, + 57364: KeyF1, + 57365: KeyF2, + 57366: KeyF3, + 57367: KeyF4, + 57368: KeyF5, + 57369: KeyF6, + 57370: KeyF7, + 57371: KeyF8, + 57372: KeyF9, + 57373: KeyF10, + 57374: KeyF11, + 57375: KeyF12, + 57376: KeyF13, + 57377: KeyF14, + 57378: KeyF15, + 57379: KeyF16, + 57380: KeyF17, + 57381: KeyF18, + 57382: KeyF19, + 57383: KeyF20, + 57384: KeyF21, + 57385: KeyF22, + 57386: KeyF23, + 57387: KeyF24, + 57388: KeyF25, + 57389: KeyF26, + 57390: KeyF27, + 57391: KeyF28, + 57392: KeyF29, + 57393: KeyF30, + 57394: KeyF31, + 57395: KeyF32, + 57396: KeyF33, + 57397: KeyF34, + 57398: KeyF35, + 57399: KeyKp0, + 57400: KeyKp1, + 57401: KeyKp2, + 57402: KeyKp3, + 57403: KeyKp4, + 57404: KeyKp5, + 57405: KeyKp6, + 57406: KeyKp7, + 57407: KeyKp8, + 57408: KeyKp9, + 57409: KeyKpDecimal, + 57410: KeyKpDivide, + 57411: KeyKpMultiply, + 57412: KeyKpMinus, + 57413: KeyKpPlus, + 57414: KeyKpEnter, + 57415: KeyKpEqual, + 57416: KeyKpSep, + 57417: KeyKpLeft, + 57418: KeyKpRight, + 57419: KeyKpUp, + 57420: KeyKpDown, + 57421: KeyKpPgUp, + 57422: KeyKpPgDown, + 57423: KeyKpHome, + 57424: KeyKpEnd, + 57425: KeyKpInsert, + 57426: KeyKpDelete, + 57427: KeyKpBegin, + 57428: KeyMediaPlay, + 57429: KeyMediaPause, + 57430: KeyMediaPlayPause, + 57431: KeyMediaReverse, + 57432: KeyMediaStop, + 57433: KeyMediaFastForward, + 57434: KeyMediaRewind, + 57435: KeyMediaNext, + 57436: KeyMediaPrev, + 57437: KeyMediaRecord, + 57438: KeyLowerVol, + 57439: KeyRaiseVol, + 57440: KeyMute, + 57441: KeyLeftShift, + 57442: KeyLeftCtrl, + 57443: KeyLeftAlt, + 57444: KeyLeftSuper, + 57445: KeyLeftHyper, + 57446: KeyLeftMeta, + 57447: KeyRightShift, + 57448: KeyRightCtrl, + 57449: KeyRightAlt, + 57450: KeyRightSuper, + 57451: KeyRightHyper, + 57452: KeyRightMeta, + 57453: KeyIsoLevel3Shift, + 57454: KeyIsoLevel5Shift, +} + +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(csi *ansi.CsiSequence) Msg { + var isRelease bool + key := Key{} + + if params := csi.Subparams(0); len(params) > 0 { + code := params[0] + if sym, ok := kittyKeyMap[code]; ok { + key.Type = sym + } else { + r := rune(code) + if !utf8.ValidRune(r) { + r = utf8.RuneError + } + + key.Type = KeyRunes + key.Runes = []rune{r} + + // alternate key reporting + switch len(params) { + case 3: + // shifted key + base key + if b := rune(params[2]); 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.baseRune = b + } + fallthrough + case 2: + // shifted key + if s := rune(params[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.altRune = key.Rune() + key.Runes = []rune{s} + } + } + } + } + if params := csi.Subparams(1); len(params) > 0 { + mod := params[0] + if mod > 1 { + key.Mod = fromKittyMod(mod - 1) + } + if len(params) > 1 { + switch params[1] { + case 2: + key.IsRepeat = true + case 3: + isRelease = true + } + } + } + // TODO: Associated keys are not support yet. + // if params := csi.Subparams(2); len(params) > 0 { + // r := rune(params[0]) + // if unicode.IsPrint(r) { + // key.AltRune = r + // } + // } + if isRelease { + return KeyReleaseMsg(key) + } + return KeyPressMsg(key) +} diff --git a/mod.go b/mod.go new file mode 100644 index 0000000000..9bb43a0cb3 --- /dev/null +++ b/mod.go @@ -0,0 +1,37 @@ +package tea + +// KeyMod represents modifier keys. +type KeyMod uint16 + +// 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/mode.go b/mode.go new file mode 100644 index 0000000000..5dfcbd0621 --- /dev/null +++ b/mode.go @@ -0,0 +1,12 @@ +package tea + +// ReportModeMsg is a message that represents a mode report event (DECRPM). +// +// See: https://vt100.net/docs/vt510-rm/DECRPM.html +type ReportModeMsg struct { + // Mode is the mode number. + Mode int + + // Value is the mode value. + Value int +} diff --git a/mouse.go b/mouse.go index 6ec51cc0c0..f3460fa8f8 100644 --- a/mouse.go +++ b/mouse.go @@ -1,89 +1,10 @@ package tea -import "strconv" - -// MouseMsg contains information about a mouse event and are sent to a programs -// update function when mouse activity occurs. Note that the mouse must first -// be enabled in order for the mouse events to be received. -type MouseMsg MouseEvent - -// String returns a string representation of a mouse event. -func (m MouseMsg) String() string { - return MouseEvent(m).String() -} - -// MouseEvent represents a mouse event, which could be a click, a scroll wheel -// movement, a cursor movement, or a combination. -type MouseEvent struct { - X int - Y int - Shift bool - Alt bool - Ctrl bool - Action MouseAction - Button MouseButton - - // Deprecated: Use MouseAction & MouseButton instead. - Type MouseEventType -} - -// IsWheel returns true if the mouse event is a wheel event. -func (m MouseEvent) IsWheel() bool { - return m.Button == MouseButtonWheelUp || m.Button == MouseButtonWheelDown || - m.Button == MouseButtonWheelLeft || m.Button == MouseButtonWheelRight -} - -// String returns a string representation of a mouse event. -func (m MouseEvent) String() (s string) { - if m.Ctrl { - s += "ctrl+" - } - if m.Alt { - s += "alt+" - } - if m.Shift { - s += "shift+" - } - - if m.Button == MouseButtonNone { //nolint:nestif - if m.Action == MouseActionMotion || m.Action == MouseActionRelease { - s += mouseActions[m.Action] - } else { - s += "unknown" - } - } else if m.IsWheel() { - s += mouseButtons[m.Button] - } else { - btn := mouseButtons[m.Button] - if btn != "" { - s += btn - } - act := mouseActions[m.Action] - if act != "" { - s += " " + act - } - } - - return s -} - -// MouseAction represents the action that occurred during a mouse event. -type MouseAction int - -// Mouse event actions. -const ( - MouseActionPress MouseAction = iota - MouseActionRelease - MouseActionMotion +import ( + "github.com/charmbracelet/x/ansi" ) -var mouseActions = map[MouseAction]string{ - MouseActionPress: "press", - MouseActionRelease: "release", - MouseActionMotion: "motion", -} - -// MouseButton represents the button that was pressed during a mouse event. +// MouseButton represents the button that was pressed during a mouse message. type MouseButton int // Mouse event buttons @@ -104,58 +25,100 @@ type MouseButton int // // Other buttons are not supported. const ( - MouseButtonNone MouseButton = iota - MouseButtonLeft - MouseButtonMiddle - MouseButtonRight - MouseButtonWheelUp - MouseButtonWheelDown - MouseButtonWheelLeft - MouseButtonWheelRight - MouseButtonBackward - MouseButtonForward - MouseButton10 - MouseButton11 -) - -var mouseButtons = map[MouseButton]string{ - MouseButtonNone: "none", - MouseButtonLeft: "left", - MouseButtonMiddle: "middle", - MouseButtonRight: "right", - MouseButtonWheelUp: "wheel up", - MouseButtonWheelDown: "wheel down", - MouseButtonWheelLeft: "wheel left", - MouseButtonWheelRight: "wheel right", - MouseButtonBackward: "backward", - MouseButtonForward: "forward", - MouseButton10: "button 10", - MouseButton11: "button 11", -} - -// MouseEventType indicates the type of mouse event occurring. -// -// Deprecated: Use MouseAction & MouseButton instead. -type MouseEventType int - -// Mouse event types. -// -// Deprecated: Use MouseAction & MouseButton instead. -const ( - MouseUnknown MouseEventType = iota + MouseNone MouseButton = iota MouseLeft - MouseRight MouseMiddle - MouseRelease // mouse button release (X10 only) + MouseRight MouseWheelUp MouseWheelDown MouseWheelLeft MouseWheelRight MouseBackward MouseForward - MouseMotion + MouseExtra1 + MouseExtra2 ) +var mouseButtons = map[MouseButton]string{ + MouseNone: "none", + MouseLeft: "left", + MouseMiddle: "middle", + MouseRight: "right", + MouseWheelUp: "wheelup", + MouseWheelDown: "wheeldown", + MouseWheelLeft: "wheelleft", + MouseWheelRight: "wheelright", + MouseBackward: "backward", + MouseForward: "forward", + MouseExtra1: "button10", + MouseExtra2: "button11", +} + +// Mouse represents a mouse message. +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, ok := mouseButtons[m.Button] + if !ok { + s += "unknown" + } else if str != "none" { // motion events don't have a button + s += str + } + + return s +} + +// MouseClickMsg represents a mouse button click message. +type MouseClickMsg Mouse + +// String returns a string representation of the mouse click message. +func (e MouseClickMsg) String() string { + return Mouse(e).String() +} + +// MouseReleaseMsg represents a mouse button release message. +type MouseReleaseMsg Mouse + +// String returns a string representation of the mouse release message. +func (e MouseReleaseMsg) String() string { + return Mouse(e).String() +} + +// MouseWheelMsg represents a mouse wheel message event. +type MouseWheelMsg Mouse + +// String returns a string representation of the mouse wheel message. +func (e MouseWheelMsg) String() string { + return Mouse(e).String() +} + +// MouseMotionMsg represents a mouse motion message. +type MouseMotionMsg Mouse + +// String returns a string representation of the mouse motion message. +func (e MouseMotionMsg) String() string { + m := Mouse(e) + if m.Button != 0 { + return m.String() + "+motion" + } + return m.String() + "motion" +} + // Parse SGR-encoded mouse events; SGR extended mouse events. SGR mouse events // look like: // @@ -169,35 +132,28 @@ const ( // M is for button press, m is for button release // // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates -func parseSGRMouseEvent(buf []byte) MouseEvent { - str := string(buf[3:]) - matches := mouseSGRRegex.FindStringSubmatch(str) - if len(matches) != 5 { //nolint:gomnd - // Unreachable, we already checked the regex in `detectOneMsg`. - panic("invalid mouse event") - } +func parseSGRMouseEvent(csi *ansi.CsiSequence) Msg { + x := csi.Param(1) + y := csi.Param(2) + release := csi.Command() == 'm' + mod, btn, _, isMotion := parseMouseButton(csi.Param(0)) + + // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). + x-- + y-- - b, _ := strconv.Atoi(matches[1]) - px := matches[2] - py := matches[3] - release := matches[4] == "m" - m := parseMouseButton(b, true) + 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 m.Action != MouseActionMotion && !m.IsWheel() && release { - m.Action = MouseActionRelease - m.Type = MouseRelease + if isWheel(m.Button) { + return MouseWheelMsg(m) + } else if !isMotion && release { + return MouseReleaseMsg(m) + } else if isMotion { + return MouseMotionMsg(m) } - - x, _ := strconv.Atoi(px) - y, _ := strconv.Atoi(py) - - // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). - m.X = x - 1 - m.Y = y - 1 - - return m + return MouseClickMsg(m) } const x10MouseByteOffset = 32 @@ -211,25 +167,34 @@ const x10MouseByteOffset = 32 // ESC [M Cb Cx Cy // // See: http://www.xfree86.org/current/ctlseqs.html#Mouse%20Tracking -func parseX10MouseEvent(buf []byte) MouseEvent { +func parseX10MouseEvent(buf []byte) Msg { v := buf[3:6] - m := parseMouseButton(int(v[0]), false) + b := int(v[0]) + if b >= x10MouseByteOffset { + // XXX: b < 32 should be impossible, but we're being defensive. + b -= x10MouseByteOffset + } - // (1,1) is the upper left. We subtract 1 to normalize it to (0,0). - m.X = int(v[1]) - x10MouseByteOffset - 1 - m.Y = int(v[2]) - x10MouseByteOffset - 1 + mod, btn, isRelease, isMotion := parseMouseButton(b) - return m + // (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 MouseWheelMsg(m) + } else if isMotion { + return MouseMotionMsg(m) + } else if isRelease { + return MouseReleaseMsg(m) + } + return MouseClickMsg(m) } // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Extended-coordinates -func parseMouseButton(b int, isSGR bool) MouseEvent { - var m MouseEvent - e := b - if !isSGR { - e -= x10MouseByteOffset - } - +func parseMouseButton(b int) (mod KeyMod, btn MouseButton, isRelease bool, isMotion bool) { + // mouse bit shifts const ( bitShift = 0b0000_0100 bitAlt = 0b0000_1000 @@ -241,68 +206,39 @@ func parseMouseButton(b int, isSGR bool) MouseEvent { bitsMask = 0b0000_0011 ) - if e&bitAdd != 0 { - m.Button = MouseButtonBackward + MouseButton(e&bitsMask) - } else if e&bitWheel != 0 { - m.Button = MouseButtonWheelUp + MouseButton(e&bitsMask) + // 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 { - m.Button = MouseButtonLeft + MouseButton(e&bitsMask) + btn = MouseLeft + MouseButton(b&bitsMask) // X10 reports a button release as 0b0000_0011 (3) - if e&bitsMask == bitsMask { - m.Action = MouseActionRelease - m.Button = MouseButtonNone + if b&bitsMask == bitsMask { + btn = MouseNone + isRelease = true } } // Motion bit doesn't get reported for wheel events. - if e&bitMotion != 0 && !m.IsWheel() { - m.Action = MouseActionMotion + if b&bitMotion != 0 && !isWheel(btn) { + isMotion = true } - // Modifiers - m.Alt = e&bitAlt != 0 - m.Ctrl = e&bitCtrl != 0 - m.Shift = e&bitShift != 0 - - // backward compatibility - switch { - case m.Button == MouseButtonLeft && m.Action == MouseActionPress: - m.Type = MouseLeft - case m.Button == MouseButtonMiddle && m.Action == MouseActionPress: - m.Type = MouseMiddle - case m.Button == MouseButtonRight && m.Action == MouseActionPress: - m.Type = MouseRight - case m.Button == MouseButtonNone && m.Action == MouseActionRelease: - m.Type = MouseRelease - case m.Button == MouseButtonWheelUp && m.Action == MouseActionPress: - m.Type = MouseWheelUp - case m.Button == MouseButtonWheelDown && m.Action == MouseActionPress: - m.Type = MouseWheelDown - case m.Button == MouseButtonWheelLeft && m.Action == MouseActionPress: - m.Type = MouseWheelLeft - case m.Button == MouseButtonWheelRight && m.Action == MouseActionPress: - m.Type = MouseWheelRight - case m.Button == MouseButtonBackward && m.Action == MouseActionPress: - m.Type = MouseBackward - case m.Button == MouseButtonForward && m.Action == MouseActionPress: - m.Type = MouseForward - case m.Action == MouseActionMotion: - m.Type = MouseMotion - switch m.Button { //nolint:exhaustive - case MouseButtonLeft: - m.Type = MouseLeft - case MouseButtonMiddle: - m.Type = MouseMiddle - case MouseButtonRight: - m.Type = MouseRight - case MouseButtonBackward: - m.Type = MouseBackward - case MouseButtonForward: - m.Type = MouseForward - } - default: - m.Type = MouseUnknown - } + return +} - return m +// isWheel returns true if the mouse event is a wheel event. +func isWheel(btn MouseButton) bool { + return btn >= MouseWheelUp && btn <= MouseWheelRight } diff --git a/mouse_deprecated.go b/mouse_deprecated.go new file mode 100644 index 0000000000..714b63fd22 --- /dev/null +++ b/mouse_deprecated.go @@ -0,0 +1,162 @@ +package tea + +// MouseMsg contains information about a mouse event and are sent to a programs +// update function when mouse activity occurs. Note that the mouse must first +// be enabled in order for the mouse events to be received. +// +// Deprecated in favor of MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, and +// MouseMotionMsg. +type MouseMsg struct { + X int + Y int + Shift bool + Alt bool + Ctrl bool + Action MouseAction + Button MouseButton + Type MouseEventType +} + +// MouseEvent represents a mouse event. +// +// Deprecated: Use Mouse. +type MouseEvent = MouseMsg + +// IsWheel returns true if the mouse event is a wheel event. +func (m MouseMsg) IsWheel() bool { + return m.Button == MouseButtonWheelUp || m.Button == MouseButtonWheelDown || + m.Button == MouseButtonWheelLeft || m.Button == MouseButtonWheelRight +} + +// String returns a string representation of a mouse event. +func (m MouseMsg) String() (s string) { + if m.Ctrl { + s += "ctrl+" + } + if m.Alt { + s += "alt+" + } + if m.Shift { + s += "shift+" + } + + if m.Button == MouseButtonNone { //nolint:nestif + if m.Action == MouseActionMotion || m.Action == MouseActionRelease { + s += mouseMsgActions[m.Action] + } else { + s += "unknown" + } + } else if m.IsWheel() { + s += mouseMsgButtons[m.Button] + } else { + btn := mouseMsgButtons[m.Button] + if btn != "" { + s += btn + } + act := mouseMsgActions[m.Action] + if act != "" { + s += " " + act + } + } + + return s +} + +// MouseAction represents the action that occurred during a mouse event. +// +// Deprecated: Use MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, and +// MouseMotionMsg. +type MouseAction int + +// Mouse event actions. +// +// Deprecated in favor of MouseClickMsg, MouseReleaseMsg, MouseWheelMsg, and +// MouseMotionMsg. +const ( + MouseActionPress MouseAction = iota + MouseActionRelease + MouseActionMotion +) + +var mouseMsgActions = map[MouseAction]string{ + MouseActionPress: "press", + MouseActionRelease: "release", + MouseActionMotion: "motion", +} + +// 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. +// +// Deprecated: Use MouseNone, MouseLeft, etc. +const ( + MouseButtonNone = MouseNone + MouseButtonLeft = MouseLeft + MouseButtonMiddle = MouseMiddle + MouseButtonRight = MouseRight + MouseButtonWheelUp = MouseWheelUp + MouseButtonWheelDown = MouseWheelDown + MouseButtonWheelLeft = MouseWheelLeft + MouseButtonWheelRight = MouseWheelRight + MouseButtonBackward = MouseBackward + MouseButtonForward = MouseForward + MouseButton10 = MouseExtra1 + MouseButton11 = MouseExtra2 +) + +// Deprecated: Use mouseButtons. +var mouseMsgButtons = map[MouseButton]string{ + MouseButtonNone: "none", + MouseButtonLeft: "left", + MouseButtonMiddle: "middle", + MouseButtonRight: "right", + MouseButtonWheelUp: "wheel up", + MouseButtonWheelDown: "wheel down", + MouseButtonWheelLeft: "wheel left", + MouseButtonWheelRight: "wheel right", + MouseButtonBackward: "backward", + MouseButtonForward: "forward", + MouseButton10: "button 10", + MouseButton11: "button 11", +} + +// MouseEventType indicates the type of mouse event occurring. +// +// Deprecated: Use MouseButton. +type MouseEventType = MouseButton + +// Mouse event types. +// +// Deprecated in favor of MouseReleaseMsg and MouseMotionMsg. +const ( + MouseUnknown = MouseNone + + MouseRelease MouseEventType = -iota // mouse button release (X10 only) + MouseMotion +) + +// toMouseMsg converts a mouse event to a mouse message. +func toMouseMsg(m Mouse) MouseMsg { + return MouseMsg{ + X: m.X, + Y: m.Y, + Shift: m.Mod.Contains(ModShift), + Alt: m.Mod.Contains(ModAlt), + Ctrl: m.Mod.Contains(ModCtrl), + Button: m.Button, + } +} diff --git a/mouse_test.go b/mouse_test.go index 30f6ee364b..69b2730d1f 100644 --- a/mouse_test.go +++ b/mouse_test.go @@ -3,205 +3,118 @@ package tea 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 MouseEvent + event Msg expected string }{ { - name: "unknown", - event: MouseEvent{ - Action: MouseActionPress, - Button: MouseButtonNone, - Type: MouseUnknown, - }, + name: "unknown", + event: MouseClickMsg{Button: MouseButton(0xff)}, expected: "unknown", }, { - name: "left", - event: MouseEvent{ - Action: MouseActionPress, - Button: MouseButtonLeft, - Type: MouseLeft, - }, - expected: "left press", - }, - { - name: "right", - event: MouseEvent{ - Action: MouseActionPress, - Button: MouseButtonRight, - Type: MouseRight, - }, - expected: "right press", - }, - { - name: "middle", - event: MouseEvent{ - Action: MouseActionPress, - Button: MouseButtonMiddle, - Type: MouseMiddle, - }, - expected: "middle press", - }, - { - name: "release", - event: MouseEvent{ - Action: MouseActionRelease, - Button: MouseButtonNone, - Type: MouseRelease, - }, - expected: "release", - }, - { - name: "wheel up", - event: MouseEvent{ - Action: MouseActionPress, - Button: MouseButtonWheelUp, - Type: MouseWheelUp, - }, - expected: "wheel up", - }, - { - name: "wheel down", - event: MouseEvent{ - Action: MouseActionPress, - Button: MouseButtonWheelDown, - Type: MouseWheelDown, - }, - expected: "wheel down", - }, - { - name: "wheel left", - event: MouseEvent{ - Action: MouseActionPress, - Button: MouseButtonWheelLeft, - Type: MouseWheelLeft, - }, - expected: "wheel left", - }, - { - name: "wheel right", - event: MouseEvent{ - Action: MouseActionPress, - Button: MouseButtonWheelRight, - Type: MouseWheelRight, - }, - expected: "wheel right", - }, - { - name: "motion", - event: MouseEvent{ - Action: MouseActionMotion, - Button: MouseButtonNone, - Type: MouseMotion, - }, - expected: "motion", + name: "left", + event: MouseClickMsg{Button: MouseLeft}, + expected: "left", }, { - name: "shift+left release", - event: MouseEvent{ - Type: MouseLeft, - Action: MouseActionRelease, - Button: MouseButtonLeft, - Shift: true, - }, - expected: "shift+left release", - }, - { - name: "shift+left", - event: MouseEvent{ - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - Shift: true, - }, - expected: "shift+left press", - }, - { - name: "ctrl+shift+left", - event: MouseEvent{ - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - Shift: true, - Ctrl: true, - }, - expected: "ctrl+shift+left press", - }, - { - name: "alt+left", - event: MouseEvent{ - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - Alt: true, - }, - expected: "alt+left press", - }, - { - name: "ctrl+left", - event: MouseEvent{ - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - Ctrl: true, - }, - expected: "ctrl+left press", - }, - { - name: "ctrl+alt+left", - event: MouseEvent{ - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - Alt: true, - Ctrl: true, - }, - expected: "ctrl+alt+left press", - }, - { - name: "ctrl+alt+shift+left", - event: MouseEvent{ - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - Alt: true, - Ctrl: true, - Shift: true, - }, - expected: "ctrl+alt+shift+left press", - }, - { - name: "ignore coordinates", - event: MouseEvent{ - X: 100, - Y: 200, - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - }, - expected: "left press", - }, - { - name: "broken type", - event: MouseEvent{ - Type: MouseEventType(-100), - Action: MouseAction(-110), - Button: MouseButton(-120), - }, + name: "right", + event: MouseClickMsg{Button: MouseRight}, + expected: "right", + }, + { + name: "middle", + event: MouseClickMsg{Button: MouseMiddle}, + expected: "middle", + }, + { + name: "release", + event: MouseReleaseMsg{Button: MouseNone}, expected: "", }, + { + name: "wheelup", + event: MouseWheelMsg{Button: MouseWheelUp}, + expected: "wheelup", + }, + { + name: "wheeldown", + event: MouseWheelMsg{Button: MouseWheelDown}, + expected: "wheeldown", + }, + { + name: "wheelleft", + event: MouseWheelMsg{Button: MouseWheelLeft}, + expected: "wheelleft", + }, + { + name: "wheelright", + event: MouseWheelMsg{Button: MouseWheelRight}, + expected: "wheelright", + }, + { + name: "motion", + event: MouseMotionMsg{Button: MouseNone}, + expected: "motion", + }, + { + name: "shift+left", + event: MouseReleaseMsg{Button: MouseLeft, Mod: ModShift}, + expected: "shift+left", + }, + { + name: "shift+left", event: MouseClickMsg{Button: MouseLeft, Mod: ModShift}, + expected: "shift+left", + }, + { + name: "ctrl+shift+left", + event: MouseClickMsg{Button: MouseLeft, Mod: ModCtrl | ModShift}, + expected: "ctrl+shift+left", + }, + { + name: "alt+left", + event: MouseClickMsg{Button: MouseLeft, Mod: ModAlt}, + expected: "alt+left", + }, + { + name: "ctrl+left", + event: MouseClickMsg{Button: MouseLeft, Mod: ModCtrl}, + expected: "ctrl+left", + }, + { + name: "ctrl+alt+left", + event: MouseClickMsg{Button: MouseLeft, Mod: ModAlt | ModCtrl}, + expected: "ctrl+alt+left", + }, + { + name: "ctrl+alt+shift+left", + event: MouseClickMsg{Button: MouseLeft, Mod: ModAlt | ModCtrl | ModShift}, + expected: "ctrl+alt+shift+left", + }, + { + name: "ignore coordinates", + event: MouseClickMsg{X: 100, Y: 200, Button: MouseLeft}, + expected: "left", + }, + { + name: "broken type", + event: MouseClickMsg{Button: MouseButton(120)}, + expected: "unknown", + }, } for i := range tt { tc := tt[i] t.Run(tc.name, func(t *testing.T) { - actual := tc.event.String() + actual := fmt.Sprint(tc.event) if tc.expected != actual { t.Fatalf("expected %q but got %q", @@ -213,7 +126,7 @@ func TestMouseEvent_String(t *testing.T) { } } -func TestParseX10MouseEvent(t *testing.T) { +func TestParseX10MouseDownEvent(t *testing.T) { encode := func(b byte, x, y int) []byte { return []byte{ '\x1b', @@ -228,330 +141,151 @@ func TestParseX10MouseEvent(t *testing.T) { tt := []struct { name string buf []byte - expected MouseEvent + expected Msg }{ // Position. { - name: "zero position", - buf: encode(0b0000_0000, 0, 0), - expected: MouseEvent{ - X: 0, - Y: 0, - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - }, - }, - { - name: "max position", - buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1. - expected: MouseEvent{ - X: 222, - Y: 222, - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - }, + name: "zero position", + buf: encode(0b0000_0000, 0, 0), + expected: MouseClickMsg{X: 0, Y: 0, Button: MouseLeft}, + }, + { + name: "max position", + buf: encode(0b0000_0000, 222, 222), // Because 255 (max int8) - 32 - 1. + expected: MouseClickMsg{X: 222, Y: 222, Button: MouseLeft}, }, // Simple. { - name: "left", - buf: encode(0b0000_0000, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - }, - }, - { - name: "left in motion", - buf: encode(0b0010_0000, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, - Action: MouseActionMotion, - Button: MouseButtonLeft, - }, - }, - { - name: "middle", - buf: encode(0b0000_0001, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMiddle, - Action: MouseActionPress, - Button: MouseButtonMiddle, - }, - }, - { - name: "middle in motion", - buf: encode(0b0010_0001, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMiddle, - Action: MouseActionMotion, - Button: MouseButtonMiddle, - }, - }, - { - name: "right", - buf: encode(0b0000_0010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Action: MouseActionPress, - Button: MouseButtonRight, - }, - }, - { - name: "right in motion", - buf: encode(0b0010_0010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Action: MouseActionMotion, - Button: MouseButtonRight, - }, - }, - { - name: "motion", - buf: encode(0b0010_0011, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMotion, - Action: MouseActionMotion, - Button: MouseButtonNone, - }, - }, - { - name: "wheel up", - buf: encode(0b0100_0000, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelUp, - Action: MouseActionPress, - Button: MouseButtonWheelUp, - }, - }, - { - name: "wheel down", - buf: encode(0b0100_0001, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, - Action: MouseActionPress, - Button: MouseButtonWheelDown, - }, - }, - { - name: "wheel left", - buf: encode(0b0100_0010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelLeft, - Action: MouseActionPress, - Button: MouseButtonWheelLeft, - }, - }, - { - name: "wheel right", - buf: encode(0b0100_0011, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelRight, - Action: MouseActionPress, - Button: MouseButtonWheelRight, - }, - }, - { - name: "release", - buf: encode(0b0000_0011, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRelease, - Action: MouseActionRelease, - Button: MouseButtonNone, - }, - }, - { - name: "backward", - buf: encode(0b1000_0000, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseBackward, - Action: MouseActionPress, - Button: MouseButtonBackward, - }, - }, - { - name: "forward", - buf: encode(0b1000_0001, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseForward, - Action: MouseActionPress, - Button: MouseButtonForward, - }, - }, - { - name: "button 10", - buf: encode(0b1000_0010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseUnknown, - Action: MouseActionPress, - Button: MouseButton10, - }, - }, - { - name: "button 11", - buf: encode(0b1000_0011, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseUnknown, - Action: MouseActionPress, - Button: MouseButton11, - }, + name: "left", + buf: encode(0b0000_0000, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseLeft}, + }, + { + name: "left in motion", + buf: encode(0b0010_0000, 32, 16), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseLeft}, + }, + { + name: "middle", + buf: encode(0b0000_0001, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseMiddle}, + }, + { + name: "middle in motion", + buf: encode(0b0010_0001, 32, 16), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseMiddle}, + }, + { + name: "right", + buf: encode(0b0000_0010, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseRight}, + }, + { + name: "right in motion", + buf: encode(0b0010_0010, 32, 16), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseRight}, + }, + { + name: "motion", + buf: encode(0b0010_0011, 32, 16), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseNone}, + }, + { + name: "wheel up", + buf: encode(0b0100_0000, 32, 16), + expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelUp}, + }, + { + name: "wheel down", + buf: encode(0b0100_0001, 32, 16), + expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelDown}, + }, + { + name: "wheel left", + buf: encode(0b0100_0010, 32, 16), + expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelLeft}, + }, + { + name: "wheel right", + buf: encode(0b0100_0011, 32, 16), + expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelRight}, + }, + { + name: "release", + buf: encode(0b0000_0011, 32, 16), + expected: MouseReleaseMsg{X: 32, Y: 16, Button: MouseNone}, + }, + { + name: "backward", + buf: encode(0b1000_0000, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseBackward}, + }, + { + name: "forward", + buf: encode(0b1000_0001, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseForward}, + }, + { + name: "button 10", + buf: encode(0b1000_0010, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseExtra1}, + }, + { + name: "button 11", + buf: encode(0b1000_0011, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseExtra2}, }, // Combinations. { - name: "alt+right", - buf: encode(0b0000_1010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Type: MouseRight, - Action: MouseActionPress, - Button: MouseButtonRight, - }, - }, - { - name: "ctrl+right", - buf: encode(0b0001_0010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Ctrl: true, - Type: MouseRight, - Action: MouseActionPress, - Button: MouseButtonRight, - }, - }, - { - name: "left in motion", - buf: encode(0b0010_0000, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: false, - Type: MouseLeft, - Action: MouseActionMotion, - Button: MouseButtonLeft, - }, - }, - { - name: "alt+right in motion", - buf: encode(0b0010_1010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Type: MouseRight, - Action: MouseActionMotion, - Button: MouseButtonRight, - }, - }, - { - name: "ctrl+right in motion", - buf: encode(0b0011_0010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Ctrl: true, - Type: MouseRight, - Action: MouseActionMotion, - Button: MouseButtonRight, - }, - }, - { - name: "ctrl+alt+right", - buf: encode(0b0001_1010, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Ctrl: true, - Type: MouseRight, - Action: MouseActionPress, - Button: MouseButtonRight, - }, - }, - { - name: "ctrl+wheel up", - buf: encode(0b0101_0000, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Ctrl: true, - Type: MouseWheelUp, - Action: MouseActionPress, - Button: MouseButtonWheelUp, - }, - }, - { - name: "alt+wheel down", - buf: encode(0b0100_1001, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Type: MouseWheelDown, - Action: MouseActionPress, - Button: MouseButtonWheelDown, - }, - }, - { - name: "ctrl+alt+wheel down", - buf: encode(0b0101_1001, 32, 16), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Ctrl: true, - Type: MouseWheelDown, - Action: MouseActionPress, - Button: MouseButtonWheelDown, - }, + name: "alt+right", + buf: encode(0b0000_1010, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight}, + }, + { + name: "ctrl+right", + buf: encode(0b0001_0010, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight}, + }, + { + name: "left in motion", + buf: encode(0b0010_0000, 32, 16), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseLeft}, + }, + { + name: "alt+right in motion", + buf: encode(0b0010_1010, 32, 16), + expected: MouseMotionMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight}, + }, + { + name: "ctrl+right in motion", + buf: encode(0b0011_0010, 32, 16), + expected: MouseMotionMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight}, + }, + { + name: "ctrl+alt+right", + buf: encode(0b0001_1010, 32, 16), + expected: MouseClickMsg{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight}, + }, + { + name: "ctrl+wheel up", + buf: encode(0b0101_0000, 32, 16), + expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelUp}, + }, + { + name: "alt+wheel down", + buf: encode(0b0100_1001, 32, 16), + expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown}, + }, + { + name: "ctrl+alt+wheel down", + buf: encode(0b0101_1001, 32, 16), + expected: MouseWheelMsg{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: MouseEvent{ - X: -6, - Y: -33, - Type: MouseLeft, - Action: MouseActionMotion, - Button: MouseButtonLeft, - }, + name: "overflow position", + buf: encode(0b0010_0000, 250, 223), // Because 255 (max int8) - 32 - 1. + expected: MouseMotionMsg{X: -6, Y: -33, Button: MouseLeft}, }, } @@ -571,355 +305,155 @@ func TestParseX10MouseEvent(t *testing.T) { } } -// func TestParseX10MouseEvent_error(t *testing.T) { -// tt := []struct { -// name string -// buf []byte -// }{ -// { -// name: "empty buf", -// buf: nil, -// }, -// { -// name: "wrong high bit", -// buf: []byte("\x1a[M@A1"), -// }, -// { -// name: "short buf", -// buf: []byte("\x1b[M@A"), -// }, -// { -// name: "long buf", -// buf: []byte("\x1b[M@A11"), -// }, -// } -// -// for i := range tt { -// tc := tt[i] -// -// t.Run(tc.name, func(t *testing.T) { -// _, err := parseX10MouseEvent(tc.buf) -// -// if err == nil { -// t.Fatalf("expected error but got nil") -// } -// }) -// } -// } - func TestParseSGRMouseEvent(t *testing.T) { - encode := func(b, x, y int, r bool) []byte { + encode := func(b, x, y int, r bool) *ansi.CsiSequence { re := 'M' if r { re = 'm' } - return []byte(fmt.Sprintf("\x1b[<%d;%d;%d%c", b, x+1, y+1, re)) + return &ansi.CsiSequence{ + Params: []int{b, x + 1, y + 1}, + Cmd: int(re) | ('<' << parser.MarkerShift), + } } tt := []struct { name string - buf []byte - expected MouseEvent + buf *ansi.CsiSequence + expected Msg }{ // Position. { - name: "zero position", - buf: encode(0, 0, 0, false), - expected: MouseEvent{ - X: 0, - Y: 0, - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - }, - }, - { - name: "225 position", - buf: encode(0, 225, 225, false), - expected: MouseEvent{ - X: 225, - Y: 225, - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - }, + name: "zero position", + buf: encode(0, 0, 0, false), + expected: MouseClickMsg{X: 0, Y: 0, Button: MouseLeft}, + }, + { + name: "225 position", + buf: encode(0, 225, 225, false), + expected: MouseClickMsg{X: 225, Y: 225, Button: MouseLeft}, }, // Simple. { - name: "left", - buf: encode(0, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, - Action: MouseActionPress, - Button: MouseButtonLeft, - }, - }, - { - name: "left in motion", - buf: encode(32, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseLeft, - Action: MouseActionMotion, - Button: MouseButtonLeft, - }, - }, - { - name: "left release", - buf: encode(0, 32, 16, true), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRelease, - Action: MouseActionRelease, - Button: MouseButtonLeft, - }, - }, - { - name: "middle", - buf: encode(1, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMiddle, - Action: MouseActionPress, - Button: MouseButtonMiddle, - }, - }, - { - name: "middle in motion", - buf: encode(33, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMiddle, - Action: MouseActionMotion, - Button: MouseButtonMiddle, - }, - }, - { - name: "middle release", - buf: encode(1, 32, 16, true), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRelease, - Action: MouseActionRelease, - Button: MouseButtonMiddle, - }, - }, - { - name: "right", - buf: encode(2, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRight, - Action: MouseActionPress, - Button: MouseButtonRight, - }, - }, - { - name: "right release", - buf: encode(2, 32, 16, true), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseRelease, - Action: MouseActionRelease, - Button: MouseButtonRight, - }, - }, - { - name: "motion", - buf: encode(35, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseMotion, - Action: MouseActionMotion, - Button: MouseButtonNone, - }, - }, - { - name: "wheel up", - buf: encode(64, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelUp, - Action: MouseActionPress, - Button: MouseButtonWheelUp, - }, - }, - { - name: "wheel down", - buf: encode(65, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelDown, - Action: MouseActionPress, - Button: MouseButtonWheelDown, - }, - }, - { - name: "wheel left", - buf: encode(66, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelLeft, - Action: MouseActionPress, - Button: MouseButtonWheelLeft, - }, - }, - { - name: "wheel right", - buf: encode(67, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseWheelRight, - Action: MouseActionPress, - Button: MouseButtonWheelRight, - }, - }, - { - name: "backward", - buf: encode(128, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseBackward, - Action: MouseActionPress, - Button: MouseButtonBackward, - }, - }, - { - name: "backward in motion", - buf: encode(160, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseBackward, - Action: MouseActionMotion, - Button: MouseButtonBackward, - }, - }, - { - name: "forward", - buf: encode(129, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseForward, - Action: MouseActionPress, - Button: MouseButtonForward, - }, - }, - { - name: "forward in motion", - buf: encode(161, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Type: MouseForward, - Action: MouseActionMotion, - Button: MouseButtonForward, - }, + name: "left", + buf: encode(0, 32, 16, false), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseLeft}, + }, + { + name: "left in motion", + buf: encode(32, 32, 16, false), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseLeft}, + }, + { + name: "left", + buf: encode(0, 32, 16, true), + expected: MouseReleaseMsg{X: 32, Y: 16, Button: MouseLeft}, + }, + { + name: "middle", + buf: encode(1, 32, 16, false), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseMiddle}, + }, + { + name: "middle in motion", + buf: encode(33, 32, 16, false), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseMiddle}, + }, + { + name: "middle", + buf: encode(1, 32, 16, true), + expected: MouseReleaseMsg{X: 32, Y: 16, Button: MouseMiddle}, + }, + { + name: "right", + buf: encode(2, 32, 16, false), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseRight}, + }, + { + name: "right", + buf: encode(2, 32, 16, true), + expected: MouseReleaseMsg{X: 32, Y: 16, Button: MouseRight}, + }, + { + name: "motion", + buf: encode(35, 32, 16, false), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseNone}, + }, + { + name: "wheel up", + buf: encode(64, 32, 16, false), + expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelUp}, + }, + { + name: "wheel down", + buf: encode(65, 32, 16, false), + expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelDown}, + }, + { + name: "wheel left", + buf: encode(66, 32, 16, false), + expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelLeft}, + }, + { + name: "wheel right", + buf: encode(67, 32, 16, false), + expected: MouseWheelMsg{X: 32, Y: 16, Button: MouseWheelRight}, + }, + { + name: "backward", + buf: encode(128, 32, 16, false), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseBackward}, + }, + { + name: "backward in motion", + buf: encode(160, 32, 16, false), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseBackward}, + }, + { + name: "forward", + buf: encode(129, 32, 16, false), + expected: MouseClickMsg{X: 32, Y: 16, Button: MouseForward}, + }, + { + name: "forward in motion", + buf: encode(161, 32, 16, false), + expected: MouseMotionMsg{X: 32, Y: 16, Button: MouseForward}, }, // Combinations. { - name: "alt+right", - buf: encode(10, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Type: MouseRight, - Action: MouseActionPress, - Button: MouseButtonRight, - }, - }, - { - name: "ctrl+right", - buf: encode(18, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Ctrl: true, - Type: MouseRight, - Action: MouseActionPress, - Button: MouseButtonRight, - }, - }, - { - name: "ctrl+alt+right", - buf: encode(26, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Ctrl: true, - Type: MouseRight, - Action: MouseActionPress, - Button: MouseButtonRight, - }, - }, - { - name: "alt+wheel press", - buf: encode(73, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Type: MouseWheelDown, - Action: MouseActionPress, - Button: MouseButtonWheelDown, - }, - }, - { - name: "ctrl+wheel press", - buf: encode(81, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Ctrl: true, - Type: MouseWheelDown, - Action: MouseActionPress, - Button: MouseButtonWheelDown, - }, - }, - { - name: "ctrl+alt+wheel press", - buf: encode(89, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Alt: true, - Ctrl: true, - Type: MouseWheelDown, - Action: MouseActionPress, - Button: MouseButtonWheelDown, - }, - }, - { - name: "ctrl+alt+shift+wheel press", - buf: encode(93, 32, 16, false), - expected: MouseEvent{ - X: 32, - Y: 16, - Shift: true, - Alt: true, - Ctrl: true, - Type: MouseWheelDown, - Action: MouseActionPress, - Button: MouseButtonWheelDown, - }, + name: "alt+right", + buf: encode(10, 32, 16, false), + expected: MouseClickMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseRight}, + }, + { + name: "ctrl+right", + buf: encode(18, 32, 16, false), + expected: MouseClickMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseRight}, + }, + { + name: "ctrl+alt+right", + buf: encode(26, 32, 16, false), + expected: MouseClickMsg{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseRight}, + }, + { + name: "alt+wheel", + buf: encode(73, 32, 16, false), + expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt, Button: MouseWheelDown}, + }, + { + name: "ctrl+wheel", + buf: encode(81, 32, 16, false), + expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModCtrl, Button: MouseWheelDown}, + }, + { + name: "ctrl+alt+wheel", + buf: encode(89, 32, 16, false), + expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt | ModCtrl, Button: MouseWheelDown}, + }, + { + name: "ctrl+alt+shift+wheel", + buf: encode(93, 32, 16, false), + expected: MouseWheelMsg{X: 32, Y: 16, Mod: ModAlt | ModShift | ModCtrl, Button: MouseWheelDown}, }, } diff --git a/nil_renderer.go b/nil_renderer.go index f4a83b6bc4..19ad1c5a2b 100644 --- a/nil_renderer.go +++ b/nil_renderer.go @@ -2,24 +2,15 @@ package tea type nilRenderer struct{} -func (n nilRenderer) start() {} -func (n nilRenderer) stop() {} -func (n nilRenderer) kill() {} -func (n nilRenderer) write(_ string) {} -func (n nilRenderer) repaint() {} -func (n nilRenderer) clearScreen() {} -func (n nilRenderer) altScreen() bool { return false } -func (n nilRenderer) enterAltScreen() {} -func (n nilRenderer) exitAltScreen() {} -func (n nilRenderer) showCursor() {} -func (n nilRenderer) hideCursor() {} -func (n nilRenderer) enableMouseCellMotion() {} -func (n nilRenderer) disableMouseCellMotion() {} -func (n nilRenderer) enableMouseAllMotion() {} -func (n nilRenderer) disableMouseAllMotion() {} -func (n nilRenderer) enableBracketedPaste() {} -func (n nilRenderer) disableBracketedPaste() {} -func (n nilRenderer) enableMouseSGRMode() {} -func (n nilRenderer) disableMouseSGRMode() {} -func (n nilRenderer) bracketedPasteActive() bool { return false } -func (n nilRenderer) setWindowTitle(_ string) {} +func (nilRenderer) start() {} +func (nilRenderer) stop() {} +func (nilRenderer) kill() {} +func (nilRenderer) write(string) {} +func (nilRenderer) repaint() {} +func (nilRenderer) clearScreen() {} +func (nilRenderer) altScreen() bool { return false } +func (nilRenderer) enterAltScreen() {} +func (nilRenderer) exitAltScreen() {} +func (nilRenderer) showCursor() {} +func (nilRenderer) hideCursor() {} +func (nilRenderer) execute(string) {} diff --git a/nil_renderer_test.go b/nil_renderer_test.go index ab94e34d23..f3db19547c 100644 --- a/nil_renderer_test.go +++ b/nil_renderer_test.go @@ -17,8 +17,5 @@ func TestNilRenderer(t *testing.T) { r.clearScreen() r.showCursor() r.hideCursor() - r.enableMouseCellMotion() - r.disableMouseCellMotion() - r.enableMouseAllMotion() - r.disableMouseAllMotion() + r.execute("") } diff --git a/parse.go b/parse.go new file mode 100644 index 0000000000..49eae924fa --- /dev/null +++ b/parse.go @@ -0,0 +1,811 @@ +package tea + +import ( + "encoding/base64" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/parser" + "github.com/erikgeiser/coninput" + "github.com/rivo/uniseg" +) + +// Flags to control the behavior of the parser. +// TODO: Should these be exported? +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 +) + +var flags int + +// setFlags sets the flags for the parser. +// This will control the behavior of ParseSequence. +// +//nolint:unused +func setFlags(f int) { + flags = f +} + +// 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 parseSequence(buf []byte) (n int, msg Msg) { + if len(buf) == 0 { + return 0, nil + } + + switch b := buf[0]; b { + case ansi.ESC: + if len(buf) == 1 { + // Escape key + return 1, KeyPressMsg{Type: KeyEscape} + } + + switch b := buf[1]; b { + case 'O': // Esc-prefixed SS3 + return parseSs3(buf) + case 'P': // Esc-prefixed DCS + return parseDcs(buf) + case '[': // Esc-prefixed CSI + return parseCsi(buf) + case ']': // Esc-prefixed OSC + return parseOsc(buf) + case '_': // Esc-prefixed APC + return parseApc(buf) + default: + n, e := parseSequence(buf[1:]) + if k, ok := e.(KeyPressMsg); ok { + 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, KeyPressMsg{Type: KeyEscape} + } + case ansi.SS3: + return parseSs3(buf) + case ansi.DCS: + return parseDcs(buf) + case ansi.CSI: + return parseCsi(buf) + case ansi.OSC: + return parseOsc(buf) + case ansi.APC: + return parseApc(buf) + default: + if b <= ansi.US || b == ansi.DEL || b == ansi.SP { + return 1, 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+ + return 1, KeyPressMsg{Runes: []rune{rune(b) - 0x40}, Mod: ModCtrl | ModAlt} + } + return parseUtf8(buf) + } +} + +func parseCsi(b []byte) (int, Msg) { + if len(b) == 2 && b[0] == ansi.ESC { + // short cut if this is an alt+[ key + return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt} + } + + var csi ansi.CsiSequence + var params [parser.MaxParamsSize]int + 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] <= '?' { + csi.Cmd |= int(b[i]) << parser.MarkerShift + } + + // 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] += int(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 + csi.Cmd |= int(intermed) << parser.IntermedShift + + // Scan final byte in the range 0x40-0x7E + if i >= len(b) || b[i] < 0x40 || b[i] > 0x7E { + // Special case for URxvt keys + // CSI $ is an invalid sequence, but URxvt uses it for + // shift modified keys. + if b[i-1] == '$' { + n, ev := parseCsi(append(b[:i-1], '~')) + if k, ok := ev.(KeyPressMsg); ok { + k.Mod |= ModShift + return n, k + } + } + return i, UnknownMsg(b[:i-1]) + } + + // Add the final byte + csi.Cmd |= int(b[i]) + i++ + + csi.Params = params[:paramsLen] + switch cmd := csi.Cmd; cmd { + case 'y' | '?'<= 2 && csi.Param(0) != -1 && csi.Param(1) != -1 { + return i, CursorPositionMsg{Row: csi.Param(0), Column: csi.Param(1)} + } + case 'm' | '<'<'< 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, multiMsg{KeyPressMsg{Type: KeyF3, Mod: KeyMod(csi.Param(1) - 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 KeyPressMsg + switch cmd { + case 'a', 'b', 'c', 'd': + k = KeyPressMsg{Type: KeyUp + KeyType(cmd-'a'), Mod: ModShift} + case 'A', 'B', 'C', 'D': + k = KeyPressMsg{Type: KeyUp + KeyType(cmd-'A')} + case 'E': + k = KeyPressMsg{Type: KeyBegin} + case 'F': + k = KeyPressMsg{Type: KeyEnd} + case 'H': + k = KeyPressMsg{Type: KeyHome} + case 'P', 'Q', 'R', 'S': + k = KeyPressMsg{Type: KeyF1 + KeyType(cmd-'P')} + case 'Z': + k = KeyPressMsg{Type: KeyTab, Mod: ModShift} + } + if paramsLen > 1 && csi.Param(0) == 1 && csi.Param(1) != -1 { + // CSI 1 ; A + k.Mod |= KeyMod(csi.Param(1) - 1) + } + return i, k + case 'M': + // Handle X10 mouse + if i+3 > len(b) { + return i, UnknownMsg(b[:i]) + } + return i + 3, parseX10MouseEvent(append(b[:i], b[i:i+3]...)) + case 'y': + // Report Mode (DECRPM) + if paramsLen != 2 && csi.Param(0) != -1 && csi.Param(0) != -1 { + return i, UnknownMsg(b[:i]) + } + return i, ReportModeMsg{Mode: csi.Param(0), Value: csi.Param(1)} + case 'u': + // Kitty keyboard protocol & CSI u (fixterms) + if paramsLen == 0 { + return i, UnknownMsg(b[:i]) + } + return i, parseKittyKeyboard(&csi) + case '_': + // Win32 Input Mode + if paramsLen != 6 { + return i, UnknownMsg(b[:i]) + } + + rc := uint16(csi.Param(5)) + if rc == 0 { + rc = 1 + } + + event := parseWin32InputKeyEvent( + coninput.VirtualKeyCode(csi.Param(0)), // Vk wVirtualKeyCode + coninput.VirtualKeyCode(csi.Param(1)), // Sc wVirtualScanCode + rune(csi.Param(2)), // Uc UnicodeChar + csi.Param(3) == 1, // Kd bKeyDown + coninput.ControlKeyState(csi.Param(4)), // Cs dwControlKeyState + rc, // Rc wRepeatCount + ) + + if event == nil { + return i, UnknownMsg(b[:]) + } + + return i, event + case '@', '^', '~': + if paramsLen == 0 { + return i, UnknownMsg(b[:i]) + } + + param := csi.Param(0) + switch cmd { + case '~': + switch param { + case 27: + // XTerm modifyOtherKeys 2 + if paramsLen != 3 { + return i, UnknownMsg(b[:i]) + } + return i, parseXTermModifyOtherKeys(&csi) + case 200: + // bracketed-paste start + return i, PasteStartMsg{} + case 201: + // bracketed-paste end + return i, PasteEndMsg{} + } + } + + 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 KeyPressMsg + switch param { + case 1: + if flags&_FlagFind != 0 { + k = KeyPressMsg{Type: KeyFind} + } else { + k = KeyPressMsg{Type: KeyHome} + } + case 2: + k = KeyPressMsg{Type: KeyInsert} + case 3: + k = KeyPressMsg{Type: KeyDelete} + case 4: + if flags&_FlagSelect != 0 { + k = KeyPressMsg{Type: KeySelect} + } else { + k = KeyPressMsg{Type: KeyEnd} + } + case 5: + k = KeyPressMsg{Type: KeyPgUp} + case 6: + k = KeyPressMsg{Type: KeyPgDown} + case 7: + k = KeyPressMsg{Type: KeyHome} + case 8: + k = KeyPressMsg{Type: KeyEnd} + case 11, 12, 13, 14, 15: + k = KeyPressMsg{Type: KeyF1 + KeyType(param-11)} + case 17, 18, 19, 20, 21: + k = KeyPressMsg{Type: KeyF6 + KeyType(param-17)} + case 23, 24, 25, 26: + k = KeyPressMsg{Type: KeyF11 + KeyType(param-23)} + case 28, 29: + k = KeyPressMsg{Type: KeyF15 + KeyType(param-28)} + case 31, 32, 33, 34: + k = KeyPressMsg{Type: KeyF17 + KeyType(param-31)} + } + + // modifiers + if paramsLen > 1 && csi.Param(1) != -1 { + k.Mod |= KeyMod(csi.Param(1) - 1) + } + + // Handle URxvt weird keys + switch cmd { + case '^': + k.Mod |= ModCtrl + case '@': + k.Mod |= ModCtrl | ModShift + } + + return i, k + } + } + return i, UnknownMsg(b[:i]) +} + +// parseSs3 parses a SS3 sequence. +// See https://vt100.net/docs/vt220-rm/chapter4.html#S4.4.4.2 +func parseSs3(b []byte) (int, Msg) { + if len(b) == 2 && b[0] == ansi.ESC { + // short cut if this is an alt+O key + return 2, KeyPressMsg{Runes: []rune{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) || b[i] < 0x21 || b[i] > 0x7E { + return i, UnknownMsg(b[:i]) + } + + // GL character(s) + gl := b[i] + i++ + + var k KeyPressMsg + switch gl { + case 'a', 'b', 'c', 'd': + k = KeyPressMsg{Type: KeyUp + KeyType(gl-'a'), Mod: ModCtrl} + case 'A', 'B', 'C', 'D': + k = KeyPressMsg{Type: KeyUp + KeyType(gl-'A')} + case 'E': + k = KeyPressMsg{Type: KeyBegin} + case 'F': + k = KeyPressMsg{Type: KeyEnd} + case 'H': + k = KeyPressMsg{Type: KeyHome} + case 'P', 'Q', 'R', 'S': + k = KeyPressMsg{Type: KeyF1 + KeyType(gl-'P')} + case 'M': + k = KeyPressMsg{Type: KeyKpEnter} + case 'X': + k = KeyPressMsg{Type: KeyKpEqual} + case 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y': + k = KeyPressMsg{Type: KeyKpMultiply + KeyType(gl-'j')} + default: + return i, UnknownMsg(b[:i]) + } + + // Handle weird SS3 Func + if mod > 0 { + k.Mod |= KeyMod(mod - 1) + } + + return i, k +} + +func parseOsc(b []byte) (int, Msg) { + if len(b) == 2 && b[0] == ansi.ESC { + // short cut if this is an alt+] key + return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt} + } + + 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 b[i] == ansi.BEL || b[i] == ansi.ESC || b[i] == ansi.ST { + break + } + } + + if i >= len(b) { + return i, UnknownMsg(b[:i]) + } + + 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++ + } + + if end <= start { + return i, UnknownMsg(b[:i]) + } + + data := string(b[start:end]) + switch cmd { + case 10: + return i, ForegroundColorMsg{xParseColor(data)} + case 11: + return i, BackgroundColorMsg{xParseColor(data)} + case 12: + return i, CursorColorMsg{xParseColor(data)} + case 52: + parts := strings.Split(data, ";") + if len(parts) == 0 { + return i, ClipboardMsg("") + } + b64 := parts[len(parts)-1] + bts, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return i, ClipboardMsg("") + } + return i, ClipboardMsg(bts) + default: + return i, UnknownMsg(b[:i]) + } +} + +// parseStTerminated parses a control sequence that gets terminated by a ST character. +func parseStTerminated(intro8, intro7 byte) func([]byte) (int, Msg) { + return func(b []byte) (int, Msg) { + 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 \) + // nolint: revive + for ; i < len(b) && b[i] != ansi.ST && b[i] != ansi.ESC; i++ { + } + + if i >= len(b) { + return i, UnknownMsg(b[:i]) + } + i++ + + // Check 7-bit ST (string terminator) character + if i < len(b) && b[i-1] == ansi.ESC && b[i] == '\\' { + i++ + } + + return i, UnknownMsg(b[:i]) + } +} + +func parseDcs(b []byte) (int, Msg) { + if len(b) == 2 && b[0] == ansi.ESC { + // short cut if this is an alt+P key + return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt} + } + + var params [16]int + var paramsLen int + var dcs ansi.DcsSequence + + // 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] <= '?' { + dcs.Cmd |= int(b[i]) << parser.MarkerShift + } + + // 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] += int(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 + dcs.Cmd |= int(intermed) << parser.IntermedShift + + // Scan final byte in the range 0x40-0x7E + if i >= len(b) || b[i] < 0x40 || b[i] > 0x7E { + return i, UnknownMsg(b[:i]) + } + + // Add the final byte + dcs.Cmd |= int(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) { + return i, UnknownMsg(b[:i]) + } + + 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++ + } + + dcs.Params = params[:paramsLen] + switch cmd := dcs.Command(); cmd { + case 'r': + switch dcs.Intermediate() { + case '+': + // XTGETTCAP responses + switch param := dcs.Param(0); param { + case 0, 1: + 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\\" + // + // See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands + return i, tc + } + } + } + + return i, UnknownMsg(b[:i]) +} + +func parseApc(b []byte) (int, Msg) { + if len(b) == 2 && b[0] == ansi.ESC { + // short cut if this is an alt+_ key + return 2, KeyPressMsg{Runes: []rune{rune(b[1])}, Mod: ModAlt} + } + + // APC sequences are introduced by APC (0x9f) or ESC _ (0x1b 0x5f) + return parseStTerminated(ansi.APC, '_')(b) +} + +func parseUtf8(b []byte) (int, Msg) { + 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, parseControl(c) + } else if c > ansi.US && c < ansi.DEL { + // ASCII printable characters + return 1, KeyPressMsg{Runes: []rune{rune(c)}} + } + + if r, _ := utf8.DecodeRune(b); r == utf8.RuneError { + return 1, UnknownMsg(b[0]) + } + + cluster, _, _, _ := uniseg.FirstGraphemeCluster(b, -1) + return len(cluster), KeyPressMsg{Runes: []rune(string(cluster))} +} + +func parseControl(b byte) Msg { + switch b { + case ansi.NUL: + if flags&_FlagCtrlAt != 0 { + return KeyPressMsg{Runes: []rune{'@'}, Mod: ModCtrl} + } + return KeyPressMsg{Runes: []rune{' '}, Type: KeySpace, Mod: ModCtrl} + case ansi.BS: + return KeyPressMsg{Runes: []rune{'h'}, Mod: ModCtrl} + case ansi.HT: + if flags&_FlagCtrlI != 0 { + return KeyPressMsg{Runes: []rune{'i'}, Mod: ModCtrl} + } + return KeyPressMsg{Type: KeyTab} + case ansi.CR: + if flags&_FlagCtrlM != 0 { + return KeyPressMsg{Runes: []rune{'m'}, Mod: ModCtrl} + } + return KeyPressMsg{Type: KeyEnter} + case ansi.ESC: + if flags&_FlagCtrlOpenBracket != 0 { + return KeyPressMsg{Runes: []rune{'['}, Mod: ModCtrl} + } + return KeyPressMsg{Type: KeyEscape} + case ansi.DEL: + if flags&_FlagBackspace != 0 { + return KeyPressMsg{Type: KeyDelete} + } + return KeyPressMsg{Type: KeyBackspace} + case ansi.SP: + return KeyPressMsg{Type: KeySpace, Runes: []rune{' '}} + default: + if b >= ansi.SOH && b <= ansi.SUB { + // Use lower case letters for control codes + return KeyPressMsg{Runes: []rune{rune(b + 0x60)}, Mod: ModCtrl} + } else if b >= ansi.FS && b <= ansi.US { + return KeyPressMsg{Runes: []rune{rune(b + 0x40)}, Mod: ModCtrl} + } + return UnknownMsg(b) + } +} diff --git a/parse_test.go b/parse_test.go new file mode 100644 index 0000000000..6e8e0b6170 --- /dev/null +++ b/parse_test.go @@ -0,0 +1,41 @@ +package tea + +import ( + "image/color" + "reflect" + "testing" +) + +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") + want := []Msg{ + KeyPressMsg{Type: KeyTab, Mod: ModShift | ModAlt}, + KeyPressMsg{Type: KeyRunes, Runes: []rune{'t'}}, + KeyPressMsg{Type: KeyRunes, Runes: []rune{'e'}}, + KeyPressMsg{Type: KeyRunes, Runes: []rune{'s'}}, + KeyPressMsg{Type: KeyRunes, Runes: []rune{'t'}}, + KeyPressMsg{Type: KeySpace, Runes: []rune{' '}, Mod: ModCtrl}, + ForegroundColorMsg{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}}, + KeyPressMsg{Type: KeyEscape, Mod: ModShift}, + ReportModeMsg{Mode: 1049, Value: 2}, + } + for i := 0; len(input) != 0; i++ { + if i >= len(want) { + t.Fatalf("reached end of want events") + } + n, got := 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) { + 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++ { + parseSequence(input) + } +} diff --git a/paste.go b/paste.go new file mode 100644 index 0000000000..575f995a90 --- /dev/null +++ b/paste.go @@ -0,0 +1,13 @@ +package tea + +// PasteMsg is an message that is emitted when a terminal receives pasted text +// using bracketed-paste. +type PasteMsg string + +// PasteStartMsg is an message that is emitted when the terminal starts the +// bracketed-paste text +type PasteStartMsg struct{} + +// PasteEndMsg is an message that is emitted when the terminal ends the +// bracketed-paste text. +type PasteEndMsg struct{} diff --git a/renderer.go b/renderer.go index de3936e73b..a15f5ff69d 100644 --- a/renderer.go +++ b/renderer.go @@ -36,40 +36,8 @@ type renderer interface { // Hide the cursor. hideCursor() - // enableMouseCellMotion enables mouse click, release, wheel and motion - // events if a mouse button is pressed (i.e., drag events). - enableMouseCellMotion() - - // disableMouseCellMotion disables Mouse Cell Motion tracking. - disableMouseCellMotion() - - // enableMouseAllMotion enables mouse click, release, wheel and motion - // events, regardless of whether a mouse button is pressed. Many modern - // terminals support this, but not all. - enableMouseAllMotion() - - // disableMouseAllMotion disables All Motion mouse tracking. - disableMouseAllMotion() - - // enableMouseSGRMode enables mouse extended mode (SGR). - enableMouseSGRMode() - - // disableMouseSGRMode disables mouse extended mode (SGR). - disableMouseSGRMode() - - // enableBracketedPaste enables bracketed paste, where characters - // inside the input are not interpreted when pasted as a whole. - enableBracketedPaste() - - // disableBracketedPaste disables bracketed paste. - disableBracketedPaste() - - // bracketedPasteActive reports whether bracketed paste mode is - // currently enabled. - bracketedPasteActive() bool - - // setWindowTitle sets the terminal window title. - setWindowTitle(string) + // execute writes a sequence to the terminal. + execute(string) } // repaintMsg forces a full repaint. diff --git a/screen.go b/screen.go index 6256c15920..1f52dc26c0 100644 --- a/screen.go +++ b/screen.go @@ -1,5 +1,7 @@ package tea +import "github.com/charmbracelet/x/ansi" + // WindowSizeMsg is used to report the terminal size. It's sent to Update once // initially and then on every terminal resize. Note that Windows does not // have support for reporting when resizes occur as it does not support the @@ -173,7 +175,7 @@ func (p *Program) ExitAltScreen() { // Deprecated: Use the WithMouseCellMotion ProgramOption instead. func (p *Program) EnableMouseCellMotion() { if p.renderer != nil { - p.renderer.enableMouseCellMotion() + p.renderer.execute(ansi.EnableMouseCellMotion) } else { p.startupOptions |= withMouseCellMotion } @@ -185,7 +187,7 @@ func (p *Program) EnableMouseCellMotion() { // Deprecated: The mouse will automatically be disabled when the program exits. func (p *Program) DisableMouseCellMotion() { if p.renderer != nil { - p.renderer.disableMouseCellMotion() + p.renderer.execute(ansi.DisableMouseCellMotion) } else { p.startupOptions &^= withMouseCellMotion } @@ -198,7 +200,7 @@ func (p *Program) DisableMouseCellMotion() { // Deprecated: Use the WithMouseAllMotion ProgramOption instead. func (p *Program) EnableMouseAllMotion() { if p.renderer != nil { - p.renderer.enableMouseAllMotion() + p.renderer.execute(ansi.EnableMouseAllMotion) } else { p.startupOptions |= withMouseAllMotion } @@ -210,7 +212,7 @@ func (p *Program) EnableMouseAllMotion() { // Deprecated: The mouse will automatically be disabled when the program exits. func (p *Program) DisableMouseAllMotion() { if p.renderer != nil { - p.renderer.disableMouseAllMotion() + p.renderer.execute(ansi.DisableMouseAllMotion) } else { p.startupOptions &^= withMouseAllMotion } @@ -221,7 +223,7 @@ func (p *Program) DisableMouseAllMotion() { // Deprecated: Use the SetWindowTitle command instead. func (p *Program) SetWindowTitle(title string) { if p.renderer != nil { - p.renderer.setWindowTitle(title) + p.renderer.execute(ansi.SetWindowTitle(title)) } else { p.startupTitle = title } diff --git a/standard_renderer.go b/standard_renderer.go index f81920de01..284ad32a92 100644 --- a/standard_renderer.go +++ b/standard_renderer.go @@ -44,9 +44,6 @@ type standardRenderer struct { // essentially whether or not we're using the full size of the terminal altScreenActive bool - // whether or not we're currently using bracketed paste - bpActive bool - // renderer dimensions; usually the size of the window width int height int @@ -393,76 +390,6 @@ func (r *standardRenderer) hideCursor() { r.execute(ansi.HideCursor) } -func (r *standardRenderer) enableMouseCellMotion() { - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.EnableMouseCellMotion) -} - -func (r *standardRenderer) disableMouseCellMotion() { - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.DisableMouseCellMotion) -} - -func (r *standardRenderer) enableMouseAllMotion() { - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.EnableMouseAllMotion) -} - -func (r *standardRenderer) disableMouseAllMotion() { - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.DisableMouseAllMotion) -} - -func (r *standardRenderer) enableMouseSGRMode() { - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.EnableMouseSgrExt) -} - -func (r *standardRenderer) disableMouseSGRMode() { - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.DisableMouseSgrExt) -} - -func (r *standardRenderer) enableBracketedPaste() { - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.EnableBracketedPaste) - r.bpActive = true -} - -func (r *standardRenderer) disableBracketedPaste() { - r.mtx.Lock() - defer r.mtx.Unlock() - - r.execute(ansi.DisableBracketedPaste) - r.bpActive = false -} - -func (r *standardRenderer) bracketedPasteActive() bool { - r.mtx.Lock() - defer r.mtx.Unlock() - - return r.bpActive -} - -// setWindowTitle sets the terminal window title. -func (r *standardRenderer) setWindowTitle(title string) { - r.execute(ansi.SetWindowTitle(title)) -} - // setIgnoredLines specifies lines not to be touched by the standard Bubble Tea // renderer. func (r *standardRenderer) setIgnoredLines(from int, to int) { diff --git a/table.go b/table.go new file mode 100644 index 0000000000..678388cdd9 --- /dev/null +++ b/table.go @@ -0,0 +1,391 @@ +package tea + +import ( + "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. +// TODO: Use flags? +func buildKeysTable(flags int, term string) map[string]Key { + nul := Key{Runes: []rune{' '}, Type: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space + if flags&_FlagCtrlAt != 0 { + nul = Key{Runes: []rune{'@'}, Mod: ModCtrl} + } + + tab := Key{Type: KeyTab} // ctrl+i or tab + if flags&_FlagCtrlI != 0 { + tab = Key{Runes: []rune{'i'}, Mod: ModCtrl} + } + + enter := Key{Type: KeyEnter} // ctrl+m or enter + if flags&_FlagCtrlM != 0 { + enter = Key{Runes: []rune{'m'}, Mod: ModCtrl} + } + + esc := Key{Type: KeyEscape} // ctrl+[ or escape + if flags&_FlagCtrlOpenBracket != 0 { + esc = Key{Runes: []rune{'['}, Mod: ModCtrl} // ctrl+[ or escape + } + + del := Key{Type: KeyBackspace} + if flags&_FlagBackspace != 0 { + del.Type = KeyDelete + } + + find := Key{Type: KeyHome} + if flags&_FlagFind != 0 { + find.Type = KeyFind + } + + sel := Key{Type: KeyEnd} + if flags&_FlagSelect != 0 { + sel.Type = 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)): {Runes: []rune{'a'}, Mod: ModCtrl}, + string(byte(ansi.STX)): {Runes: []rune{'b'}, Mod: ModCtrl}, + string(byte(ansi.ETX)): {Runes: []rune{'c'}, Mod: ModCtrl}, + string(byte(ansi.EOT)): {Runes: []rune{'d'}, Mod: ModCtrl}, + string(byte(ansi.ENQ)): {Runes: []rune{'e'}, Mod: ModCtrl}, + string(byte(ansi.ACK)): {Runes: []rune{'f'}, Mod: ModCtrl}, + string(byte(ansi.BEL)): {Runes: []rune{'g'}, Mod: ModCtrl}, + string(byte(ansi.BS)): {Runes: []rune{'h'}, Mod: ModCtrl}, + string(byte(ansi.HT)): tab, + string(byte(ansi.LF)): {Runes: []rune{'j'}, Mod: ModCtrl}, + string(byte(ansi.VT)): {Runes: []rune{'k'}, Mod: ModCtrl}, + string(byte(ansi.FF)): {Runes: []rune{'l'}, Mod: ModCtrl}, + string(byte(ansi.CR)): enter, + string(byte(ansi.SO)): {Runes: []rune{'n'}, Mod: ModCtrl}, + string(byte(ansi.SI)): {Runes: []rune{'o'}, Mod: ModCtrl}, + string(byte(ansi.DLE)): {Runes: []rune{'p'}, Mod: ModCtrl}, + string(byte(ansi.DC1)): {Runes: []rune{'q'}, Mod: ModCtrl}, + string(byte(ansi.DC2)): {Runes: []rune{'r'}, Mod: ModCtrl}, + string(byte(ansi.DC3)): {Runes: []rune{'s'}, Mod: ModCtrl}, + string(byte(ansi.DC4)): {Runes: []rune{'t'}, Mod: ModCtrl}, + string(byte(ansi.NAK)): {Runes: []rune{'u'}, Mod: ModCtrl}, + string(byte(ansi.SYN)): {Runes: []rune{'v'}, Mod: ModCtrl}, + string(byte(ansi.ETB)): {Runes: []rune{'w'}, Mod: ModCtrl}, + string(byte(ansi.CAN)): {Runes: []rune{'x'}, Mod: ModCtrl}, + string(byte(ansi.EM)): {Runes: []rune{'y'}, Mod: ModCtrl}, + string(byte(ansi.SUB)): {Runes: []rune{'z'}, Mod: ModCtrl}, + string(byte(ansi.ESC)): esc, + string(byte(ansi.FS)): {Runes: []rune{'\\'}, Mod: ModCtrl}, + string(byte(ansi.GS)): {Runes: []rune{']'}, Mod: ModCtrl}, + string(byte(ansi.RS)): {Runes: []rune{'^'}, Mod: ModCtrl}, + string(byte(ansi.US)): {Runes: []rune{'_'}, Mod: ModCtrl}, + + // Special keys in G0 + string(byte(ansi.SP)): {Type: KeySpace, Runes: []rune{' '}}, + string(byte(ansi.DEL)): del, + + // Special keys + + "\x1b[Z": {Type: KeyTab, Mod: ModShift}, + + "\x1b[1~": find, + "\x1b[2~": {Type: KeyInsert}, + "\x1b[3~": {Type: KeyDelete}, + "\x1b[4~": sel, + "\x1b[5~": {Type: KeyPgUp}, + "\x1b[6~": {Type: KeyPgDown}, + "\x1b[7~": {Type: KeyHome}, + "\x1b[8~": {Type: KeyEnd}, + + // Normal mode + "\x1b[A": {Type: KeyUp}, + "\x1b[B": {Type: KeyDown}, + "\x1b[C": {Type: KeyRight}, + "\x1b[D": {Type: KeyLeft}, + "\x1b[E": {Type: KeyBegin}, + "\x1b[F": {Type: KeyEnd}, + "\x1b[H": {Type: KeyHome}, + "\x1b[P": {Type: KeyF1}, + "\x1b[Q": {Type: KeyF2}, + "\x1b[R": {Type: KeyF3}, + "\x1b[S": {Type: KeyF4}, + + // Application Cursor Key Mode (DECCKM) + "\x1bOA": {Type: KeyUp}, + "\x1bOB": {Type: KeyDown}, + "\x1bOC": {Type: KeyRight}, + "\x1bOD": {Type: KeyLeft}, + "\x1bOE": {Type: KeyBegin}, + "\x1bOF": {Type: KeyEnd}, + "\x1bOH": {Type: KeyHome}, + "\x1bOP": {Type: KeyF1}, + "\x1bOQ": {Type: KeyF2}, + "\x1bOR": {Type: KeyF3}, + "\x1bOS": {Type: KeyF4}, + + // Keypad Application Mode (DECKPAM) + + "\x1bOM": {Type: KeyKpEnter}, + "\x1bOX": {Type: KeyKpEqual}, + "\x1bOj": {Type: KeyKpMultiply}, + "\x1bOk": {Type: KeyKpPlus}, + "\x1bOl": {Type: KeyKpComma}, + "\x1bOm": {Type: KeyKpMinus}, + "\x1bOn": {Type: KeyKpDecimal}, + "\x1bOo": {Type: KeyKpDivide}, + "\x1bOp": {Type: KeyKp0}, + "\x1bOq": {Type: KeyKp1}, + "\x1bOr": {Type: KeyKp2}, + "\x1bOs": {Type: KeyKp3}, + "\x1bOt": {Type: KeyKp4}, + "\x1bOu": {Type: KeyKp5}, + "\x1bOv": {Type: KeyKp6}, + "\x1bOw": {Type: KeyKp7}, + "\x1bOx": {Type: KeyKp8}, + "\x1bOy": {Type: KeyKp9}, + + // Function keys + + "\x1b[11~": {Type: KeyF1}, + "\x1b[12~": {Type: KeyF2}, + "\x1b[13~": {Type: KeyF3}, + "\x1b[14~": {Type: KeyF4}, + "\x1b[15~": {Type: KeyF5}, + "\x1b[17~": {Type: KeyF6}, + "\x1b[18~": {Type: KeyF7}, + "\x1b[19~": {Type: KeyF8}, + "\x1b[20~": {Type: KeyF9}, + "\x1b[21~": {Type: KeyF10}, + "\x1b[23~": {Type: KeyF11}, + "\x1b[24~": {Type: KeyF12}, + "\x1b[25~": {Type: KeyF13}, + "\x1b[26~": {Type: KeyF14}, + "\x1b[28~": {Type: KeyF15}, + "\x1b[29~": {Type: KeyF16}, + "\x1b[31~": {Type: KeyF17}, + "\x1b[32~": {Type: KeyF18}, + "\x1b[33~": {Type: KeyF19}, + "\x1b[34~": {Type: KeyF20}, + } + + // CSI ~ sequence keys + csiTildeKeys := map[string]Key{ + "1": find, "2": {Type: KeyInsert}, + "3": {Type: KeyDelete}, "4": sel, + "5": {Type: KeyPgUp}, "6": {Type: KeyPgDown}, + "7": {Type: KeyHome}, "8": {Type: KeyEnd}, + // There are no 9 and 10 keys + "11": {Type: KeyF1}, "12": {Type: KeyF2}, + "13": {Type: KeyF3}, "14": {Type: KeyF4}, + "15": {Type: KeyF5}, "17": {Type: KeyF6}, + "18": {Type: KeyF7}, "19": {Type: KeyF8}, + "20": {Type: KeyF9}, "21": {Type: KeyF10}, + "23": {Type: KeyF11}, "24": {Type: KeyF12}, + "25": {Type: KeyF13}, "26": {Type: KeyF14}, + "28": {Type: KeyF15}, "29": {Type: KeyF16}, + "31": {Type: KeyF17}, "32": {Type: KeyF18}, + "33": {Type: KeyF19}, "34": {Type: KeyF20}, + } + + // URxvt keys + // See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes + table["\x1b[a"] = Key{Type: KeyUp, Mod: ModShift} + table["\x1b[b"] = Key{Type: KeyDown, Mod: ModShift} + table["\x1b[c"] = Key{Type: KeyRight, Mod: ModShift} + table["\x1b[d"] = Key{Type: KeyLeft, Mod: ModShift} + table["\x1bOa"] = Key{Type: KeyUp, Mod: ModCtrl} + table["\x1bOb"] = Key{Type: KeyDown, Mod: ModCtrl} + table["\x1bOc"] = Key{Type: KeyRight, Mod: ModCtrl} + table["\x1bOd"] = Key{Type: KeyLeft, Mod: ModCtrl} + // 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{Type: KeyF11, Mod: ModShift} + table["\x1b[24$"] = Key{Type: KeyF12, Mod: ModShift} + table["\x1b[25$"] = Key{Type: KeyF13, Mod: ModShift} + table["\x1b[26$"] = Key{Type: KeyF14, Mod: ModShift} + table["\x1b[28$"] = Key{Type: KeyF15, Mod: ModShift} + table["\x1b[29$"] = Key{Type: KeyF16, Mod: ModShift} + table["\x1b[31$"] = Key{Type: KeyF17, Mod: ModShift} + table["\x1b[32$"] = Key{Type: KeyF18, Mod: ModShift} + table["\x1b[33$"] = Key{Type: KeyF19, Mod: ModShift} + table["\x1b[34$"] = Key{Type: KeyF20, Mod: ModShift} + table["\x1b[11^"] = Key{Type: KeyF1, Mod: ModCtrl} + table["\x1b[12^"] = Key{Type: KeyF2, Mod: ModCtrl} + table["\x1b[13^"] = Key{Type: KeyF3, Mod: ModCtrl} + table["\x1b[14^"] = Key{Type: KeyF4, Mod: ModCtrl} + table["\x1b[15^"] = Key{Type: KeyF5, Mod: ModCtrl} + table["\x1b[17^"] = Key{Type: KeyF6, Mod: ModCtrl} + table["\x1b[18^"] = Key{Type: KeyF7, Mod: ModCtrl} + table["\x1b[19^"] = Key{Type: KeyF8, Mod: ModCtrl} + table["\x1b[20^"] = Key{Type: KeyF9, Mod: ModCtrl} + table["\x1b[21^"] = Key{Type: KeyF10, Mod: ModCtrl} + table["\x1b[23^"] = Key{Type: KeyF11, Mod: ModCtrl} + table["\x1b[24^"] = Key{Type: KeyF12, Mod: ModCtrl} + table["\x1b[25^"] = Key{Type: KeyF13, Mod: ModCtrl} + table["\x1b[26^"] = Key{Type: KeyF14, Mod: ModCtrl} + table["\x1b[28^"] = Key{Type: KeyF15, Mod: ModCtrl} + table["\x1b[29^"] = Key{Type: KeyF16, Mod: ModCtrl} + table["\x1b[31^"] = Key{Type: KeyF17, Mod: ModCtrl} + table["\x1b[32^"] = Key{Type: KeyF18, Mod: ModCtrl} + table["\x1b[33^"] = Key{Type: KeyF19, Mod: ModCtrl} + table["\x1b[34^"] = Key{Type: KeyF20, Mod: ModCtrl} + table["\x1b[23@"] = Key{Type: KeyF11, Mod: ModShift | ModCtrl} + table["\x1b[24@"] = Key{Type: KeyF12, Mod: ModShift | ModCtrl} + table["\x1b[25@"] = Key{Type: KeyF13, Mod: ModShift | ModCtrl} + table["\x1b[26@"] = Key{Type: KeyF14, Mod: ModShift | ModCtrl} + table["\x1b[28@"] = Key{Type: KeyF15, Mod: ModShift | ModCtrl} + table["\x1b[29@"] = Key{Type: KeyF16, Mod: ModShift | ModCtrl} + table["\x1b[31@"] = Key{Type: KeyF17, Mod: ModShift | ModCtrl} + table["\x1b[32@"] = Key{Type: KeyF18, Mod: ModShift | ModCtrl} + table["\x1b[33@"] = Key{Type: KeyF19, Mod: ModShift | ModCtrl} + table["\x1b[34@"] = Key{Type: KeyF20, Mod: ModShift | ModCtrl} + + // Register Alt + 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 + tmap["\x1b"+seq] = key + } + for seq, key := range tmap { + table[seq] = key + } + + // 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": {Type: KeyKpEnter}, "X": {Type: KeyKpEqual}, + "j": {Type: KeyKpMultiply}, "k": {Type: KeyKpPlus}, + "l": {Type: KeyKpComma}, "m": {Type: KeyKpMinus}, + "n": {Type: KeyKpDecimal}, "o": {Type: KeyKpDivide}, + "p": {Type: KeyKp0}, "q": {Type: KeyKp1}, + "r": {Type: KeyKp2}, "s": {Type: KeyKp3}, + "t": {Type: KeyKp4}, "u": {Type: KeyKp5}, + "v": {Type: KeyKp6}, "w": {Type: KeyKp7}, + "x": {Type: KeyKp8}, "y": {Type: KeyKp9}, + } + + // XTerm keys + csiFuncKeys := map[string]Key{ + "A": {Type: KeyUp}, "B": {Type: KeyDown}, + "C": {Type: KeyRight}, "D": {Type: KeyLeft}, + "E": {Type: KeyBegin}, "F": {Type: KeyEnd}, + "H": {Type: KeyHome}, "P": {Type: KeyF1}, + "Q": {Type: KeyF2}, "R": {Type: KeyF3}, + "S": {Type: KeyF4}, + } + + // CSI 27 ; ; ~ keys defined in XTerm modifyOtherKeys + modifyOtherKeys := map[int]Key{ + ansi.BS: {Type: KeyBackspace}, + ansi.HT: {Type: KeyTab}, + ansi.CR: {Type: KeyEnter}, + ansi.ESC: {Type: KeyEscape}, + ansi.DEL: {Type: KeyBackspace}, + } + + for _, m := range modifiers { + // XTerm modifier offset +1 + xtermMod := strconv.Itoa(int(m) + 1) + + // CSI 1 ; + 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 + for k, v := range ss3FuncKeys { + seq := "\x1bO" + xtermMod + k + key := v + key.Mod = m + table[seq] = key + } + // CSI ; ~ + for k, v := range csiTildeKeys { + seq := "\x1b[" + k + ";" + xtermMod + "~" + key := v + key.Mod = m + table[seq] = key + } + // CSI 27 ; ; ~ + 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) + for seq, key := range titable { + table[seq] = key + } + } + + return table +} diff --git a/tea.go b/tea.go index 62cd6415bb..d9b95a6d2f 100644 --- a/tea.go +++ b/tea.go @@ -21,8 +21,8 @@ import ( "sync/atomic" "syscall" + "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" - "github.com/muesli/cancelreader" "golang.org/x/sync/errgroup" ) @@ -159,14 +159,14 @@ type Program struct { // ttyInput is null if input is not a TTY. ttyInput term.File previousTtyInputState *term.State - cancelReader cancelreader.CancelReader + inputReader *driver readLoopDone chan struct{} // was the altscreen active before releasing the terminal? altScreenWasActive bool ignoreSignals uint32 - bpWasActive bool // was the bracketed paste mode active before releasing the terminal? + bpActive bool // was the bracketed paste mode active before releasing the terminal? filter func(Model, Msg) Msg @@ -321,9 +321,9 @@ func (p *Program) handleCommands(cmds chan Cmd) chan struct{} { } func (p *Program) disableMouse() { - p.renderer.disableMouseCellMotion() - p.renderer.disableMouseAllMotion() - p.renderer.disableMouseSGRMode() + p.renderer.execute(ansi.DisableMouseCellMotion) + p.renderer.execute(ansi.DisableMouseAllMotion) + p.renderer.execute(ansi.DisableMouseSgrExt) } // eventLoop is the central message loop. It receives and handles the default @@ -368,12 +368,12 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { case enableMouseCellMotionMsg, enableMouseAllMotionMsg: switch msg.(type) { case enableMouseCellMotionMsg: - p.renderer.enableMouseCellMotion() + p.renderer.execute(ansi.EnableMouseCellMotion) case enableMouseAllMotionMsg: - p.renderer.enableMouseAllMotion() + p.renderer.execute(ansi.EnableMouseAllMotion) } // mouse mode (1006) is a no-op if the terminal doesn't support it. - p.renderer.enableMouseSGRMode() + p.renderer.execute(ansi.EnableMouseSgrExt) case disableMouseMsg: p.disableMouse() @@ -385,10 +385,12 @@ func (p *Program) eventLoop(model Model, cmds chan Cmd) (Model, error) { p.renderer.hideCursor() case enableBracketedPasteMsg: - p.renderer.enableBracketedPaste() + p.renderer.execute(ansi.EnableBracketedPaste) + p.bpActive = true case disableBracketedPasteMsg: - p.renderer.disableBracketedPaste() + p.renderer.execute(ansi.DisableBracketedPaste) + p.bpActive = false case execMsg: // NB: this blocks. @@ -514,40 +516,51 @@ func (p *Program) Run() (Model, error) { }() } + // Check if output is a TTY before entering raw mode, hiding the cursor and + // so on. + if err := p.initTerminal(); err != nil { + return p.initialModel, err + } + // If no renderer is set use the standard one. if p.renderer == nil { p.renderer = newRenderer(p.output, p.startupOptions.has(withANSICompressor), p.fps) } - // Check if output is a TTY before entering raw mode, hiding the cursor and - // so on. - if err := p.initTerminal(); err != nil { - return p.initialModel, err + // Init the input reader and initial model. + model := p.initialModel + if p.input != nil { + if err := p.initInputReader(); err != nil { + return model, err + } } + // Hide the cursor before starting the renderer. + p.renderer.hideCursor() + // Honor program startup options. if p.startupTitle != "" { - p.renderer.setWindowTitle(p.startupTitle) + p.renderer.execute(ansi.SetWindowTitle(p.startupTitle)) } if p.startupOptions&withAltScreen != 0 { p.renderer.enterAltScreen() } if p.startupOptions&withoutBracketedPaste == 0 { - p.renderer.enableBracketedPaste() + p.renderer.execute(ansi.EnableBracketedPaste) + p.bpActive = true } if p.startupOptions&withMouseCellMotion != 0 { - p.renderer.enableMouseCellMotion() - p.renderer.enableMouseSGRMode() + p.renderer.execute(ansi.EnableMouseCellMotion) + p.renderer.execute(ansi.EnableMouseSgrExt) } else if p.startupOptions&withMouseAllMotion != 0 { - p.renderer.enableMouseAllMotion() - p.renderer.enableMouseSGRMode() + p.renderer.execute(ansi.EnableMouseAllMotion) + p.renderer.execute(ansi.EnableMouseSgrExt) } // Start the renderer. p.renderer.start() // Initialize the program. - model := p.initialModel if initCmd := model.Init(); initCmd != nil { ch := make(chan struct{}) handlers.add(ch) @@ -565,13 +578,6 @@ func (p *Program) Run() (Model, error) { // Render the initial view. p.renderer.write(model.View()) - // Subscribe to user input. - if p.input != nil { - if err := p.initCancelReader(); err != nil { - return model, err - } - } - // Handle resize events. handlers.add(p.handleResize()) @@ -592,12 +598,12 @@ func (p *Program) Run() (Model, error) { p.cancel() // Check if the cancel reader has been setup before waiting and closing. - if p.cancelReader != nil { + if p.inputReader != nil { // Wait for input loop to finish. - if p.cancelReader.Cancel() { + if p.inputReader.Cancel() { p.waitForReadLoop() } - _ = p.cancelReader.Close() + _ = p.inputReader.Close() } // Wait for all handlers to finish. @@ -684,8 +690,8 @@ func (p *Program) shutdown(kill bool) { // reader. You can return control to the Program with RestoreTerminal. func (p *Program) ReleaseTerminal() error { atomic.StoreUint32(&p.ignoreSignals, 1) - if p.cancelReader != nil { - p.cancelReader.Cancel() + if p.inputReader != nil { + p.inputReader.Cancel() } p.waitForReadLoop() @@ -693,7 +699,6 @@ func (p *Program) ReleaseTerminal() error { if p.renderer != nil { p.renderer.stop() p.altScreenWasActive = p.renderer.altScreen() - p.bpWasActive = p.renderer.bracketedPasteActive() } return p.restoreTerminalState() @@ -708,7 +713,7 @@ func (p *Program) RestoreTerminal() error { if err := p.initTerminal(); err != nil { return err } - if err := p.initCancelReader(); err != nil { + if err := p.initInputReader(); err != nil { return err } if p.altScreenWasActive { @@ -719,9 +724,9 @@ func (p *Program) RestoreTerminal() error { } if p.renderer != nil { p.renderer.start() - } - if p.bpWasActive { - p.renderer.enableBracketedPaste() + p.renderer.hideCursor() + p.renderer.execute(ansi.EnableBracketedPaste) + p.bpActive = true } // If the output is a terminal, it may have been resized while another diff --git a/termcap.go b/termcap.go new file mode 100644 index 0000000000..b2d31911b8 --- /dev/null +++ b/termcap.go @@ -0,0 +1,54 @@ +package tea + +import ( + "bytes" + "encoding/hex" + "strings" +) + +// TermcapMsg represents a Termcap 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 TermcapMsg string + +func parseTermcap(data []byte) TermcapMsg { + // XTGETTCAP + if len(data) == 0 { + return TermcapMsg("") + } + + var tc strings.Builder + split := bytes.Split(data, []byte{';'}) + for _, s := range split { + parts := bytes.SplitN(s, []byte{'='}, 2) + if len(parts) == 0 { + return TermcapMsg("") + } + + 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 TermcapMsg(tc.String()) +} diff --git a/terminfo.go b/terminfo.go new file mode 100644 index 0000000000..e4be0ddf3e --- /dev/null +++ b/terminfo.go @@ -0,0 +1,277 @@ +package tea + +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": {Type: KeyUp}, + "kUP": {Type: KeyUp, Mod: ModShift}, + "kUP3": {Type: KeyUp, Mod: ModAlt}, + "kUP4": {Type: KeyUp, Mod: ModShift | ModAlt}, + "kUP5": {Type: KeyUp, Mod: ModCtrl}, + "kUP6": {Type: KeyUp, Mod: ModShift | ModCtrl}, + "kUP7": {Type: KeyUp, Mod: ModAlt | ModCtrl}, + "kUP8": {Type: KeyUp, Mod: ModShift | ModAlt | ModCtrl}, + "kcud1": {Type: KeyDown}, + "kDN": {Type: KeyDown, Mod: ModShift}, + "kDN3": {Type: KeyDown, Mod: ModAlt}, + "kDN4": {Type: KeyDown, Mod: ModShift | ModAlt}, + "kDN5": {Type: KeyDown, Mod: ModCtrl}, + "kDN7": {Type: KeyDown, Mod: ModAlt | ModCtrl}, + "kDN6": {Type: KeyDown, Mod: ModShift | ModCtrl}, + "kDN8": {Type: KeyDown, Mod: ModShift | ModAlt | ModCtrl}, + "kcub1": {Type: KeyLeft}, + "kLFT": {Type: KeyLeft, Mod: ModShift}, + "kLFT3": {Type: KeyLeft, Mod: ModAlt}, + "kLFT4": {Type: KeyLeft, Mod: ModShift | ModAlt}, + "kLFT5": {Type: KeyLeft, Mod: ModCtrl}, + "kLFT6": {Type: KeyLeft, Mod: ModShift | ModCtrl}, + "kLFT7": {Type: KeyLeft, Mod: ModAlt | ModCtrl}, + "kLFT8": {Type: KeyLeft, Mod: ModShift | ModAlt | ModCtrl}, + "kcuf1": {Type: KeyRight}, + "kRIT": {Type: KeyRight, Mod: ModShift}, + "kRIT3": {Type: KeyRight, Mod: ModAlt}, + "kRIT4": {Type: KeyRight, Mod: ModShift | ModAlt}, + "kRIT5": {Type: KeyRight, Mod: ModCtrl}, + "kRIT6": {Type: KeyRight, Mod: ModShift | ModCtrl}, + "kRIT7": {Type: KeyRight, Mod: ModAlt | ModCtrl}, + "kRIT8": {Type: KeyRight, Mod: ModShift | ModAlt | ModCtrl}, + "kich1": {Type: KeyInsert}, + "kIC": {Type: KeyInsert, Mod: ModShift}, + "kIC3": {Type: KeyInsert, Mod: ModAlt}, + "kIC4": {Type: KeyInsert, Mod: ModShift | ModAlt}, + "kIC5": {Type: KeyInsert, Mod: ModCtrl}, + "kIC6": {Type: KeyInsert, Mod: ModShift | ModCtrl}, + "kIC7": {Type: KeyInsert, Mod: ModAlt | ModCtrl}, + "kIC8": {Type: KeyInsert, Mod: ModShift | ModAlt | ModCtrl}, + "kdch1": {Type: KeyDelete}, + "kDC": {Type: KeyDelete, Mod: ModShift}, + "kDC3": {Type: KeyDelete, Mod: ModAlt}, + "kDC4": {Type: KeyDelete, Mod: ModShift | ModAlt}, + "kDC5": {Type: KeyDelete, Mod: ModCtrl}, + "kDC6": {Type: KeyDelete, Mod: ModShift | ModCtrl}, + "kDC7": {Type: KeyDelete, Mod: ModAlt | ModCtrl}, + "kDC8": {Type: KeyDelete, Mod: ModShift | ModAlt | ModCtrl}, + "khome": {Type: KeyHome}, + "kHOM": {Type: KeyHome, Mod: ModShift}, + "kHOM3": {Type: KeyHome, Mod: ModAlt}, + "kHOM4": {Type: KeyHome, Mod: ModShift | ModAlt}, + "kHOM5": {Type: KeyHome, Mod: ModCtrl}, + "kHOM6": {Type: KeyHome, Mod: ModShift | ModCtrl}, + "kHOM7": {Type: KeyHome, Mod: ModAlt | ModCtrl}, + "kHOM8": {Type: KeyHome, Mod: ModShift | ModAlt | ModCtrl}, + "kend": {Type: KeyEnd}, + "kEND": {Type: KeyEnd, Mod: ModShift}, + "kEND3": {Type: KeyEnd, Mod: ModAlt}, + "kEND4": {Type: KeyEnd, Mod: ModShift | ModAlt}, + "kEND5": {Type: KeyEnd, Mod: ModCtrl}, + "kEND6": {Type: KeyEnd, Mod: ModShift | ModCtrl}, + "kEND7": {Type: KeyEnd, Mod: ModAlt | ModCtrl}, + "kEND8": {Type: KeyEnd, Mod: ModShift | ModAlt | ModCtrl}, + "kpp": {Type: KeyPgUp}, + "kprv": {Type: KeyPgUp}, + "kPRV": {Type: KeyPgUp, Mod: ModShift}, + "kPRV3": {Type: KeyPgUp, Mod: ModAlt}, + "kPRV4": {Type: KeyPgUp, Mod: ModShift | ModAlt}, + "kPRV5": {Type: KeyPgUp, Mod: ModCtrl}, + "kPRV6": {Type: KeyPgUp, Mod: ModShift | ModCtrl}, + "kPRV7": {Type: KeyPgUp, Mod: ModAlt | ModCtrl}, + "kPRV8": {Type: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl}, + "knp": {Type: KeyPgDown}, + "knxt": {Type: KeyPgDown}, + "kNXT": {Type: KeyPgDown, Mod: ModShift}, + "kNXT3": {Type: KeyPgDown, Mod: ModAlt}, + "kNXT4": {Type: KeyPgDown, Mod: ModShift | ModAlt}, + "kNXT5": {Type: KeyPgDown, Mod: ModCtrl}, + "kNXT6": {Type: KeyPgDown, Mod: ModShift | ModCtrl}, + "kNXT7": {Type: KeyPgDown, Mod: ModAlt | ModCtrl}, + "kNXT8": {Type: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl}, + + "kbs": {Type: KeyBackspace}, + "kcbt": {Type: 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": {Type: KeyF1}, + "kf2": {Type: KeyF2}, + "kf3": {Type: KeyF3}, + "kf4": {Type: KeyF4}, + "kf5": {Type: KeyF5}, + "kf6": {Type: KeyF6}, + "kf7": {Type: KeyF7}, + "kf8": {Type: KeyF8}, + "kf9": {Type: KeyF9}, + "kf10": {Type: KeyF10}, + "kf11": {Type: KeyF11}, + "kf12": {Type: KeyF12}, + "kf13": {Type: KeyF1, Mod: ModShift}, + "kf14": {Type: KeyF2, Mod: ModShift}, + "kf15": {Type: KeyF3, Mod: ModShift}, + "kf16": {Type: KeyF4, Mod: ModShift}, + "kf17": {Type: KeyF5, Mod: ModShift}, + "kf18": {Type: KeyF6, Mod: ModShift}, + "kf19": {Type: KeyF7, Mod: ModShift}, + "kf20": {Type: KeyF8, Mod: ModShift}, + "kf21": {Type: KeyF9, Mod: ModShift}, + "kf22": {Type: KeyF10, Mod: ModShift}, + "kf23": {Type: KeyF11, Mod: ModShift}, + "kf24": {Type: KeyF12, Mod: ModShift}, + "kf25": {Type: KeyF1, Mod: ModCtrl}, + "kf26": {Type: KeyF2, Mod: ModCtrl}, + "kf27": {Type: KeyF3, Mod: ModCtrl}, + "kf28": {Type: KeyF4, Mod: ModCtrl}, + "kf29": {Type: KeyF5, Mod: ModCtrl}, + "kf30": {Type: KeyF6, Mod: ModCtrl}, + "kf31": {Type: KeyF7, Mod: ModCtrl}, + "kf32": {Type: KeyF8, Mod: ModCtrl}, + "kf33": {Type: KeyF9, Mod: ModCtrl}, + "kf34": {Type: KeyF10, Mod: ModCtrl}, + "kf35": {Type: KeyF11, Mod: ModCtrl}, + "kf36": {Type: KeyF12, Mod: ModCtrl}, + "kf37": {Type: KeyF1, Mod: ModShift | ModCtrl}, + "kf38": {Type: KeyF2, Mod: ModShift | ModCtrl}, + "kf39": {Type: KeyF3, Mod: ModShift | ModCtrl}, + "kf40": {Type: KeyF4, Mod: ModShift | ModCtrl}, + "kf41": {Type: KeyF5, Mod: ModShift | ModCtrl}, + "kf42": {Type: KeyF6, Mod: ModShift | ModCtrl}, + "kf43": {Type: KeyF7, Mod: ModShift | ModCtrl}, + "kf44": {Type: KeyF8, Mod: ModShift | ModCtrl}, + "kf45": {Type: KeyF9, Mod: ModShift | ModCtrl}, + "kf46": {Type: KeyF10, Mod: ModShift | ModCtrl}, + "kf47": {Type: KeyF11, Mod: ModShift | ModCtrl}, + "kf48": {Type: KeyF12, Mod: ModShift | ModCtrl}, + "kf49": {Type: KeyF1, Mod: ModAlt}, + "kf50": {Type: KeyF2, Mod: ModAlt}, + "kf51": {Type: KeyF3, Mod: ModAlt}, + "kf52": {Type: KeyF4, Mod: ModAlt}, + "kf53": {Type: KeyF5, Mod: ModAlt}, + "kf54": {Type: KeyF6, Mod: ModAlt}, + "kf55": {Type: KeyF7, Mod: ModAlt}, + "kf56": {Type: KeyF8, Mod: ModAlt}, + "kf57": {Type: KeyF9, Mod: ModAlt}, + "kf58": {Type: KeyF10, Mod: ModAlt}, + "kf59": {Type: KeyF11, Mod: ModAlt}, + "kf60": {Type: KeyF12, Mod: ModAlt}, + "kf61": {Type: KeyF1, Mod: ModShift | ModAlt}, + "kf62": {Type: KeyF2, Mod: ModShift | ModAlt}, + "kf63": {Type: 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{Type: KeyF13} + keys["kf14"] = Key{Type: KeyF14} + keys["kf15"] = Key{Type: KeyF15} + keys["kf16"] = Key{Type: KeyF16} + keys["kf17"] = Key{Type: KeyF17} + keys["kf18"] = Key{Type: KeyF18} + keys["kf19"] = Key{Type: KeyF19} + keys["kf20"] = Key{Type: KeyF20} + keys["kf21"] = Key{Type: KeyF21} + keys["kf22"] = Key{Type: KeyF22} + keys["kf23"] = Key{Type: KeyF23} + keys["kf24"] = Key{Type: KeyF24} + keys["kf25"] = Key{Type: KeyF25} + keys["kf26"] = Key{Type: KeyF26} + keys["kf27"] = Key{Type: KeyF27} + keys["kf28"] = Key{Type: KeyF28} + keys["kf29"] = Key{Type: KeyF29} + keys["kf30"] = Key{Type: KeyF30} + keys["kf31"] = Key{Type: KeyF31} + keys["kf32"] = Key{Type: KeyF32} + keys["kf33"] = Key{Type: KeyF33} + keys["kf34"] = Key{Type: KeyF34} + keys["kf35"] = Key{Type: KeyF35} + keys["kf36"] = Key{Type: KeyF36} + keys["kf37"] = Key{Type: KeyF37} + keys["kf38"] = Key{Type: KeyF38} + keys["kf39"] = Key{Type: KeyF39} + keys["kf40"] = Key{Type: KeyF40} + keys["kf41"] = Key{Type: KeyF41} + keys["kf42"] = Key{Type: KeyF42} + keys["kf43"] = Key{Type: KeyF43} + keys["kf44"] = Key{Type: KeyF44} + keys["kf45"] = Key{Type: KeyF45} + keys["kf46"] = Key{Type: KeyF46} + keys["kf47"] = Key{Type: KeyF47} + keys["kf48"] = Key{Type: KeyF48} + keys["kf49"] = Key{Type: KeyF49} + keys["kf50"] = Key{Type: KeyF50} + keys["kf51"] = Key{Type: KeyF51} + keys["kf52"] = Key{Type: KeyF52} + keys["kf53"] = Key{Type: KeyF53} + keys["kf54"] = Key{Type: KeyF54} + keys["kf55"] = Key{Type: KeyF55} + keys["kf56"] = Key{Type: KeyF56} + keys["kf57"] = Key{Type: KeyF57} + keys["kf58"] = Key{Type: KeyF58} + keys["kf59"] = Key{Type: KeyF59} + keys["kf60"] = Key{Type: KeyF60} + keys["kf61"] = Key{Type: KeyF61} + keys["kf62"] = Key{Type: KeyF62} + keys["kf63"] = Key{Type: KeyF63} + } + + return keys +} diff --git a/tty.go b/tty.go index ed469ad43c..9ec3d2e03f 100644 --- a/tty.go +++ b/tty.go @@ -1,11 +1,14 @@ package tea import ( + "context" "errors" "fmt" "io" + "strings" "time" + "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/term" "github.com/muesli/cancelreader" ) @@ -23,19 +26,15 @@ func (p *Program) suspend() { } func (p *Program) initTerminal() error { - if err := p.initInput(); err != nil { - return err - } - - p.renderer.hideCursor() - return nil + return p.initInput() } // restoreTerminalState restores the terminal to the state prior to running the // Bubble Tea program. func (p *Program) restoreTerminalState() error { if p.renderer != nil { - p.renderer.disableBracketedPaste() + p.renderer.execute(ansi.DisableBracketedPaste) + p.bpActive = false p.renderer.showCursor() p.disableMouse() @@ -65,24 +64,164 @@ func (p *Program) restoreInput() error { return nil } -// initCancelReader (re)commences reading inputs. -func (p *Program) initCancelReader() error { - var err error - p.cancelReader, err = newInputReader(p.input) +// initInputReader (re)commences reading inputs. +func (p *Program) initInputReader() error { + var term string + for i := len(p.environ) - 1; i >= 0; i-- { + // We iterate backwards to find the last TERM variable set in the + // environment. This is because the last one is the one that will be + // used by the terminal. + parts := strings.SplitN(p.environ[i], "=", 2) + if len(parts) == 2 && parts[0] == "TERM" { + term = parts[1] + break + } + } + + // Initialize the input reader. + // This need to be done after the terminal has been initialized and set to + // raw mode. + // On Windows, this will change the console mode to enable mouse and window + // events. + var flags int // TODO: make configurable through environment variables? + drv, err := newDriver(p.input, term, flags) if err != nil { - return fmt.Errorf("error creating cancelreader: %w", err) + return err } + p.inputReader = drv p.readLoopDone = make(chan struct{}) go p.readLoop() return nil } +func readInputs(ctx context.Context, msgs chan<- Msg, reader *driver) error { + for { + events, err := reader.ReadEvents() + if err != nil { + return err + } + + for _, msg := range events { + incomingMsgs := []Msg{msg} + + // We need to translate new e types to deprecated ones to keep + // compatibility. + switch e := msg.(type) { + case PasteMsg: + var k KeyMsg + k.Paste = true + k.Runes = []rune(e) + incomingMsgs = append(incomingMsgs, k) + case KeyPressMsg: + k := KeyMsg{ + Alt: e.Mod.Contains(ModAlt), + Runes: e.Runes, + Type: e.Type, + } + + // Backwards compatibility for ctrl- and shift- keys + switch { + case e.Mod.Contains(ModCtrl | ModShift): + switch e.Type { + case KeyUp, KeyDown, KeyRight, KeyLeft: + k.Runes = nil + k.Type = KeyCtrlShiftUp - e.Type + KeyUp + case KeyHome, KeyEnd: + k.Runes = nil + k.Type = KeyCtrlShiftHome - e.Type + KeyHome + } + case e.Mod.Contains(ModCtrl): + switch e.Type { + case KeyRunes: // KeyRunes + switch r := e.Rune(); r { + case ' ': + k.Runes = nil + k.Type = KeyCtrlAt + case '[', '\\', ']', '^', '_': + k.Runes = nil + k.Type = KeyCtrlOpenBracket - KeyType(r) + '[' + case 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', + 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', + 'u', 'v', 'w', 'x', 'y', 'z': + k.Runes = nil + k.Type = KeyCtrlA - KeyType(r) + 'a' + case '?': + k.Runes = nil + k.Type = KeyCtrlQuestionMark + } + case KeyPgUp, KeyPgDown, KeyHome, KeyEnd: + k.Runes = nil + k.Type = KeyCtrlPgUp - e.Type + KeyPgUp + case KeyUp, KeyDown, KeyRight, KeyLeft: + k.Runes = nil + k.Type = KeyCtrlUp - e.Type + KeyUp + } + case e.Mod.Contains(ModShift): + switch e.Type { + case KeyTab: + k.Runes = nil + k.Type = KeyShiftTab + case KeyUp, KeyDown, KeyRight, KeyLeft: + k.Runes = nil + k.Type = KeyShiftUp - e.Type + KeyUp + k.Runes = nil + case KeyHome, KeyEnd: + k.Runes = nil + k.Type = KeyShiftHome - e.Type + KeyHome + } + } + + switch k.Type { + case KeyRunes: // KeyRunes + if len(k.Runes) > 0 { + incomingMsgs = append(incomingMsgs, k) + } + default: + incomingMsgs = append(incomingMsgs, k) + } + case MouseClickMsg: + m := toMouseMsg(Mouse(e)) + m.Action = MouseActionPress + m.Type = e.Button + incomingMsgs = append(incomingMsgs, m) + case MouseReleaseMsg: + m := toMouseMsg(Mouse(e)) + m.Action = MouseActionRelease + m.Type = MouseRelease + incomingMsgs = append(incomingMsgs, m) + case MouseWheelMsg: + m := toMouseMsg(Mouse(e)) + m.Action = MouseActionPress + m.Type = e.Button + incomingMsgs = append(incomingMsgs, m) + case MouseMotionMsg: + m := toMouseMsg(Mouse(e)) + m.Action = MouseActionMotion + m.Type = MouseMotion + incomingMsgs = append(incomingMsgs, m) + } + + for _, m := range incomingMsgs { + select { + case msgs <- m: + case <-ctx.Done(): + err := ctx.Err() + if err != nil { + err = fmt.Errorf("found context error while reading input: %w", err) + } + return err + } + } + } + } +} + func (p *Program) readLoop() { defer close(p.readLoopDone) - err := readInputs(p.ctx, p.msgs, p.cancelReader) + err := readInputs(p.ctx, p.msgs, p.inputReader) if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) { select { case <-p.ctx.Done(): diff --git a/tutorials/go.mod b/tutorials/go.mod index 2b31c4f4f7..fa326824b2 100644 --- a/tutorials/go.mod +++ b/tutorials/go.mod @@ -5,20 +5,18 @@ go 1.18 require github.com/charmbracelet/bubbletea v0.25.0 require ( - github.com/charmbracelet/x/ansi v0.1.1 // indirect + github.com/charmbracelet/x/ansi v0.1.4 // indirect github.com/charmbracelet/x/input v0.1.0 // indirect github.com/charmbracelet/x/term v0.1.1 // indirect github.com/charmbracelet/x/windows v0.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect - github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.13.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/sys v0.24.0 // indirect ) replace github.com/charmbracelet/bubbletea => ../ diff --git a/tutorials/go.sum b/tutorials/go.sum index 1c500b5377..afe8f921d0 100644 --- a/tutorials/go.sum +++ b/tutorials/go.sum @@ -1,5 +1,5 @@ -github.com/charmbracelet/x/ansi v0.1.1 h1:CGAduulr6egay/YVbGc8Hsu8deMg1xZ/bkaXTPi1JDk= -github.com/charmbracelet/x/ansi v0.1.1/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM= +github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/input v0.1.0 h1:TEsGSfZYQyOtp+STIjyBq6tpRaorH0qpwZUj8DavAhQ= github.com/charmbracelet/x/input v0.1.0/go.mod h1:ZZwaBxPF7IG8gWWzPUVqHEtWhc1+HXJPNuerJGRGZ28= github.com/charmbracelet/x/term v0.1.1 h1:3cosVAiPOig+EV4X9U+3LDgtwwAoEzJjNdwbXDjF6yI= @@ -8,8 +8,6 @@ github.com/charmbracelet/x/windows v0.1.0 h1:gTaxdvzDM5oMa/I2ZNF7wN78X/atWemG9Wp github.com/charmbracelet/x/windows v0.1.0/go.mod h1:GLEO/l+lizvFDBPLIOk+49gdX49L9YWMB5t+DZd0jkQ= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -22,10 +20,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/win32input.go b/win32input.go new file mode 100644 index 0000000000..526f61aba0 --- /dev/null +++ b/win32input.go @@ -0,0 +1,240 @@ +package tea + +import ( + "unicode" + + "github.com/erikgeiser/coninput" +) + +func parseWin32InputKeyEvent(vkc coninput.VirtualKeyCode, _ coninput.VirtualKeyCode, r rune, keyDown bool, cks coninput.ControlKeyState, repeatCount uint16) Msg { + var key Key + isCtrl := cks.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED) + switch vkc { + case coninput.VK_SHIFT: + // We currently ignore these keys when they are pressed alone. + return nil + case coninput.VK_MENU: + if cks.Contains(coninput.LEFT_ALT_PRESSED) { + key = Key{Type: KeyLeftAlt} + } else if cks.Contains(coninput.RIGHT_ALT_PRESSED) { + key = Key{Type: KeyRightAlt} + } else if !keyDown { + return nil + } + case coninput.VK_CONTROL: + if cks.Contains(coninput.LEFT_CTRL_PRESSED) { + key = Key{Type: KeyLeftCtrl} + } else if cks.Contains(coninput.RIGHT_CTRL_PRESSED) { + key = Key{Type: KeyRightCtrl} + } else if !keyDown { + return nil + } + case coninput.VK_CAPITAL: + key = Key{Type: KeyCapsLock} + default: + var ok bool + key, ok = vkKeyEvent[vkc] + if !ok { + if isCtrl { + key = vkCtrlRune(key, r, vkc) + } else { + key = Key{Runes: []rune{r}} + } + } + } + + if isCtrl { + key.Mod |= ModCtrl + } + if cks.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED) { + key.Mod |= ModAlt + } + if cks.Contains(coninput.SHIFT_PRESSED) { + key.Mod |= ModShift + } + if cks.Contains(coninput.CAPSLOCK_ON) { + key.Mod |= ModCapsLock + } + if cks.Contains(coninput.NUMLOCK_ON) { + key.Mod |= ModNumLock + } + if cks.Contains(coninput.SCROLLLOCK_ON) { + key.Mod |= ModScrollLock + } + + // Use the unshifted key + if cks.Contains(coninput.SHIFT_PRESSED ^ coninput.CAPSLOCK_ON) { + key.altRune = unicode.ToUpper(key.Rune()) + } else { + key.altRune = unicode.ToLower(key.Rune()) + } + + var e Msg = KeyPressMsg(key) + key.IsRepeat = repeatCount > 1 + if !keyDown { + e = KeyReleaseMsg(key) + } + + if repeatCount <= 1 { + return e + } + + var kevents []Msg + for i := 0; i < int(repeatCount); i++ { + kevents = append(kevents, e) + } + + return multiMsg(kevents) +} + +var vkKeyEvent = map[coninput.VirtualKeyCode]Key{ + coninput.VK_RETURN: {Type: KeyEnter}, + coninput.VK_BACK: {Type: KeyBackspace}, + coninput.VK_TAB: {Type: KeyTab}, + coninput.VK_ESCAPE: {Type: KeyEscape}, + coninput.VK_SPACE: {Type: KeySpace, Runes: []rune{' '}}, + coninput.VK_UP: {Type: KeyUp}, + coninput.VK_DOWN: {Type: KeyDown}, + coninput.VK_RIGHT: {Type: KeyRight}, + coninput.VK_LEFT: {Type: KeyLeft}, + coninput.VK_HOME: {Type: KeyHome}, + coninput.VK_END: {Type: KeyEnd}, + coninput.VK_PRIOR: {Type: KeyPgUp}, + coninput.VK_NEXT: {Type: KeyPgDown}, + coninput.VK_DELETE: {Type: KeyDelete}, + coninput.VK_SELECT: {Type: KeySelect}, + coninput.VK_SNAPSHOT: {Type: KeyPrintScreen}, + coninput.VK_INSERT: {Type: KeyInsert}, + coninput.VK_LWIN: {Type: KeyLeftSuper}, + coninput.VK_RWIN: {Type: KeyRightSuper}, + coninput.VK_APPS: {Type: KeyMenu}, + coninput.VK_NUMPAD0: {Type: KeyKp0}, + coninput.VK_NUMPAD1: {Type: KeyKp1}, + coninput.VK_NUMPAD2: {Type: KeyKp2}, + coninput.VK_NUMPAD3: {Type: KeyKp3}, + coninput.VK_NUMPAD4: {Type: KeyKp4}, + coninput.VK_NUMPAD5: {Type: KeyKp5}, + coninput.VK_NUMPAD6: {Type: KeyKp6}, + coninput.VK_NUMPAD7: {Type: KeyKp7}, + coninput.VK_NUMPAD8: {Type: KeyKp8}, + coninput.VK_NUMPAD9: {Type: KeyKp9}, + coninput.VK_MULTIPLY: {Type: KeyKpMultiply}, + coninput.VK_ADD: {Type: KeyKpPlus}, + coninput.VK_SEPARATOR: {Type: KeyKpComma}, + coninput.VK_SUBTRACT: {Type: KeyKpMinus}, + coninput.VK_DECIMAL: {Type: KeyKpDecimal}, + coninput.VK_DIVIDE: {Type: KeyKpDivide}, + coninput.VK_F1: {Type: KeyF1}, + coninput.VK_F2: {Type: KeyF2}, + coninput.VK_F3: {Type: KeyF3}, + coninput.VK_F4: {Type: KeyF4}, + coninput.VK_F5: {Type: KeyF5}, + coninput.VK_F6: {Type: KeyF6}, + coninput.VK_F7: {Type: KeyF7}, + coninput.VK_F8: {Type: KeyF8}, + coninput.VK_F9: {Type: KeyF9}, + coninput.VK_F10: {Type: KeyF10}, + coninput.VK_F11: {Type: KeyF11}, + coninput.VK_F12: {Type: KeyF12}, + coninput.VK_F13: {Type: KeyF13}, + coninput.VK_F14: {Type: KeyF14}, + coninput.VK_F15: {Type: KeyF15}, + coninput.VK_F16: {Type: KeyF16}, + coninput.VK_F17: {Type: KeyF17}, + coninput.VK_F18: {Type: KeyF18}, + coninput.VK_F19: {Type: KeyF19}, + coninput.VK_F20: {Type: KeyF20}, + coninput.VK_F21: {Type: KeyF21}, + coninput.VK_F22: {Type: KeyF22}, + coninput.VK_F23: {Type: KeyF23}, + coninput.VK_F24: {Type: KeyF24}, + coninput.VK_NUMLOCK: {Type: KeyNumLock}, + coninput.VK_SCROLL: {Type: KeyScrollLock}, + coninput.VK_LSHIFT: {Type: KeyLeftShift}, + coninput.VK_RSHIFT: {Type: KeyRightShift}, + coninput.VK_LCONTROL: {Type: KeyLeftCtrl}, + coninput.VK_RCONTROL: {Type: KeyRightCtrl}, + coninput.VK_LMENU: {Type: KeyLeftAlt}, + coninput.VK_RMENU: {Type: KeyRightAlt}, + coninput.VK_OEM_4: {Runes: []rune{'['}}, + // TODO: add more keys +} + +func vkCtrlRune(k Key, r rune, kc coninput.VirtualKeyCode) Key { + switch r { + case '@': + k.Runes = []rune{'@'} + case '\x01': + k.Runes = []rune{'a'} + case '\x02': + k.Runes = []rune{'b'} + case '\x03': + k.Runes = []rune{'c'} + case '\x04': + k.Runes = []rune{'d'} + case '\x05': + k.Runes = []rune{'e'} + case '\x06': + k.Runes = []rune{'f'} + case '\a': + k.Runes = []rune{'g'} + case '\b': + k.Runes = []rune{'h'} + case '\t': + k.Runes = []rune{'i'} + case '\n': + k.Runes = []rune{'j'} + case '\v': + k.Runes = []rune{'k'} + case '\f': + k.Runes = []rune{'l'} + case '\r': + k.Runes = []rune{'m'} + case '\x0e': + k.Runes = []rune{'n'} + case '\x0f': + k.Runes = []rune{'o'} + case '\x10': + k.Runes = []rune{'p'} + case '\x11': + k.Runes = []rune{'q'} + case '\x12': + k.Runes = []rune{'r'} + case '\x13': + k.Runes = []rune{'s'} + case '\x14': + k.Runes = []rune{'t'} + case '\x15': + k.Runes = []rune{'u'} + case '\x16': + k.Runes = []rune{'v'} + case '\x17': + k.Runes = []rune{'w'} + case '\x18': + k.Runes = []rune{'x'} + case '\x19': + k.Runes = []rune{'y'} + case '\x1a': + k.Runes = []rune{'z'} + case '\x1b': + k.Runes = []rune{']'} + case '\x1c': + k.Runes = []rune{'\\'} + case '\x1f': + k.Runes = []rune{'_'} + } + + switch kc { + case coninput.VK_OEM_4: + k.Runes = []rune{'['} + } + + // https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes + if len(k.Runes) == 0 && + (kc >= 0x30 && kc <= 0x39) || + (kc >= 0x41 && kc <= 0x5a) { + k.Runes = []rune{rune(kc)} + } + + return k +} diff --git a/xterm.go b/xterm.go new file mode 100644 index 0000000000..f09caeba52 --- /dev/null +++ b/xterm.go @@ -0,0 +1,42 @@ +package tea + +import ( + "github.com/charmbracelet/x/ansi" +) + +func parseXTermModifyOtherKeys(csi *ansi.CsiSequence) Msg { + // XTerm modify other keys starts with ESC [ 27 ; ; ~ + mod := KeyMod(csi.Param(1) - 1) + r := rune(csi.Param(2)) + + switch r { + case ansi.BS: + return KeyPressMsg{Mod: mod, Type: KeyBackspace} + case ansi.HT: + return KeyPressMsg{Mod: mod, Type: KeyTab} + case ansi.CR: + return KeyPressMsg{Mod: mod, Type: KeyEnter} + case ansi.ESC: + return KeyPressMsg{Mod: mod, Type: KeyEscape} + case ansi.DEL: + return KeyPressMsg{Mod: mod, Type: KeyBackspace} + } + + // CSI 27 ; ; ~ keys defined in XTerm modifyOtherKeys + return KeyPressMsg{ + Mod: mod, + Runes: []rune{r}, + } +} + +// ModifyOtherKeysMsg is a message that represents XTerm modifyOtherKeys +// report. Querying the terminal for the modifyOtherKeys mode will return a +// ModifyOtherKeysMsg message with the current mode set. +// +// 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 ModifyOtherKeysMsg uint8