Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: reimplement the event loop with a sequence parser #1080

Merged
merged 18 commits into from
Aug 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading