Skip to content

Commit

Permalink
refactor: reimplement the event loop with a sequence parser (#1080)
Browse files Browse the repository at this point in the history
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
aymanbagabas authored Aug 19, 2024
2 parents d6a19f0 + 3f5fb9a commit 0c1a6a4
Show file tree
Hide file tree
Showing 49 changed files with 5,040 additions and 2,770 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,19 @@ jobs:
run: go test ./...

- name: Build examples
if: ${{ matrix.go-version != '~1.18' }}
run: |
go mod tidy
go build -v ./...
working-directory: ./examples

- name: Test examples
if: ${{ matrix.go-version != '~1.18' }}
run: go test -v ./...
working-directory: ./examples

- name: Build tutorials
if: ${{ matrix.go-version != '~1.18' }}
run: |
go mod tidy
go build -v ./...
Expand Down
2 changes: 1 addition & 1 deletion inputreader_other.go → cancelreader_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
217 changes: 217 additions & 0 deletions cancelreader_windows.go
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
}
11 changes: 11 additions & 0 deletions clipboard.go
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)
}
82 changes: 82 additions & 0 deletions color.go
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
}
10 changes: 10 additions & 0 deletions cursor.go
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
}
19 changes: 19 additions & 0 deletions da1.go
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
}
Loading

0 comments on commit 0c1a6a4

Please sign in to comment.