From ed4f43e690ed7b684bdaf57d7ce4127c1e7b13bc Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Mon, 12 Aug 2024 17:04:04 -0400 Subject: [PATCH] refactor: reimplement the event loop with a sequence parser Currently, Bubble Tea uses a simple lookup table to detect input events. Here, we're introducing an actual input sequence parser instead of simply using a lookup table. This will allow Bubble Tea programs to read all sorts of input events such Kitty keyboard, background color, mode report, and all sorts of ANSI sequence input events. Supersedes: https://github.com/charmbracelet/bubbletea/pull/1079 Supersedes: https://github.com/charmbracelet/bubbletea/issues/1014 Related: https://github.com/charmbracelet/bubbletea/issues/869 Related: https://github.com/charmbracelet/bubbletea/issues/163 Related: https://github.com/charmbracelet/bubbletea/issues/918 Related: https://github.com/charmbracelet/bubbletea/issues/850 Related: https://github.com/charmbracelet/bubbletea/issues/207 --- inputreader_other.go => cancelreader_other.go | 2 +- cancelreader_windows.go | 217 +++ clipboard.go | 9 + color.go | 77 ++ cursor.go | 10 + da1.go | 18 + driver.go | 129 ++ driver_other.go | 11 + driver_test.go | 25 + driver_windows.go | 272 ++++ examples/go.mod | 8 +- examples/go.sum | 13 +- focus.go | 9 + focus_test.go | 25 + go.mod | 4 +- go.sum | 4 - input.go | 26 + inputreader_windows.go | 107 -- key.go | 1063 ++++++--------- key_deprecated.go | 259 ++++ key_other.go | 13 - key_sequences.go | 119 -- key_test.go | 564 ++++---- key_windows.go | 351 ----- kitty.go | 281 ++++ mod.go | 70 + mode.go | 12 + mouse.go | 368 +++-- mouse_deprecated.go | 162 +++ mouse_test.go | 1178 +++++------------ parse.go | 826 ++++++++++++ parse_test.go | 41 + paste.go | 13 + table.go | 391 ++++++ tea.go | 31 +- termcap.go | 54 + terminfo.go | 277 ++++ tty.go | 154 ++- win32input.go | 240 ++++ xterm.go | 40 + 40 files changed, 4877 insertions(+), 2596 deletions(-) rename inputreader_other.go => cancelreader_other.go (66%) create mode 100644 cancelreader_windows.go create mode 100644 clipboard.go create mode 100644 color.go create mode 100644 cursor.go create mode 100644 da1.go create mode 100644 driver.go create mode 100644 driver_other.go create mode 100644 driver_test.go create mode 100644 driver_windows.go create mode 100644 focus.go create mode 100644 focus_test.go create mode 100644 input.go delete mode 100644 inputreader_windows.go create mode 100644 key_deprecated.go delete mode 100644 key_other.go delete mode 100644 key_sequences.go delete mode 100644 key_windows.go create mode 100644 kitty.go create mode 100644 mod.go create mode 100644 mode.go create mode 100644 mouse_deprecated.go create mode 100644 parse.go create mode 100644 parse_test.go create mode 100644 paste.go create mode 100644 table.go create mode 100644 termcap.go create mode 100644 terminfo.go create mode 100644 win32input.go create mode 100644 xterm.go 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..619844485a --- /dev/null +++ b/clipboard.go @@ -0,0 +1,9 @@ +package tea + +// ClipboardEvent is a clipboard read event. +type ClipboardEvent string + +// String returns the string representation of the clipboard event. +func (e ClipboardEvent) String() string { + return string(e) +} diff --git a/color.go b/color.go new file mode 100644 index 0000000000..20dad064f3 --- /dev/null +++ b/color.go @@ -0,0 +1,77 @@ +package tea + +import ( + "fmt" + "image/color" + "strconv" + "strings" +) + +// ForegroundColorEvent represents a foreground color change event. +type ForegroundColorEvent struct{ color.Color } + +// String implements fmt.Stringer. +func (e ForegroundColorEvent) String() string { + return colorToHex(e) +} + +// BackgroundColorEvent represents a background color change event. +type BackgroundColorEvent struct{ color.Color } + +// String implements fmt.Stringer. +func (e BackgroundColorEvent) String() string { + return colorToHex(e) +} + +// CursorColorEvent represents a cursor color change event. +type CursorColorEvent struct{ color.Color } + +// String implements fmt.Stringer. +func (e CursorColorEvent) 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..b231d46fa4 --- /dev/null +++ b/cursor.go @@ -0,0 +1,10 @@ +package tea + +// CursorPositionEvent represents a cursor position event. +type CursorPositionEvent 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..02cf89157f --- /dev/null +++ b/da1.go @@ -0,0 +1,18 @@ +package tea + +import "github.com/charmbracelet/x/ansi" + +// PrimaryDeviceAttributesMsg represents a primary device attributes message. +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/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..94d9b8aefd 100644 --- a/key.go +++ b/key.go @@ -1,240 +1,77 @@ package tea -import ( - "context" - "fmt" - "io" - "regexp" - "strings" - "unicode/utf8" -) +// KeySym is a keyboard symbol. +type KeySym int -// 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. -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 -} +// Key Symbol constants. +const ( + KeyNone KeySym = iota -// 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 "" -} + // Special names in C0 -// 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} -// if k.Type == KeyRunes { -// -// fmt.Println(k.Runes) -// // Output: a -// -// fmt.Println(k.String()) -// // Output: alt+a -// -// } -type KeyType int + KeyBackspace + KeyTab + KeyEnter + KeyEscape -func (k KeyType) String() (str string) { - if s, ok := keyNames[k]; ok { - return s - } - return "" -} + // Special names in G0 -// 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 -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 -) + KeySpace + KeyDelete -// 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 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 +92,394 @@ 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 represents a key event. +type Key struct { + // Sym is a special key, like enter, tab, backspace, and so on. + Sym KeySym -// 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}, + // 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 + + // 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.HasCtrl() && k.Sym != KeyLeftCtrl && k.Sym != KeyRightCtrl { + s += "ctrl+" } - - // Detect bracketed paste. - var foundbp bool - foundbp, w, msg = detectBracketedPaste(b) - if foundbp { - return w, msg + if k.Mod.HasAlt() && k.Sym != KeyLeftAlt && k.Sym != 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.HasShift() && k.Sym != KeyLeftShift && k.Sym != 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.HasMeta() && k.Sym != KeyLeftMeta && k.Sym != 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.HasHyper() && k.Sym != KeyLeftHyper && k.Sym != KeyRightHyper { + s += "hyper+" + } + if k.Mod.HasSuper() && k.Sym != KeyLeftSuper && k.Sym != 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 - } - runes = append(runes, r) - if alt { - // We only support a single rune after an escape alt modifier. - i += rw - break + runeStr := func(r rune) string { + // Space is the only invisible printable character. + if r == ' ' { + return "space" } + return string(r) } - 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 - } - - // 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.Sym.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 implements fmt.Stringer and prints the string representation of a of +// a Symbol key. +func (k KeySym) String() string { + s, ok := keySymString[k] + if !ok { + return "unknown" } + return s +} - // 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 keySymString = map[KeySym]string{ + KeyEnter: "enter", + KeyTab: "tab", + KeyBackspace: "backspace", + KeyEscape: "esc", + KeySpace: "space", + KeyUp: "up", + KeyDown: "down", + KeyLeft: "left", + KeyRight: "right", + KeyBegin: "begin", + KeyFind: "find", + KeyInsert: "insert", + KeyDelete: "delete", + KeySelect: "select", + KeyPgUp: "pgup", + KeyPgDown: "pgdown", + KeyHome: "home", + KeyEnd: "end", + KeyKpEnter: "kpenter", + KeyKpEqual: "kpequal", + KeyKpMultiply: "kpmul", + KeyKpPlus: "kpplus", + KeyKpComma: "kpcomma", + KeyKpMinus: "kpminus", + KeyKpDecimal: "kpperiod", + KeyKpDivide: "kpdiv", + KeyKp0: "kp0", + KeyKp1: "kp1", + KeyKp2: "kp2", + KeyKp3: "kp3", + KeyKp4: "kp4", + KeyKp5: "kp5", + KeyKp6: "kp6", + KeyKp7: "kp7", + KeyKp8: "kp8", + KeyKp9: "kp9", + + // Kitty keyboard extension + KeyKpSep: "kpsep", + KeyKpUp: "kpup", + KeyKpDown: "kpdown", + KeyKpLeft: "kpleft", + KeyKpRight: "kpright", + KeyKpPgUp: "kppgup", + KeyKpPgDown: "kppgdown", + KeyKpHome: "kphome", + KeyKpEnd: "kpend", + KeyKpInsert: "kpinsert", + KeyKpDelete: "kpdelete", + KeyKpBegin: "kpbegin", + + KeyF1: "f1", + KeyF2: "f2", + KeyF3: "f3", + KeyF4: "f4", + KeyF5: "f5", + KeyF6: "f6", + KeyF7: "f7", + KeyF8: "f8", + KeyF9: "f9", + KeyF10: "f10", + KeyF11: "f11", + KeyF12: "f12", + KeyF13: "f13", + KeyF14: "f14", + KeyF15: "f15", + KeyF16: "f16", + KeyF17: "f17", + KeyF18: "f18", + KeyF19: "f19", + KeyF20: "f20", + KeyF21: "f21", + KeyF22: "f22", + KeyF23: "f23", + KeyF24: "f24", + KeyF25: "f25", + KeyF26: "f26", + KeyF27: "f27", + KeyF28: "f28", + KeyF29: "f29", + KeyF30: "f30", + KeyF31: "f31", + KeyF32: "f32", + KeyF33: "f33", + KeyF34: "f34", + KeyF35: "f35", + KeyF36: "f36", + KeyF37: "f37", + KeyF38: "f38", + KeyF39: "f39", + KeyF40: "f40", + KeyF41: "f41", + KeyF42: "f42", + KeyF43: "f43", + KeyF44: "f44", + KeyF45: "f45", + KeyF46: "f46", + KeyF47: "f47", + KeyF48: "f48", + KeyF49: "f49", + KeyF50: "f50", + KeyF51: "f51", + KeyF52: "f52", + KeyF53: "f53", + KeyF54: "f54", + KeyF55: "f55", + KeyF56: "f56", + KeyF57: "f57", + KeyF58: "f58", + KeyF59: "f59", + KeyF60: "f60", + KeyF61: "f61", + KeyF62: "f62", + KeyF63: "f63", + + // Kitty keyboard extension + KeyCapsLock: "capslock", + KeyScrollLock: "scrolllock", + KeyNumLock: "numlock", + KeyPrintScreen: "printscreen", + KeyPause: "pause", + KeyMenu: "menu", + KeyMediaPlay: "mediaplay", + KeyMediaPause: "mediapause", + KeyMediaPlayPause: "mediaplaypause", + KeyMediaReverse: "mediareverse", + KeyMediaStop: "mediastop", + KeyMediaFastForward: "mediafastforward", + KeyMediaRewind: "mediarewind", + KeyMediaNext: "medianext", + KeyMediaPrev: "mediaprev", + KeyMediaRecord: "mediarecord", + KeyLowerVol: "lowervol", + KeyRaiseVol: "raisevol", + KeyMute: "mute", + KeyLeftShift: "leftshift", + KeyLeftAlt: "leftalt", + KeyLeftCtrl: "leftctrl", + KeyLeftSuper: "leftsuper", + KeyLeftHyper: "lefthyper", + KeyLeftMeta: "leftmeta", + KeyRightShift: "rightshift", + KeyRightAlt: "rightalt", + KeyRightCtrl: "rightctrl", + KeyRightSuper: "rightsuper", + KeyRightHyper: "righthyper", + KeyRightMeta: "rightmeta", + KeyIsoLevel3Shift: "isolevel3shift", + KeyIsoLevel5Shift: "isolevel5shift", } diff --git a/key_deprecated.go b/key_deprecated.go new file mode 100644 index 0000000000..f63b002583 --- /dev/null +++ b/key_deprecated.go @@ -0,0 +1,259 @@ +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 "" +} + +// 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} +// if k.Type == KeyRunes { +// +// fmt.Println(k.Runes) +// // Output: a +// +// fmt.Println(k.String()) +// // Output: alt+a +// +// } +type KeyType = KeySym + +// Control key aliases. +const ( + KeyNull KeyType = -iota - 1 + 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 + + // Deprecated: Use KeyNone instead. + KeyRunes = KeyNone +) + +// 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..f8fcd524cc 100644 --- a/key_test.go +++ b/key_test.go @@ -15,53 +15,52 @@ import ( "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{Sym: 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{Sym: 99999} + if got := k.String(); got != "unknown" { + 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) + if got := KeySym(99999).String(); got != "unknown" { + t.Fatalf(`expected a "unknown", got %q`, got) } }) } type seqTest struct { - seq []byte - msg Msg + seq []byte + msgs []Msg } // buildBaseSeqTests returns sequence tests that are valid for the @@ -69,25 +68,7 @@ type seqTest struct { 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 - } + td = append(td, seqTest{[]byte(seq), []Msg{KeyPressMsg(key)}}) } // Additional special cases. @@ -95,102 +76,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{Sym: KeySpace, Runes: []rune{' '}}, + }, }, // An escape character with the alt modifier. seqTest{ []byte{'\x1b', ' '}, - KeyMsg{Type: KeySpace, Runes: []rune(" "), Alt: true}, + []Msg{ + KeyPressMsg{Sym: 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{Sym: 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{' '}, Sym: KeySpace, Mod: ModCtrl}, + }, }, seqTest{ - []byte{'\x1b', byte(keyNUL)}, - KeyMsg{Type: KeyCtrlAt, Alt: true}, + []byte{'\x1b', ansi.NUL}, + []Msg{ + KeyPressMsg{Runes: []rune{' '}, Sym: KeySpace, Mod: ModCtrl | ModAlt}, + }, }, - // Invalid characters. + // C1 control characters. seqTest{ []byte{'\x80'}, - unknownInputByteMsg(0x80), + []Msg{ + KeyPressMsg{Runes: []rune{0x80 - '@'}, Mod: ModCtrl | ModAlt}, + }, }, ) @@ -199,39 +207,53 @@ 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) + 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 +264,185 @@ func TestReadInput(t *testing.T) { out []Msg } testData := []test{ - {"a", + { + "a", []byte{'a'}, []Msg{ - KeyMsg{ - Type: KeyRunes, - Runes: []rune{'a'}, - }, + KeyPressMsg{Sym: KeyNone, Runes: []rune{'a'}}, }, }, - {" ", + { + "space", []byte{' '}, []Msg{ - KeyMsg{ - Type: KeySpace, - Runes: []rune{' '}, - }, + KeyPressMsg{Sym: 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{Sym: KeyNone, Runes: []rune{'a'}}, + KeyPressMsg{Sym: KeyNone, 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{Sym: KeyNone, Runes: []rune{'a'}}, + KeyPressMsg{Sym: KeyNone, Runes: []rune{'a'}, Mod: ModAlt}, + KeyPressMsg{Sym: KeyNone, 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{Sym: KeyNone, 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{Sym: 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{Sym: KeyTab, Mod: ModShift}, }, }, - {"enter", + { + "enter", []byte{'\r'}, - []Msg{KeyMsg{Type: KeyEnter}}, + []Msg{KeyPressMsg{Sym: KeyEnter}}, }, - {"alt+enter", + { + "alt+enter", []byte{'\x1b', '\r'}, []Msg{ - KeyMsg{ - Type: KeyEnter, - Alt: true, - }, + KeyPressMsg{Sym: KeyEnter, Mod: ModAlt}, }, }, - {"insert", + { + "insert", []byte{'\x1b', '[', '2', '~'}, []Msg{ - KeyMsg{ - Type: KeyInsert, - }, + KeyPressMsg{Sym: 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{Sym: KeyUp}}, }, - {"down", + { + "down", []byte{'\x1b', 'O', 'B'}, - []Msg{KeyMsg{Type: KeyDown}}, + []Msg{KeyPressMsg{Sym: KeyDown}}, }, - {"right", + { + "right", []byte{'\x1b', 'O', 'C'}, - []Msg{KeyMsg{Type: KeyRight}}, + []Msg{KeyPressMsg{Sym: KeyRight}}, }, - {"left", + { + "left", []byte{'\x1b', 'O', 'D'}, - []Msg{KeyMsg{Type: KeyLeft}}, + []Msg{KeyPressMsg{Sym: KeyLeft}}, }, - {"alt+enter", + { + "alt+enter", []byte{'\x1b', '\x0d'}, - []Msg{KeyMsg{Type: KeyEnter, Alt: true}}, + []Msg{KeyPressMsg{Sym: KeyEnter, Mod: ModAlt}}, }, - {"alt+backspace", + { + "alt+backspace", []byte{'\x1b', '\x7f'}, - []Msg{KeyMsg{Type: KeyBackspace, Alt: true}}, + []Msg{KeyPressMsg{Sym: KeyBackspace, Mod: ModAlt}}, }, - {"ctrl+@", + { + "ctrl+space", []byte{'\x00'}, - []Msg{KeyMsg{Type: KeyCtrlAt}}, + []Msg{KeyPressMsg{Sym: KeySpace, Runes: []rune{' '}, Mod: ModCtrl}}, }, - {"alt+ctrl+@", + { + "ctrl+alt+space", []byte{'\x1b', '\x00'}, - []Msg{KeyMsg{Type: KeyCtrlAt, Alt: true}}, + []Msg{KeyPressMsg{Sym: KeySpace, Runes: []rune{' '}, Mod: ModCtrl | ModAlt}}, }, - {"esc", + { + "esc", []byte{'\x1b'}, - []Msg{KeyMsg{Type: KeyEsc}}, + []Msg{KeyPressMsg{Sym: KeyEscape}}, }, - {"alt+esc", + { + "alt+esc", []byte{'\x1b', '\x1b'}, - []Msg{KeyMsg{Type: KeyEsc, Alt: true}}, + []Msg{KeyPressMsg{Sym: KeyEscape, Mod: ModAlt}}, }, - {"[a b] o", + { + "a b o", []byte{ '\x1b', '[', '2', '0', '0', '~', 'a', ' ', 'b', @@ -442,38 +450,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{Sym: KeyNone, 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{Sym: KeyNone, Runes: []rune{'a'}}, + UnknownMsg(rune(0xfe)), + KeyPressMsg{Sym: KeySpace, Runes: []rune{' '}}, + KeyPressMsg{Sym: KeyNone, 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 +503,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 +528,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 +540,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 +633,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 +649,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 +682,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..5365e29518 --- /dev/null +++ b/kitty.go @@ -0,0 +1,281 @@ +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]KeySym{ + 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.Sym = sym + } else { + r := rune(code) + if !utf8.ValidRune(r) { + r = utf8.RuneError + } + + 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..a93ec72df4 --- /dev/null +++ b/mod.go @@ -0,0 +1,70 @@ +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 +) + +// HasShift reports whether the Shift modifier is set. +func (m KeyMod) HasShift() bool { + return m&ModShift != 0 +} + +// HasAlt reports whether the Alt modifier is set. +func (m KeyMod) HasAlt() bool { + return m&ModAlt != 0 +} + +// HasCtrl reports whether the Ctrl modifier is set. +func (m KeyMod) HasCtrl() bool { + return m&ModCtrl != 0 +} + +// HasMeta reports whether the Meta modifier is set. +func (m KeyMod) HasMeta() bool { + return m&ModMeta != 0 +} + +// HasHyper reports whether the Hyper modifier is set. +func (m KeyMod) HasHyper() bool { + return m&ModHyper != 0 +} + +// HasSuper reports whether the Super modifier is set. +func (m KeyMod) HasSuper() bool { + return m&ModSuper != 0 +} + +// HasCapsLock reports whether the CapsLock key is enabled. +func (m KeyMod) HasCapsLock() bool { + return m&ModCapsLock != 0 +} + +// HasNumLock reports whether the NumLock key is enabled. +func (m KeyMod) HasNumLock() bool { + return m&ModNumLock != 0 +} + +// HasScrollLock reports whether the ScrollLock key is enabled. +func (m KeyMod) HasScrollLock() bool { + return m&ModScrollLock != 0 +} diff --git a/mode.go b/mode.go new file mode 100644 index 0000000000..bee0e3b3ef --- /dev/null +++ b/mode.go @@ -0,0 +1,12 @@ +package tea + +// ReportModeEvent represents a report mode event for sequence DECRPM. +// +// See: https://vt100.net/docs/vt510-rm/DECRPM.html +type ReportModeEvent 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..710770d2bb 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.HasCtrl() { + s += "ctrl+" + } + if m.Mod.HasAlt() { + s += "alt+" + } + if m.Mod.HasShift() { + 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..fb2c41da6c --- /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.HasShift(), + Alt: m.Mod.HasAlt(), + Ctrl: m.Mod.HasCtrl(), + 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/parse.go b/parse.go new file mode 100644 index 0000000000..a2b404c67e --- /dev/null +++ b/parse.go @@ -0,0 +1,826 @@ +package tea + +import ( + "encoding/base64" + "strings" + "unicode/utf8" + + "github.com/charmbracelet/x/ansi" + "github.com/charmbracelet/x/ansi/parser" + "github.com/erikgeiser/coninput" +) + +// 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. +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{Sym: 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.HasAlt() { + 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{Sym: 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] + marker, cmd := csi.Marker(), csi.Command() + switch marker { + case '?': + switch cmd { + case 'y': + switch intermed { + case '$': + // Report Mode (DECRPM) + if paramsLen != 2 { + return i, UnknownMsg(b[:i]) + } + return i, ReportModeEvent{Mode: csi.Param(0), Value: csi.Param(1)} + } + case 'c': + // Primary Device Attributes + return i, parsePrimaryDevAttrs(&csi) + case 'u': + // Kitty keyboard flags + if param := csi.Param(0); param != -1 { + return i, KittyKeyboardMsg(param) + } + case 'R': + // This report may return a third parameter representing the page + // number, but we don't really need it. + if paramsLen >= 2 { + return i, CursorPositionEvent{Row: csi.Param(0), Column: csi.Param(1)} + } + } + return i, UnknownMsg(b[:i]) + case '<': + switch cmd { + case 'm', 'M': + // Handle SGR mouse + if paramsLen != 3 { + return i, UnknownMsg(b[:i]) + } + return i, parseSGRMouseEvent(&csi) + default: + return i, UnknownMsg(b[:i]) + } + case '>': + switch cmd { + case 'm': + // XTerm modifyOtherKeys + if paramsLen != 2 || csi.Param(0) != 4 { + return i, UnknownMsg(b[:i]) + } + + return i, ModifyOtherKeysEvent(csi.Param(1)) + default: + return i, UnknownMsg(b[:i]) + } + case '=': + // We don't support any of these yet + return i, UnknownMsg(b[:i]) + } + + switch cmd := csi.Command(); cmd { + case 'I': + return i, FocusMsg{} + case 'O': + return i, BlurMsg{} + case 'R': + // Cursor position report OR modified F3 + if paramsLen == 0 { + return i, KeyPressMsg{Sym: KeyF3} + } else if paramsLen != 2 { + break + } + + // XXX: We cannot differentiate between cursor position report and + // CSI 1 ; R (which is modified F3) when the cursor is at the + // row 1. In this case, we report a modified F3 event since it's more + // likely to be the case than the cursor being at the first row. + // + // For a non ambiguous cursor position report, use + // [ansi.RequestExtendedCursorPosition] (DECXCPR) instead. + if csi.Param(0) != 1 { + return i, CursorPositionEvent{Row: csi.Param(0), Column: csi.Param(1)} + } + + 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{Sym: KeyUp + KeySym(cmd-'a'), Mod: ModShift} + case 'A', 'B', 'C', 'D': + k = KeyPressMsg{Sym: KeyUp + KeySym(cmd-'A')} + case 'E': + k = KeyPressMsg{Sym: KeyBegin} + case 'F': + k = KeyPressMsg{Sym: KeyEnd} + case 'H': + k = KeyPressMsg{Sym: KeyHome} + case 'P', 'Q', 'R', 'S': + k = KeyPressMsg{Sym: KeyF1 + KeySym(cmd-'P')} + case 'Z': + k = KeyPressMsg{Sym: KeyTab, Mod: ModShift} + } + if paramsLen > 1 && csi.Param(0) == 1 { + // CSI 1 ; A + if paramsLen > 1 { + 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 { + return i, UnknownMsg(b[:i]) + } + return i, ReportModeEvent{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: + fallthrough + case 11, 12, 13, 14, 15: + fallthrough + case 17, 18, 19, 20, 21, 23, 24, 25, 26: + fallthrough + case 28, 29, 31, 32, 33, 34: + var k KeyPressMsg + switch param { + case 1: + if flags&_FlagFind != 0 { + k = KeyPressMsg{Sym: KeyFind} + } else { + k = KeyPressMsg{Sym: KeyHome} + } + case 2: + k = KeyPressMsg{Sym: KeyInsert} + case 3: + k = KeyPressMsg{Sym: KeyDelete} + case 4: + if flags&_FlagSelect != 0 { + k = KeyPressMsg{Sym: KeySelect} + } else { + k = KeyPressMsg{Sym: KeyEnd} + } + case 5: + k = KeyPressMsg{Sym: KeyPgUp} + case 6: + k = KeyPressMsg{Sym: KeyPgDown} + case 7: + k = KeyPressMsg{Sym: KeyHome} + case 8: + k = KeyPressMsg{Sym: KeyEnd} + case 11, 12, 13, 14, 15: + k = KeyPressMsg{Sym: KeyF1 + KeySym(param-11)} + case 17, 18, 19, 20, 21: + k = KeyPressMsg{Sym: KeyF6 + KeySym(param-17)} + case 23, 24, 25, 26: + k = KeyPressMsg{Sym: KeyF11 + KeySym(param-23)} + case 28, 29: + k = KeyPressMsg{Sym: KeyF15 + KeySym(param-28)} + case 31, 32, 33, 34: + k = KeyPressMsg{Sym: KeyF17 + KeySym(param-31)} + } + + // modifiers + if paramsLen > 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{Sym: KeyUp + KeySym(gl-'a'), Mod: ModCtrl} + case 'A', 'B', 'C', 'D': + k = KeyPressMsg{Sym: KeyUp + KeySym(gl-'A')} + case 'E': + k = KeyPressMsg{Sym: KeyBegin} + case 'F': + k = KeyPressMsg{Sym: KeyEnd} + case 'H': + k = KeyPressMsg{Sym: KeyHome} + case 'P', 'Q', 'R', 'S': + k = KeyPressMsg{Sym: KeyF1 + KeySym(gl-'P')} + case 'M': + k = KeyPressMsg{Sym: KeyKpEnter} + case 'X': + k = KeyPressMsg{Sym: KeyKpEqual} + case 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y': + k = KeyPressMsg{Sym: KeyKpMultiply + KeySym(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, ForegroundColorEvent{xParseColor(data)} + case 11: + return i, BackgroundColorEvent{xParseColor(data)} + case 12: + return i, CursorColorEvent{xParseColor(data)} + case 52: + parts := strings.Split(data, ";") + if len(parts) == 0 { + return i, ClipboardEvent("") + } + b64 := parts[len(parts)-1] + bts, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return i, ClipboardEvent("") + } + return i, ClipboardEvent(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) { + r, rw := utf8.DecodeRune(b) + if r <= ansi.US || r == ansi.DEL || r == ansi.SP { + // Control codes get handled by parseControl + return 1, parseControl(byte(r)) + } else if r == utf8.RuneError { + return 1, UnknownMsg(b[0]) + } + return rw, KeyPressMsg{Runes: []rune{r}} +} + +func parseControl(b byte) Msg { + switch b { + case ansi.NUL: + if flags&_FlagCtrlAt != 0 { + return KeyPressMsg{Runes: []rune{'@'}, Mod: ModCtrl} + } + return KeyPressMsg{Runes: []rune{' '}, Sym: 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{Sym: KeyTab} + case ansi.CR: + if flags&_FlagCtrlM != 0 { + return KeyPressMsg{Runes: []rune{'m'}, Mod: ModCtrl} + } + return KeyPressMsg{Sym: KeyEnter} + case ansi.ESC: + if flags&_FlagCtrlOpenBracket != 0 { + return KeyPressMsg{Runes: []rune{'['}, Mod: ModCtrl} + } + return KeyPressMsg{Sym: KeyEscape} + case ansi.DEL: + if flags&_FlagBackspace != 0 { + return KeyPressMsg{Sym: KeyDelete} + } + return KeyPressMsg{Sym: KeyBackspace} + case ansi.SP: + return KeyPressMsg{Sym: 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..41b608dcaa --- /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{Sym: KeyTab, Mod: ModShift | ModAlt}, + KeyPressMsg{Runes: []rune{'t'}}, + KeyPressMsg{Runes: []rune{'e'}}, + KeyPressMsg{Runes: []rune{'s'}}, + KeyPressMsg{Runes: []rune{'t'}}, + KeyPressMsg{Runes: []rune{' '}, Sym: KeySpace, Mod: ModCtrl}, + ForegroundColorEvent{color.RGBA{R: 0x12, G: 0x12, B: 0x12, A: 0xff}}, + KeyPressMsg{Sym: KeyEscape, Mod: ModShift}, + ReportModeEvent{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..efb53d6a5f --- /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 a terminal enters +// bracketed-paste mode. +type PasteStartMsg struct{} + +// PasteEvent is an message that is emitted when a terminal receives pasted +// text. +type PasteEndMsg struct{} diff --git a/table.go b/table.go new file mode 100644 index 0000000000..4b350643b2 --- /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{' '}, Sym: KeySpace, Mod: ModCtrl} // ctrl+@ or ctrl+space + if flags&_FlagCtrlAt != 0 { + nul = Key{Runes: []rune{'@'}, Mod: ModCtrl} + } + + tab := Key{Sym: KeyTab} // ctrl+i or tab + if flags&_FlagCtrlI != 0 { + tab = Key{Runes: []rune{'i'}, Mod: ModCtrl} + } + + enter := Key{Sym: KeyEnter} // ctrl+m or enter + if flags&_FlagCtrlM != 0 { + enter = Key{Runes: []rune{'m'}, Mod: ModCtrl} + } + + esc := Key{Sym: KeyEscape} // ctrl+[ or escape + if flags&_FlagCtrlOpenBracket != 0 { + esc = Key{Runes: []rune{'['}, Mod: ModCtrl} // ctrl+[ or escape + } + + del := Key{Sym: KeyBackspace} + if flags&_FlagBackspace != 0 { + del.Sym = KeyDelete + } + + find := Key{Sym: KeyHome} + if flags&_FlagFind != 0 { + find.Sym = KeyFind + } + + sel := Key{Sym: KeyEnd} + if flags&_FlagSelect != 0 { + sel.Sym = 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)): {Sym: KeySpace, Runes: []rune{' '}}, + string(byte(ansi.DEL)): del, + + // Special keys + + "\x1b[Z": {Sym: KeyTab, Mod: ModShift}, + + "\x1b[1~": find, + "\x1b[2~": {Sym: KeyInsert}, + "\x1b[3~": {Sym: KeyDelete}, + "\x1b[4~": sel, + "\x1b[5~": {Sym: KeyPgUp}, + "\x1b[6~": {Sym: KeyPgDown}, + "\x1b[7~": {Sym: KeyHome}, + "\x1b[8~": {Sym: KeyEnd}, + + // Normal mode + "\x1b[A": {Sym: KeyUp}, + "\x1b[B": {Sym: KeyDown}, + "\x1b[C": {Sym: KeyRight}, + "\x1b[D": {Sym: KeyLeft}, + "\x1b[E": {Sym: KeyBegin}, + "\x1b[F": {Sym: KeyEnd}, + "\x1b[H": {Sym: KeyHome}, + "\x1b[P": {Sym: KeyF1}, + "\x1b[Q": {Sym: KeyF2}, + "\x1b[R": {Sym: KeyF3}, + "\x1b[S": {Sym: KeyF4}, + + // Application Cursor Key Mode (DECCKM) + "\x1bOA": {Sym: KeyUp}, + "\x1bOB": {Sym: KeyDown}, + "\x1bOC": {Sym: KeyRight}, + "\x1bOD": {Sym: KeyLeft}, + "\x1bOE": {Sym: KeyBegin}, + "\x1bOF": {Sym: KeyEnd}, + "\x1bOH": {Sym: KeyHome}, + "\x1bOP": {Sym: KeyF1}, + "\x1bOQ": {Sym: KeyF2}, + "\x1bOR": {Sym: KeyF3}, + "\x1bOS": {Sym: KeyF4}, + + // Keypad Application Mode (DECKPAM) + + "\x1bOM": {Sym: KeyKpEnter}, + "\x1bOX": {Sym: KeyKpEqual}, + "\x1bOj": {Sym: KeyKpMultiply}, + "\x1bOk": {Sym: KeyKpPlus}, + "\x1bOl": {Sym: KeyKpComma}, + "\x1bOm": {Sym: KeyKpMinus}, + "\x1bOn": {Sym: KeyKpDecimal}, + "\x1bOo": {Sym: KeyKpDivide}, + "\x1bOp": {Sym: KeyKp0}, + "\x1bOq": {Sym: KeyKp1}, + "\x1bOr": {Sym: KeyKp2}, + "\x1bOs": {Sym: KeyKp3}, + "\x1bOt": {Sym: KeyKp4}, + "\x1bOu": {Sym: KeyKp5}, + "\x1bOv": {Sym: KeyKp6}, + "\x1bOw": {Sym: KeyKp7}, + "\x1bOx": {Sym: KeyKp8}, + "\x1bOy": {Sym: KeyKp9}, + + // Function keys + + "\x1b[11~": {Sym: KeyF1}, + "\x1b[12~": {Sym: KeyF2}, + "\x1b[13~": {Sym: KeyF3}, + "\x1b[14~": {Sym: KeyF4}, + "\x1b[15~": {Sym: KeyF5}, + "\x1b[17~": {Sym: KeyF6}, + "\x1b[18~": {Sym: KeyF7}, + "\x1b[19~": {Sym: KeyF8}, + "\x1b[20~": {Sym: KeyF9}, + "\x1b[21~": {Sym: KeyF10}, + "\x1b[23~": {Sym: KeyF11}, + "\x1b[24~": {Sym: KeyF12}, + "\x1b[25~": {Sym: KeyF13}, + "\x1b[26~": {Sym: KeyF14}, + "\x1b[28~": {Sym: KeyF15}, + "\x1b[29~": {Sym: KeyF16}, + "\x1b[31~": {Sym: KeyF17}, + "\x1b[32~": {Sym: KeyF18}, + "\x1b[33~": {Sym: KeyF19}, + "\x1b[34~": {Sym: KeyF20}, + } + + // CSI ~ sequence keys + csiTildeKeys := map[string]Key{ + "1": find, "2": {Sym: KeyInsert}, + "3": {Sym: KeyDelete}, "4": sel, + "5": {Sym: KeyPgUp}, "6": {Sym: KeyPgDown}, + "7": {Sym: KeyHome}, "8": {Sym: KeyEnd}, + // There are no 9 and 10 keys + "11": {Sym: KeyF1}, "12": {Sym: KeyF2}, + "13": {Sym: KeyF3}, "14": {Sym: KeyF4}, + "15": {Sym: KeyF5}, "17": {Sym: KeyF6}, + "18": {Sym: KeyF7}, "19": {Sym: KeyF8}, + "20": {Sym: KeyF9}, "21": {Sym: KeyF10}, + "23": {Sym: KeyF11}, "24": {Sym: KeyF12}, + "25": {Sym: KeyF13}, "26": {Sym: KeyF14}, + "28": {Sym: KeyF15}, "29": {Sym: KeyF16}, + "31": {Sym: KeyF17}, "32": {Sym: KeyF18}, + "33": {Sym: KeyF19}, "34": {Sym: KeyF20}, + } + + // URxvt keys + // See https://manpages.ubuntu.com/manpages/trusty/man7/urxvt.7.html#key%20codes + table["\x1b[a"] = Key{Sym: KeyUp, Mod: ModShift} + table["\x1b[b"] = Key{Sym: KeyDown, Mod: ModShift} + table["\x1b[c"] = Key{Sym: KeyRight, Mod: ModShift} + table["\x1b[d"] = Key{Sym: KeyLeft, Mod: ModShift} + table["\x1bOa"] = Key{Sym: KeyUp, Mod: ModCtrl} + table["\x1bOb"] = Key{Sym: KeyDown, Mod: ModCtrl} + table["\x1bOc"] = Key{Sym: KeyRight, Mod: ModCtrl} + table["\x1bOd"] = Key{Sym: 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{Sym: KeyF11, Mod: ModShift} + table["\x1b[24$"] = Key{Sym: KeyF12, Mod: ModShift} + table["\x1b[25$"] = Key{Sym: KeyF13, Mod: ModShift} + table["\x1b[26$"] = Key{Sym: KeyF14, Mod: ModShift} + table["\x1b[28$"] = Key{Sym: KeyF15, Mod: ModShift} + table["\x1b[29$"] = Key{Sym: KeyF16, Mod: ModShift} + table["\x1b[31$"] = Key{Sym: KeyF17, Mod: ModShift} + table["\x1b[32$"] = Key{Sym: KeyF18, Mod: ModShift} + table["\x1b[33$"] = Key{Sym: KeyF19, Mod: ModShift} + table["\x1b[34$"] = Key{Sym: KeyF20, Mod: ModShift} + table["\x1b[11^"] = Key{Sym: KeyF1, Mod: ModCtrl} + table["\x1b[12^"] = Key{Sym: KeyF2, Mod: ModCtrl} + table["\x1b[13^"] = Key{Sym: KeyF3, Mod: ModCtrl} + table["\x1b[14^"] = Key{Sym: KeyF4, Mod: ModCtrl} + table["\x1b[15^"] = Key{Sym: KeyF5, Mod: ModCtrl} + table["\x1b[17^"] = Key{Sym: KeyF6, Mod: ModCtrl} + table["\x1b[18^"] = Key{Sym: KeyF7, Mod: ModCtrl} + table["\x1b[19^"] = Key{Sym: KeyF8, Mod: ModCtrl} + table["\x1b[20^"] = Key{Sym: KeyF9, Mod: ModCtrl} + table["\x1b[21^"] = Key{Sym: KeyF10, Mod: ModCtrl} + table["\x1b[23^"] = Key{Sym: KeyF11, Mod: ModCtrl} + table["\x1b[24^"] = Key{Sym: KeyF12, Mod: ModCtrl} + table["\x1b[25^"] = Key{Sym: KeyF13, Mod: ModCtrl} + table["\x1b[26^"] = Key{Sym: KeyF14, Mod: ModCtrl} + table["\x1b[28^"] = Key{Sym: KeyF15, Mod: ModCtrl} + table["\x1b[29^"] = Key{Sym: KeyF16, Mod: ModCtrl} + table["\x1b[31^"] = Key{Sym: KeyF17, Mod: ModCtrl} + table["\x1b[32^"] = Key{Sym: KeyF18, Mod: ModCtrl} + table["\x1b[33^"] = Key{Sym: KeyF19, Mod: ModCtrl} + table["\x1b[34^"] = Key{Sym: KeyF20, Mod: ModCtrl} + table["\x1b[23@"] = Key{Sym: KeyF11, Mod: ModShift | ModCtrl} + table["\x1b[24@"] = Key{Sym: KeyF12, Mod: ModShift | ModCtrl} + table["\x1b[25@"] = Key{Sym: KeyF13, Mod: ModShift | ModCtrl} + table["\x1b[26@"] = Key{Sym: KeyF14, Mod: ModShift | ModCtrl} + table["\x1b[28@"] = Key{Sym: KeyF15, Mod: ModShift | ModCtrl} + table["\x1b[29@"] = Key{Sym: KeyF16, Mod: ModShift | ModCtrl} + table["\x1b[31@"] = Key{Sym: KeyF17, Mod: ModShift | ModCtrl} + table["\x1b[32@"] = Key{Sym: KeyF18, Mod: ModShift | ModCtrl} + table["\x1b[33@"] = Key{Sym: KeyF19, Mod: ModShift | ModCtrl} + table["\x1b[34@"] = Key{Sym: 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": {Sym: KeyKpEnter}, "X": {Sym: KeyKpEqual}, + "j": {Sym: KeyKpMultiply}, "k": {Sym: KeyKpPlus}, + "l": {Sym: KeyKpComma}, "m": {Sym: KeyKpMinus}, + "n": {Sym: KeyKpDecimal}, "o": {Sym: KeyKpDivide}, + "p": {Sym: KeyKp0}, "q": {Sym: KeyKp1}, + "r": {Sym: KeyKp2}, "s": {Sym: KeyKp3}, + "t": {Sym: KeyKp4}, "u": {Sym: KeyKp5}, + "v": {Sym: KeyKp6}, "w": {Sym: KeyKp7}, + "x": {Sym: KeyKp8}, "y": {Sym: KeyKp9}, + } + + // XTerm keys + csiFuncKeys := map[string]Key{ + "A": {Sym: KeyUp}, "B": {Sym: KeyDown}, + "C": {Sym: KeyRight}, "D": {Sym: KeyLeft}, + "E": {Sym: KeyBegin}, "F": {Sym: KeyEnd}, + "H": {Sym: KeyHome}, "P": {Sym: KeyF1}, + "Q": {Sym: KeyF2}, "R": {Sym: KeyF3}, + "S": {Sym: KeyF4}, + } + + // CSI 27 ; ; ~ keys defined in XTerm modifyOtherKeys + modifyOtherKeys := map[int]Key{ + ansi.BS: {Sym: KeyBackspace}, + ansi.HT: {Sym: KeyTab}, + ansi.CR: {Sym: KeyEnter}, + ansi.ESC: {Sym: KeyEscape}, + ansi.DEL: {Sym: 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..c0105b320b 100644 --- a/tea.go +++ b/tea.go @@ -22,7 +22,6 @@ import ( "syscall" "github.com/charmbracelet/x/term" - "github.com/muesli/cancelreader" "golang.org/x/sync/errgroup" ) @@ -159,7 +158,7 @@ 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? @@ -543,11 +542,18 @@ func (p *Program) Run() (Model, error) { p.renderer.enableMouseSGRMode() } + // Init the input reader and initial model. + model := p.initialModel + if p.input != nil { + if err := p.initInputReader(); err != nil { + return model, err + } + } + // 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 +571,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 +591,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 +683,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() @@ -708,7 +707,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 { 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..eb9cb3c71d --- /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": {Sym: KeyUp}, + "kUP": {Sym: KeyUp, Mod: ModShift}, + "kUP3": {Sym: KeyUp, Mod: ModAlt}, + "kUP4": {Sym: KeyUp, Mod: ModShift | ModAlt}, + "kUP5": {Sym: KeyUp, Mod: ModCtrl}, + "kUP6": {Sym: KeyUp, Mod: ModShift | ModCtrl}, + "kUP7": {Sym: KeyUp, Mod: ModAlt | ModCtrl}, + "kUP8": {Sym: KeyUp, Mod: ModShift | ModAlt | ModCtrl}, + "kcud1": {Sym: KeyDown}, + "kDN": {Sym: KeyDown, Mod: ModShift}, + "kDN3": {Sym: KeyDown, Mod: ModAlt}, + "kDN4": {Sym: KeyDown, Mod: ModShift | ModAlt}, + "kDN5": {Sym: KeyDown, Mod: ModCtrl}, + "kDN7": {Sym: KeyDown, Mod: ModAlt | ModCtrl}, + "kDN6": {Sym: KeyDown, Mod: ModShift | ModCtrl}, + "kDN8": {Sym: KeyDown, Mod: ModShift | ModAlt | ModCtrl}, + "kcub1": {Sym: KeyLeft}, + "kLFT": {Sym: KeyLeft, Mod: ModShift}, + "kLFT3": {Sym: KeyLeft, Mod: ModAlt}, + "kLFT4": {Sym: KeyLeft, Mod: ModShift | ModAlt}, + "kLFT5": {Sym: KeyLeft, Mod: ModCtrl}, + "kLFT6": {Sym: KeyLeft, Mod: ModShift | ModCtrl}, + "kLFT7": {Sym: KeyLeft, Mod: ModAlt | ModCtrl}, + "kLFT8": {Sym: KeyLeft, Mod: ModShift | ModAlt | ModCtrl}, + "kcuf1": {Sym: KeyRight}, + "kRIT": {Sym: KeyRight, Mod: ModShift}, + "kRIT3": {Sym: KeyRight, Mod: ModAlt}, + "kRIT4": {Sym: KeyRight, Mod: ModShift | ModAlt}, + "kRIT5": {Sym: KeyRight, Mod: ModCtrl}, + "kRIT6": {Sym: KeyRight, Mod: ModShift | ModCtrl}, + "kRIT7": {Sym: KeyRight, Mod: ModAlt | ModCtrl}, + "kRIT8": {Sym: KeyRight, Mod: ModShift | ModAlt | ModCtrl}, + "kich1": {Sym: KeyInsert}, + "kIC": {Sym: KeyInsert, Mod: ModShift}, + "kIC3": {Sym: KeyInsert, Mod: ModAlt}, + "kIC4": {Sym: KeyInsert, Mod: ModShift | ModAlt}, + "kIC5": {Sym: KeyInsert, Mod: ModCtrl}, + "kIC6": {Sym: KeyInsert, Mod: ModShift | ModCtrl}, + "kIC7": {Sym: KeyInsert, Mod: ModAlt | ModCtrl}, + "kIC8": {Sym: KeyInsert, Mod: ModShift | ModAlt | ModCtrl}, + "kdch1": {Sym: KeyDelete}, + "kDC": {Sym: KeyDelete, Mod: ModShift}, + "kDC3": {Sym: KeyDelete, Mod: ModAlt}, + "kDC4": {Sym: KeyDelete, Mod: ModShift | ModAlt}, + "kDC5": {Sym: KeyDelete, Mod: ModCtrl}, + "kDC6": {Sym: KeyDelete, Mod: ModShift | ModCtrl}, + "kDC7": {Sym: KeyDelete, Mod: ModAlt | ModCtrl}, + "kDC8": {Sym: KeyDelete, Mod: ModShift | ModAlt | ModCtrl}, + "khome": {Sym: KeyHome}, + "kHOM": {Sym: KeyHome, Mod: ModShift}, + "kHOM3": {Sym: KeyHome, Mod: ModAlt}, + "kHOM4": {Sym: KeyHome, Mod: ModShift | ModAlt}, + "kHOM5": {Sym: KeyHome, Mod: ModCtrl}, + "kHOM6": {Sym: KeyHome, Mod: ModShift | ModCtrl}, + "kHOM7": {Sym: KeyHome, Mod: ModAlt | ModCtrl}, + "kHOM8": {Sym: KeyHome, Mod: ModShift | ModAlt | ModCtrl}, + "kend": {Sym: KeyEnd}, + "kEND": {Sym: KeyEnd, Mod: ModShift}, + "kEND3": {Sym: KeyEnd, Mod: ModAlt}, + "kEND4": {Sym: KeyEnd, Mod: ModShift | ModAlt}, + "kEND5": {Sym: KeyEnd, Mod: ModCtrl}, + "kEND6": {Sym: KeyEnd, Mod: ModShift | ModCtrl}, + "kEND7": {Sym: KeyEnd, Mod: ModAlt | ModCtrl}, + "kEND8": {Sym: KeyEnd, Mod: ModShift | ModAlt | ModCtrl}, + "kpp": {Sym: KeyPgUp}, + "kprv": {Sym: KeyPgUp}, + "kPRV": {Sym: KeyPgUp, Mod: ModShift}, + "kPRV3": {Sym: KeyPgUp, Mod: ModAlt}, + "kPRV4": {Sym: KeyPgUp, Mod: ModShift | ModAlt}, + "kPRV5": {Sym: KeyPgUp, Mod: ModCtrl}, + "kPRV6": {Sym: KeyPgUp, Mod: ModShift | ModCtrl}, + "kPRV7": {Sym: KeyPgUp, Mod: ModAlt | ModCtrl}, + "kPRV8": {Sym: KeyPgUp, Mod: ModShift | ModAlt | ModCtrl}, + "knp": {Sym: KeyPgDown}, + "knxt": {Sym: KeyPgDown}, + "kNXT": {Sym: KeyPgDown, Mod: ModShift}, + "kNXT3": {Sym: KeyPgDown, Mod: ModAlt}, + "kNXT4": {Sym: KeyPgDown, Mod: ModShift | ModAlt}, + "kNXT5": {Sym: KeyPgDown, Mod: ModCtrl}, + "kNXT6": {Sym: KeyPgDown, Mod: ModShift | ModCtrl}, + "kNXT7": {Sym: KeyPgDown, Mod: ModAlt | ModCtrl}, + "kNXT8": {Sym: KeyPgDown, Mod: ModShift | ModAlt | ModCtrl}, + + "kbs": {Sym: KeyBackspace}, + "kcbt": {Sym: 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": {Sym: KeyF1}, + "kf2": {Sym: KeyF2}, + "kf3": {Sym: KeyF3}, + "kf4": {Sym: KeyF4}, + "kf5": {Sym: KeyF5}, + "kf6": {Sym: KeyF6}, + "kf7": {Sym: KeyF7}, + "kf8": {Sym: KeyF8}, + "kf9": {Sym: KeyF9}, + "kf10": {Sym: KeyF10}, + "kf11": {Sym: KeyF11}, + "kf12": {Sym: KeyF12}, + "kf13": {Sym: KeyF1, Mod: ModShift}, + "kf14": {Sym: KeyF2, Mod: ModShift}, + "kf15": {Sym: KeyF3, Mod: ModShift}, + "kf16": {Sym: KeyF4, Mod: ModShift}, + "kf17": {Sym: KeyF5, Mod: ModShift}, + "kf18": {Sym: KeyF6, Mod: ModShift}, + "kf19": {Sym: KeyF7, Mod: ModShift}, + "kf20": {Sym: KeyF8, Mod: ModShift}, + "kf21": {Sym: KeyF9, Mod: ModShift}, + "kf22": {Sym: KeyF10, Mod: ModShift}, + "kf23": {Sym: KeyF11, Mod: ModShift}, + "kf24": {Sym: KeyF12, Mod: ModShift}, + "kf25": {Sym: KeyF1, Mod: ModCtrl}, + "kf26": {Sym: KeyF2, Mod: ModCtrl}, + "kf27": {Sym: KeyF3, Mod: ModCtrl}, + "kf28": {Sym: KeyF4, Mod: ModCtrl}, + "kf29": {Sym: KeyF5, Mod: ModCtrl}, + "kf30": {Sym: KeyF6, Mod: ModCtrl}, + "kf31": {Sym: KeyF7, Mod: ModCtrl}, + "kf32": {Sym: KeyF8, Mod: ModCtrl}, + "kf33": {Sym: KeyF9, Mod: ModCtrl}, + "kf34": {Sym: KeyF10, Mod: ModCtrl}, + "kf35": {Sym: KeyF11, Mod: ModCtrl}, + "kf36": {Sym: KeyF12, Mod: ModCtrl}, + "kf37": {Sym: KeyF1, Mod: ModShift | ModCtrl}, + "kf38": {Sym: KeyF2, Mod: ModShift | ModCtrl}, + "kf39": {Sym: KeyF3, Mod: ModShift | ModCtrl}, + "kf40": {Sym: KeyF4, Mod: ModShift | ModCtrl}, + "kf41": {Sym: KeyF5, Mod: ModShift | ModCtrl}, + "kf42": {Sym: KeyF6, Mod: ModShift | ModCtrl}, + "kf43": {Sym: KeyF7, Mod: ModShift | ModCtrl}, + "kf44": {Sym: KeyF8, Mod: ModShift | ModCtrl}, + "kf45": {Sym: KeyF9, Mod: ModShift | ModCtrl}, + "kf46": {Sym: KeyF10, Mod: ModShift | ModCtrl}, + "kf47": {Sym: KeyF11, Mod: ModShift | ModCtrl}, + "kf48": {Sym: KeyF12, Mod: ModShift | ModCtrl}, + "kf49": {Sym: KeyF1, Mod: ModAlt}, + "kf50": {Sym: KeyF2, Mod: ModAlt}, + "kf51": {Sym: KeyF3, Mod: ModAlt}, + "kf52": {Sym: KeyF4, Mod: ModAlt}, + "kf53": {Sym: KeyF5, Mod: ModAlt}, + "kf54": {Sym: KeyF6, Mod: ModAlt}, + "kf55": {Sym: KeyF7, Mod: ModAlt}, + "kf56": {Sym: KeyF8, Mod: ModAlt}, + "kf57": {Sym: KeyF9, Mod: ModAlt}, + "kf58": {Sym: KeyF10, Mod: ModAlt}, + "kf59": {Sym: KeyF11, Mod: ModAlt}, + "kf60": {Sym: KeyF12, Mod: ModAlt}, + "kf61": {Sym: KeyF1, Mod: ModShift | ModAlt}, + "kf62": {Sym: KeyF2, Mod: ModShift | ModAlt}, + "kf63": {Sym: 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{Sym: KeyF13} + keys["kf14"] = Key{Sym: KeyF14} + keys["kf15"] = Key{Sym: KeyF15} + keys["kf16"] = Key{Sym: KeyF16} + keys["kf17"] = Key{Sym: KeyF17} + keys["kf18"] = Key{Sym: KeyF18} + keys["kf19"] = Key{Sym: KeyF19} + keys["kf20"] = Key{Sym: KeyF20} + keys["kf21"] = Key{Sym: KeyF21} + keys["kf22"] = Key{Sym: KeyF22} + keys["kf23"] = Key{Sym: KeyF23} + keys["kf24"] = Key{Sym: KeyF24} + keys["kf25"] = Key{Sym: KeyF25} + keys["kf26"] = Key{Sym: KeyF26} + keys["kf27"] = Key{Sym: KeyF27} + keys["kf28"] = Key{Sym: KeyF28} + keys["kf29"] = Key{Sym: KeyF29} + keys["kf30"] = Key{Sym: KeyF30} + keys["kf31"] = Key{Sym: KeyF31} + keys["kf32"] = Key{Sym: KeyF32} + keys["kf33"] = Key{Sym: KeyF33} + keys["kf34"] = Key{Sym: KeyF34} + keys["kf35"] = Key{Sym: KeyF35} + keys["kf36"] = Key{Sym: KeyF36} + keys["kf37"] = Key{Sym: KeyF37} + keys["kf38"] = Key{Sym: KeyF38} + keys["kf39"] = Key{Sym: KeyF39} + keys["kf40"] = Key{Sym: KeyF40} + keys["kf41"] = Key{Sym: KeyF41} + keys["kf42"] = Key{Sym: KeyF42} + keys["kf43"] = Key{Sym: KeyF43} + keys["kf44"] = Key{Sym: KeyF44} + keys["kf45"] = Key{Sym: KeyF45} + keys["kf46"] = Key{Sym: KeyF46} + keys["kf47"] = Key{Sym: KeyF47} + keys["kf48"] = Key{Sym: KeyF48} + keys["kf49"] = Key{Sym: KeyF49} + keys["kf50"] = Key{Sym: KeyF50} + keys["kf51"] = Key{Sym: KeyF51} + keys["kf52"] = Key{Sym: KeyF52} + keys["kf53"] = Key{Sym: KeyF53} + keys["kf54"] = Key{Sym: KeyF54} + keys["kf55"] = Key{Sym: KeyF55} + keys["kf56"] = Key{Sym: KeyF56} + keys["kf57"] = Key{Sym: KeyF57} + keys["kf58"] = Key{Sym: KeyF58} + keys["kf59"] = Key{Sym: KeyF59} + keys["kf60"] = Key{Sym: KeyF60} + keys["kf61"] = Key{Sym: KeyF61} + keys["kf62"] = Key{Sym: KeyF62} + keys["kf63"] = Key{Sym: KeyF63} + } + + return keys +} diff --git a/tty.go b/tty.go index ed469ad43c..7d4bdf332c 100644 --- a/tty.go +++ b/tty.go @@ -1,9 +1,11 @@ package tea import ( + "context" "errors" "fmt" "io" + "strings" "time" "github.com/charmbracelet/x/term" @@ -65,24 +67,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.HasAlt(), + Runes: e.Runes, + Type: e.Sym, + } + + // Backwards compatibility for ctrl- and shift- keys + switch { + case e.Mod.HasCtrl() && e.Mod.HasShift(): + switch e.Sym { + case KeyUp, KeyDown, KeyRight, KeyLeft: + k.Runes = nil + k.Type = KeyCtrlShiftUp - e.Sym + KeyUp + case KeyHome, KeyEnd: + k.Runes = nil + k.Type = KeyCtrlShiftHome - e.Sym + KeyHome + } + case e.Mod.HasCtrl(): + switch e.Sym { + case KeyNone: // 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.Sym + KeyPgUp + case KeyUp, KeyDown, KeyRight, KeyLeft: + k.Runes = nil + k.Type = KeyCtrlUp - e.Sym + KeyUp + } + case e.Mod.HasShift(): + switch e.Sym { + case KeyTab: + k.Runes = nil + k.Type = KeyShiftTab + case KeyUp, KeyDown, KeyRight, KeyLeft: + k.Runes = nil + k.Type = KeyShiftUp - e.Sym + KeyUp + k.Runes = nil + case KeyHome, KeyEnd: + k.Runes = nil + k.Type = KeyShiftHome - e.Sym + KeyHome + } + } + + switch k.Type { + case KeyNone: // 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/win32input.go b/win32input.go new file mode 100644 index 0000000000..5d8b9902c7 --- /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{Sym: KeyLeftAlt} + } else if cks.Contains(coninput.RIGHT_ALT_PRESSED) { + key = Key{Sym: KeyRightAlt} + } else if !keyDown { + return nil + } + case coninput.VK_CONTROL: + if cks.Contains(coninput.LEFT_CTRL_PRESSED) { + key = Key{Sym: KeyLeftCtrl} + } else if cks.Contains(coninput.RIGHT_CTRL_PRESSED) { + key = Key{Sym: KeyRightCtrl} + } else if !keyDown { + return nil + } + case coninput.VK_CAPITAL: + key = Key{Sym: 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: {Sym: KeyEnter}, + coninput.VK_BACK: {Sym: KeyBackspace}, + coninput.VK_TAB: {Sym: KeyTab}, + coninput.VK_ESCAPE: {Sym: KeyEscape}, + coninput.VK_SPACE: {Sym: KeySpace, Runes: []rune{' '}}, + coninput.VK_UP: {Sym: KeyUp}, + coninput.VK_DOWN: {Sym: KeyDown}, + coninput.VK_RIGHT: {Sym: KeyRight}, + coninput.VK_LEFT: {Sym: KeyLeft}, + coninput.VK_HOME: {Sym: KeyHome}, + coninput.VK_END: {Sym: KeyEnd}, + coninput.VK_PRIOR: {Sym: KeyPgUp}, + coninput.VK_NEXT: {Sym: KeyPgDown}, + coninput.VK_DELETE: {Sym: KeyDelete}, + coninput.VK_SELECT: {Sym: KeySelect}, + coninput.VK_SNAPSHOT: {Sym: KeyPrintScreen}, + coninput.VK_INSERT: {Sym: KeyInsert}, + coninput.VK_LWIN: {Sym: KeyLeftSuper}, + coninput.VK_RWIN: {Sym: KeyRightSuper}, + coninput.VK_APPS: {Sym: KeyMenu}, + coninput.VK_NUMPAD0: {Sym: KeyKp0}, + coninput.VK_NUMPAD1: {Sym: KeyKp1}, + coninput.VK_NUMPAD2: {Sym: KeyKp2}, + coninput.VK_NUMPAD3: {Sym: KeyKp3}, + coninput.VK_NUMPAD4: {Sym: KeyKp4}, + coninput.VK_NUMPAD5: {Sym: KeyKp5}, + coninput.VK_NUMPAD6: {Sym: KeyKp6}, + coninput.VK_NUMPAD7: {Sym: KeyKp7}, + coninput.VK_NUMPAD8: {Sym: KeyKp8}, + coninput.VK_NUMPAD9: {Sym: KeyKp9}, + coninput.VK_MULTIPLY: {Sym: KeyKpMultiply}, + coninput.VK_ADD: {Sym: KeyKpPlus}, + coninput.VK_SEPARATOR: {Sym: KeyKpComma}, + coninput.VK_SUBTRACT: {Sym: KeyKpMinus}, + coninput.VK_DECIMAL: {Sym: KeyKpDecimal}, + coninput.VK_DIVIDE: {Sym: KeyKpDivide}, + coninput.VK_F1: {Sym: KeyF1}, + coninput.VK_F2: {Sym: KeyF2}, + coninput.VK_F3: {Sym: KeyF3}, + coninput.VK_F4: {Sym: KeyF4}, + coninput.VK_F5: {Sym: KeyF5}, + coninput.VK_F6: {Sym: KeyF6}, + coninput.VK_F7: {Sym: KeyF7}, + coninput.VK_F8: {Sym: KeyF8}, + coninput.VK_F9: {Sym: KeyF9}, + coninput.VK_F10: {Sym: KeyF10}, + coninput.VK_F11: {Sym: KeyF11}, + coninput.VK_F12: {Sym: KeyF12}, + coninput.VK_F13: {Sym: KeyF13}, + coninput.VK_F14: {Sym: KeyF14}, + coninput.VK_F15: {Sym: KeyF15}, + coninput.VK_F16: {Sym: KeyF16}, + coninput.VK_F17: {Sym: KeyF17}, + coninput.VK_F18: {Sym: KeyF18}, + coninput.VK_F19: {Sym: KeyF19}, + coninput.VK_F20: {Sym: KeyF20}, + coninput.VK_F21: {Sym: KeyF21}, + coninput.VK_F22: {Sym: KeyF22}, + coninput.VK_F23: {Sym: KeyF23}, + coninput.VK_F24: {Sym: KeyF24}, + coninput.VK_NUMLOCK: {Sym: KeyNumLock}, + coninput.VK_SCROLL: {Sym: KeyScrollLock}, + coninput.VK_LSHIFT: {Sym: KeyLeftShift}, + coninput.VK_RSHIFT: {Sym: KeyRightShift}, + coninput.VK_LCONTROL: {Sym: KeyLeftCtrl}, + coninput.VK_RCONTROL: {Sym: KeyRightCtrl}, + coninput.VK_LMENU: {Sym: KeyLeftAlt}, + coninput.VK_RMENU: {Sym: 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..ca2e37104e --- /dev/null +++ b/xterm.go @@ -0,0 +1,40 @@ +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, Sym: KeyBackspace} + case ansi.HT: + return KeyPressMsg{Mod: mod, Sym: KeyTab} + case ansi.CR: + return KeyPressMsg{Mod: mod, Sym: KeyEnter} + case ansi.ESC: + return KeyPressMsg{Mod: mod, Sym: KeyEscape} + case ansi.DEL: + return KeyPressMsg{Mod: mod, Sym: KeyBackspace} + } + + // CSI 27 ; ; ~ keys defined in XTerm modifyOtherKeys + return KeyPressMsg{ + Mod: mod, + Runes: []rune{r}, + } +} + +// ModifyOtherKeysEvent represents a modifyOtherKeys event. +// +// 0: disable +// 1: enable mode 1 +// 2: enable mode 2 +// +// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_ +// See: https://invisible-island.net/xterm/manpage/xterm.html#VT100-Widget-Resources:modifyOtherKeys +type ModifyOtherKeysEvent uint8