-
Notifications
You must be signed in to change notification settings - Fork 805
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
refactor: reimplement the event loop with a sequence parser (#1080)
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. This PR includes the following changes: - Support clipboard OSC52 read messages (`OSC 52 ?`) - Support terminal foreground/background/cursor color report messages (OSC10, OSC11, OSC12) - Support terminal focus events (mode 1004) - Deprecate the old `KeyMsg` API in favor of `KeyPressMsg` and `KeyReleaseMsg` - `KeyType` const values are different now. Programs that use int value comparison **will** break. E.g. `key.Type == 13` where `13` is the control code for `CR` that corresponds to the <kbd>enter</kbd> key. (BREAKING CHANGE!) - Bubble Tea will send two messages for key presses, the first of type `KeyMsg` and the second of type `KeyPressMsg`. This is to keep backwards compatibility and _not_ break the API - `tea.Key` contains breaking changes (BREAKING CHANGE!) - Deprecate `MouseMsg` in favor of `MouseClickMsg`, `MouseReleaseMsg`, `MouseWheelMsg`, and `MouseMotionMsg` - Bubble Tea will send two messages for mouse clicks, releases, wheel, and motion. The first message will be a `MouseMsg` type. And the second will have the new corresponding type. This is to keep backwards compatibility and _not_ break the API - `tea.Mouse` contains breaking changes (BREAKING CHANGE!) - Support reading Kitty keyboard reports (reading the results of sending `CSI ? u` to the terminal) - Support reading Kitty keyboard and fixterms keys `CSI u` - Support reading terminal mode reports (DECRPM) - Bracketed-paste messages now have their own message type `PasteMsg`. Use `PasteStartMsg` and `PasteEndMsg` to listen to the start/end of the paste message. - Bubble Tea will send two messages for bracketed-paste, the first is of type `KeyMsg` and the second is of type `PasteMsg`. This is to keep backwards compatibility and _not_ break the API - Support more obscure key input sequences found in URxvt and others - Support reading termcap/terminfo capabilities through `XTGETTCAP`. These capabilities will get reported as `TermcapMsg` - Support reading terminfo databases for key input sequences (disabled for now) - Support reading [Win32 Input Mode keys](https://github.com/microsoft/terminal/blob/main/doc/specs/%234999%20-%20Improved%20keyboard%20handling%20in%20Conpty.md#win32-input-mode-sequences) - Support reading Xterm `modifyOtherKeys` keys TODO: - [x] Parse multi-rune graphemes as one `KeyPressMsg` storing it in `key.Runes` - [x] Kitty keyboard startup settings and options #1083 - [x] Xterm modify other keys startup settings and options #1084 - [x] Focus events startup settings and options #1081 - [x] Fg/bg/cursor terminal color startup settings and options #1085 Supersedes: #1079 Supersedes: #1014 Related: #869 Related: #163 Related: #918 Related: #850 Related: #207
- Loading branch information
Showing
49 changed files
with
5,040 additions
and
2,770 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
package tea | ||
|
||
// ClipboardMsg is a clipboard read message event. | ||
// This message is emitted when a terminal receives an OSC52 clipboard read | ||
// message event. | ||
type ClipboardMsg string | ||
|
||
// String returns the string representation of the clipboard message. | ||
func (e ClipboardMsg) String() string { | ||
return string(e) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
package tea | ||
|
||
import ( | ||
"fmt" | ||
"image/color" | ||
"strconv" | ||
"strings" | ||
) | ||
|
||
// ForegroundColorMsg represents a foreground color message. | ||
// This message is emitted when the program requests the terminal foreground | ||
// color. | ||
type ForegroundColorMsg struct{ color.Color } | ||
|
||
// String returns the hex representation of the color. | ||
func (e ForegroundColorMsg) String() string { | ||
return colorToHex(e) | ||
} | ||
|
||
// BackgroundColorMsg represents a background color message. | ||
// This message is emitted when the program requests the terminal background | ||
// color. | ||
type BackgroundColorMsg struct{ color.Color } | ||
|
||
// String returns the hex representation of the color. | ||
func (e BackgroundColorMsg) String() string { | ||
return colorToHex(e) | ||
} | ||
|
||
// CursorColorMsg represents a cursor color change message. | ||
// This message is emitted when the program requests the terminal cursor color. | ||
type CursorColorMsg struct{ color.Color } | ||
|
||
// String returns the hex representation of the color. | ||
func (e CursorColorMsg) String() string { | ||
return colorToHex(e) | ||
} | ||
|
||
type shiftable interface { | ||
~uint | ~uint16 | ~uint32 | ~uint64 | ||
} | ||
|
||
func shift[T shiftable](x T) T { | ||
if x > 0xff { | ||
x >>= 8 | ||
} | ||
return x | ||
} | ||
|
||
func colorToHex(c color.Color) string { | ||
r, g, b, _ := c.RGBA() | ||
return fmt.Sprintf("#%02x%02x%02x", shift(r), shift(g), shift(b)) | ||
} | ||
|
||
func xParseColor(s string) color.Color { | ||
switch { | ||
case strings.HasPrefix(s, "rgb:"): | ||
parts := strings.Split(s[4:], "/") | ||
if len(parts) != 3 { | ||
return color.Black | ||
} | ||
|
||
r, _ := strconv.ParseUint(parts[0], 16, 32) | ||
g, _ := strconv.ParseUint(parts[1], 16, 32) | ||
b, _ := strconv.ParseUint(parts[2], 16, 32) | ||
|
||
return color.RGBA{uint8(shift(r)), uint8(shift(g)), uint8(shift(b)), 255} | ||
case strings.HasPrefix(s, "rgba:"): | ||
parts := strings.Split(s[5:], "/") | ||
if len(parts) != 4 { | ||
return color.Black | ||
} | ||
|
||
r, _ := strconv.ParseUint(parts[0], 16, 32) | ||
g, _ := strconv.ParseUint(parts[1], 16, 32) | ||
b, _ := strconv.ParseUint(parts[2], 16, 32) | ||
a, _ := strconv.ParseUint(parts[3], 16, 32) | ||
|
||
return color.RGBA{uint8(shift(r)), uint8(shift(g)), uint8(shift(b)), uint8(shift(a))} | ||
} | ||
return color.Black | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package tea | ||
|
||
// CursorPositionMsg is a message that represents the terminal cursor position. | ||
type CursorPositionMsg struct { | ||
// Row is the row number. | ||
Row int | ||
|
||
// Column is the column number. | ||
Column int | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
package tea | ||
|
||
import "github.com/charmbracelet/x/ansi" | ||
|
||
// PrimaryDeviceAttributesMsg is a message that represents the terminal primary | ||
// device attributes. | ||
type PrimaryDeviceAttributesMsg []uint | ||
|
||
func parsePrimaryDevAttrs(csi *ansi.CsiSequence) Msg { | ||
// Primary Device Attributes | ||
da1 := make(PrimaryDeviceAttributesMsg, len(csi.Params)) | ||
csi.Range(func(i int, p int, hasMore bool) bool { | ||
if !hasMore { | ||
da1[i] = uint(p) | ||
} | ||
return true | ||
}) | ||
return da1 | ||
} |
Oops, something went wrong.