-
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
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: #1079 Supersedes: #1014 Related: #869 Related: #163 Related: #918 Related: #850 Related: #207
- Loading branch information
1 parent
d6a19f0
commit ed4f43e
Showing
40 changed files
with
4,877 additions
and
2,596 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
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,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) | ||
} |
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,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 | ||
} |
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 | ||
|
||
// CursorPositionEvent represents a cursor position event. | ||
type CursorPositionEvent 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,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 | ||
} |
Oops, something went wrong.