From e6d4d92ebeb55fe78a84e4b8584c8516a211df69 Mon Sep 17 00:00:00 2001 From: Erik G Date: Mon, 4 Oct 2021 17:41:03 +0200 Subject: [PATCH] Rework input reader to support reading input record on Windows --- cancelreader.go | 72 ---- cancelreader_default.go | 14 - cancelreader_unix.go | 20 -- cancelreader_windows.go | 245 ------------- examples/textinput/main.go | 7 + go.mod | 3 +- go.sum | 5 +- inputreader.go | 78 +++++ cancelreader_bsd.go => inputreader_bsd.go | 46 +-- inputreader_default.go | 14 + cancelreader_linux.go => inputreader_linux.go | 41 ++- ...lreader_select.go => inputreader_select.go | 40 ++- inputreader_unix.go | 19 ++ inputreader_windows.go | 322 ++++++++++++++++++ key.go | 34 +- key_windows.go | 212 ++++++++++++ mouse.go | 17 +- tea.go | 67 ++-- 18 files changed, 796 insertions(+), 460 deletions(-) delete mode 100644 cancelreader.go delete mode 100644 cancelreader_default.go delete mode 100644 cancelreader_unix.go delete mode 100644 cancelreader_windows.go create mode 100644 inputreader.go rename cancelreader_bsd.go => inputreader_bsd.go (64%) create mode 100644 inputreader_default.go rename cancelreader_linux.go => inputreader_linux.go (70%) rename cancelreader_select.go => inputreader_select.go (67%) create mode 100644 inputreader_unix.go create mode 100644 inputreader_windows.go create mode 100644 key_windows.go diff --git a/cancelreader.go b/cancelreader.go deleted file mode 100644 index 90b22f0e60..0000000000 --- a/cancelreader.go +++ /dev/null @@ -1,72 +0,0 @@ -package tea - -import ( - "fmt" - "io" - "sync" -) - -var errCanceled = fmt.Errorf("read cancelled") - -// cancelReader is a io.Reader whose Read() calls can be cancelled without data -// being consumed. The cancelReader has to be closed. -type cancelReader interface { - io.ReadCloser - - // Cancel cancels ongoing and future reads an returns true if it succeeded. - Cancel() bool -} - -// fallbackCancelReader implements cancelReader but does not actually support -// cancelation during an ongoing Read() call. Thus, Cancel() always returns -// false. However, after calling Cancel(), new Read() calls immediately return -// errCanceled and don't consume any data anymore. -type fallbackCancelReader struct { - r io.Reader - cancelled bool -} - -// newFallbackCancelReader is a fallback for newCancelReader that cannot -// actually cancel an ongoing read but will immediately return on future reads -// if it has been cancelled. -func newFallbackCancelReader(reader io.Reader) (cancelReader, error) { - return &fallbackCancelReader{r: reader}, nil -} - -func (r *fallbackCancelReader) Read(data []byte) (int, error) { - if r.cancelled { - return 0, errCanceled - } - - return r.r.Read(data) -} - -func (r *fallbackCancelReader) Cancel() bool { - r.cancelled = true - - return false -} - -func (r *fallbackCancelReader) Close() error { - return nil -} - -// cancelMixin represents a goroutine-safe cancelation status. -type cancelMixin struct { - unsafeCancelled bool - lock sync.Mutex -} - -func (c *cancelMixin) isCancelled() bool { - c.lock.Lock() - defer c.lock.Unlock() - - return c.unsafeCancelled -} - -func (c *cancelMixin) setCancelled() { - c.lock.Lock() - defer c.lock.Unlock() - - c.unsafeCancelled = true -} diff --git a/cancelreader_default.go b/cancelreader_default.go deleted file mode 100644 index 3d06887071..0000000000 --- a/cancelreader_default.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !darwin && !windows && !linux && !solaris && !freebsd && !netbsd && !openbsd -// +build !darwin,!windows,!linux,!solaris,!freebsd,!netbsd,!openbsd - -package tea - -import ( - "io" -) - -// newCancelReader returns a fallbackCancelReader that satisfies the -// cancelReader but does not actually support cancelation. -func newCancelReader(reader io.Reader) (cancelReader, error) { - return newFallbackCancelReader(reader) -} diff --git a/cancelreader_unix.go b/cancelreader_unix.go deleted file mode 100644 index c69db614c2..0000000000 --- a/cancelreader_unix.go +++ /dev/null @@ -1,20 +0,0 @@ -//go:build solaris -// +build solaris - -// nolint:revive -package tea - -import ( - "io" -) - -// newCancelReader returns a reader and a cancel function. If the input reader -// is an *os.File, the cancel function can be used to interrupt a blocking call -// read call. In this case, the cancel function returns true if the call was -// cancelled successfully. If the input reader is not a *os.File or the file -// descriptor is 1024 or larger, the cancel function does nothing and always -// returns false. The generic unix implementation is based on the posix select -// syscall. -func newCancelReader(reader io.Reader) (cancelReader, error) { - return newSelectCancelReader(reader) -} diff --git a/cancelreader_windows.go b/cancelreader_windows.go deleted file mode 100644 index 0bd75d69b7..0000000000 --- a/cancelreader_windows.go +++ /dev/null @@ -1,245 +0,0 @@ -//go:build windows -// +build windows - -package tea - -import ( - "fmt" - "io" - "os" - "syscall" - "time" - "unicode/utf16" - - "golang.org/x/sys/windows" -) - -var fileShareValidFlags uint32 = 0x00000007 - -// newCancelReader returns a reader and a cancel function. If the input reader -// is an *os.File with the same file descriptor as os.Stdin, the cancel function -// can be used to interrupt a blocking call read call. In this case, the cancel -// function returns true if the call was cancelled successfully. If the input -// reader is not a *os.File with the same file descriptor as os.Stdin, the -// cancel function does nothing and always returns false. The Windows -// implementation is based on WaitForMultipleObject with overlapping reads from -// CONIN$. -func newCancelReader(reader io.Reader) (cancelReader, error) { - if f, ok := reader.(*os.File); !ok || f.Fd() != os.Stdin.Fd() { - return newFallbackCancelReader(reader) - } - - // it is neccessary to open CONIN$ (NOT windows.STD_INPUT_HANDLE) in - // overlapped mode to be able to use it with WaitForMultipleObjects. - conin, err := windows.CreateFile( - &(utf16.Encode([]rune("CONIN$\x00"))[0]), windows.GENERIC_READ|windows.GENERIC_WRITE, - fileShareValidFlags, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OVERLAPPED, 0) - if err != nil { - return nil, fmt.Errorf("open CONIN$ in overlapping mode: %w", err) - } - - resetConsole, err := prepareConsole(conin) - if err != nil { - return nil, fmt.Errorf("prepare console: %w", err) - } - - // flush input, otherwise it can contain events which trigger - // WaitForMultipleObjects but which ReadFile cannot read, resulting in an - // un-cancelable read - err = flushConsoleInputBuffer(conin) - if err != nil { - return nil, fmt.Errorf("flush console input buffer: %w", err) - } - - cancelEvent, err := windows.CreateEvent(nil, 0, 0, nil) - if err != nil { - return nil, fmt.Errorf("create stop event: %w", err) - } - - return &winCancelReader{ - conin: conin, - cancelEvent: cancelEvent, - resetConsole: resetConsole, - blockingReadSignal: make(chan struct{}, 1), - }, nil -} - -type winCancelReader struct { - conin windows.Handle - cancelEvent windows.Handle - cancelMixin - - resetConsole func() error - blockingReadSignal chan struct{} -} - -func (r *winCancelReader) Read(data []byte) (int, error) { - if r.isCancelled() { - return 0, errCanceled - } - - err := r.wait() - if err != nil { - return 0, err - } - - if r.isCancelled() { - return 0, errCanceled - } - - // windows.Read does not work on overlapping windows.Handles - return r.readAsync(data) -} - -// Cancel cancels ongoing and future Read() calls and returns true if the -// cancelation of the ongoing Read() was successful. On Windows Terminal, -// WaitForMultipleObjects sometimes immediately returns without input being -// available. In this case, graceful cancelation is not possible and Cancel() -// returns false. -func (r *winCancelReader) Cancel() bool { - r.setCancelled() - - 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 -} - -func (r *winCancelReader) Close() error { - err := windows.CloseHandle(r.cancelEvent) - if err != nil { - return fmt.Errorf("closing cancel event handle: %w", err) - } - - err = r.resetConsole() - if err != nil { - return err - } - - err = windows.Close(r.conin) - if err != nil { - return fmt.Errorf("closing CONIN$") - } - - return nil -} - -func (r *winCancelReader) wait() error { - event, err := windows.WaitForMultipleObjects([]windows.Handle{r.conin, r.cancelEvent}, false, windows.INFINITE) - switch { - case windows.WAIT_OBJECT_0 <= event && event < windows.WAIT_OBJECT_0+2: - if event == windows.WAIT_OBJECT_0+1 { - return 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", error(err)) - } -} - -// readAsync is neccessary to read from a windows.Handle in overlapping mode. -func (r *winCancelReader) readAsync(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(r.conin, data, &n, &overlapped) - if err != nil && err != windows.ERROR_IO_PENDING { - return int(n), err - } - - r.blockingReadSignal <- struct{}{} - err = windows.GetOverlappedResult(r.conin, &overlapped, &n, true) - if err != nil { - return int(n), nil - } - <-r.blockingReadSignal - - return int(n), nil -} - -func prepareConsole(input windows.Handle) (reset func() error, err error) { - var originalMode uint32 - - err = windows.GetConsoleMode(input, &originalMode) - if err != nil { - return nil, fmt.Errorf("get console mode: %w", err) - } - - var newMode uint32 - newMode &^= windows.ENABLE_ECHO_INPUT - newMode &^= windows.ENABLE_LINE_INPUT - newMode &^= windows.ENABLE_MOUSE_INPUT - newMode &^= windows.ENABLE_WINDOW_INPUT - newMode &^= windows.ENABLE_PROCESSED_INPUT - - newMode |= windows.ENABLE_EXTENDED_FLAGS - newMode |= windows.ENABLE_INSERT_MODE - newMode |= windows.ENABLE_QUICK_EDIT_MODE - - // Enabling virutal terminal input is necessary for processing certain - // types of input like X10 mouse events and arrows keys with the current - // bytes-based input reader. It does, however, prevent cancelReader from - // being able to cancel input. The planned solution for this is to read - // Windows events in a more native fashion, rather than the current simple - // bytes-based input reader which works well on unix systems. - newMode |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT - - err = windows.SetConsoleMode(input, newMode) - if err != nil { - return nil, fmt.Errorf("set console mode: %w", err) - } - - return func() error { - err := windows.SetConsoleMode(input, originalMode) - if err != nil { - return fmt.Errorf("reset console mode: %w", err) - } - - return nil - }, nil -} - -var ( - modkernel32 = windows.NewLazySystemDLL("kernel32.dll") - procFlushConsoleInputBuffer = modkernel32.NewProc("FlushConsoleInputBuffer") -) - -func flushConsoleInputBuffer(consoleInput windows.Handle) error { - r, _, e := syscall.Syscall(procFlushConsoleInputBuffer.Addr(), 1, - uintptr(consoleInput), 0, 0) - if r == 0 { - return error(e) - } - - return nil -} diff --git a/examples/textinput/main.go b/examples/textinput/main.go index 25617ae031..212caafc04 100644 --- a/examples/textinput/main.go +++ b/examples/textinput/main.go @@ -17,6 +17,13 @@ func main() { if err := p.Start(); err != nil { log.Fatal(err) } + + p = tea.NewProgram(initialModel()) + + if err := p.Start(); err != nil { + log.Fatal(err) + } + } type tickMsg struct{} diff --git a/go.mod b/go.mod index 760bb3453a..67913e560b 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,10 @@ go 1.13 require ( github.com/containerd/console v1.0.2 + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f github.com/mattn/go-isatty v0.0.12 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 github.com/muesli/termenv v0.8.1 - golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c + golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e golang.org/x/term v0.0.0-20210422114643-f5beecf764ed ) diff --git a/go.sum b/go.sum index f20a630d0b..606b44bfb5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/containerd/console v1.0.2 h1:Pi6D+aZXM+oUw1czuKgH5IJ+y0jhYcwBJfx5/Ghn9dE= github.com/containerd/console v1.0.2/go.mod h1:ytZPjGgY2oeTkAONYafi2kSj0aYggsf8acV1PGKCbzQ= +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/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.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= @@ -17,7 +19,8 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e h1:WUoyKPm6nCo1BnNUvPGnFG3T5DUVem42yDJZZ4CNxMA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed h1:Ei4bQjjpYUsS4efOUz+5Nz++IVkHk87n2zBA0NxBWc0= golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/inputreader.go b/inputreader.go new file mode 100644 index 0000000000..ef981e9077 --- /dev/null +++ b/inputreader.go @@ -0,0 +1,78 @@ +package tea + +import ( + "fmt" + "io" + "sync" +) + +var errCanceled = fmt.Errorf("read cancelled") + +// inputReader allows cancellable reads of input events. The inputReader has to +// be closed. +type inputReader interface { + ReadInput() ([]Msg, error) + Close() error + + // Cancel cancels ongoing and future reads an returns true if it succeeded. + Cancel() bool +} + +// fallbackInputReader implements inputReader but does not actually support +// cancelation during an ongoing ReadInput() call. Thus, Cancel() always returns +// false. However, after calling Cancel(), new ReadInput() calls immediately +// return errCanceled and don't consume any data anymore. +type fallbackInputReader struct { + r io.Reader + cancelled bool +} + +// newFallbackInputReader is a fallback for newInputReader that cannot actually +// cancel an ongoing read but will immediately return on future reads if it has +// been cancelled. +func newFallbackInputReader(reader io.Reader) (inputReader, error) { + return &fallbackInputReader{r: reader}, nil +} + +func (r *fallbackInputReader) ReadInput() ([]Msg, error) { + if r.cancelled { + return nil, errCanceled + } + + msg, err := parseInputMsgFromReader(r.r) + if err != nil { + return nil, err + } + + return []Msg{msg}, nil +} + +func (r *fallbackInputReader) Cancel() bool { + r.cancelled = true + + return false +} + +func (r *fallbackInputReader) Close() error { + return nil +} + +// cancelMixin represents a goroutine-safe cancelation status. +type cancelMixin struct { + unsafeCancelled bool + lock sync.Mutex +} + +func (c *cancelMixin) isCancelled() bool { + c.lock.Lock() + defer c.lock.Unlock() + + return c.unsafeCancelled +} + +func (c *cancelMixin) setCancelled() { + c.lock.Lock() + defer c.lock.Unlock() + + c.unsafeCancelled = true +} diff --git a/cancelreader_bsd.go b/inputreader_bsd.go similarity index 64% rename from cancelreader_bsd.go rename to inputreader_bsd.go index 0f6653abbf..3e46dae306 100644 --- a/cancelreader_bsd.go +++ b/inputreader_bsd.go @@ -1,3 +1,4 @@ +//go:build darwin || freebsd || netbsd || openbsd // +build darwin freebsd netbsd openbsd // nolint:revive @@ -13,21 +14,23 @@ import ( "golang.org/x/sys/unix" ) -// newkqueueCancelReader returns a reader and a cancel function. If the input reader -// is an *os.File, the cancel function can be used to interrupt a blocking call -// read call. In this case, the cancel function returns true if the call was -// cancelled successfully. If the input reader is not a *os.File, the cancel -// function does nothing and always returns false. The BSD and macOS -// implementation is based on the kqueue mechanism. -func newCancelReader(reader io.Reader) (cancelReader, error) { +// newInputReader returns a cancelable reader. If the input reader is an +// *os.File, the cancel method can be used to interrupt a blocking call read +// call. In this case, the cancel method returns true if the call was cancelled +// successfully. If the input reader is not a *os.File, the cancel function does +// nothing and always returns false. The BSD and macOS implementation is based +// on the kqueue mechanism, but if falls back to using the POSIX select syscall +// when the input reader is /dev/tty which is not supported by kqueue (in this +// case, only file descriptors < 1024 are supported). +func newInputReader(reader io.Reader) (inputReader, error) { file, ok := reader.(*os.File) if !ok { - return newFallbackCancelReader(reader) + return newFallbackInputReader(reader) } // kqueue returns instantly when polling /dev/tty so fallback to select if file.Name() == "/dev/tty" { - return newSelectCancelReader(reader) + return newSelectInputReader(reader) } kQueue, err := unix.Kqueue() @@ -35,7 +38,7 @@ func newCancelReader(reader io.Reader) (cancelReader, error) { return nil, fmt.Errorf("create kqueue: %w", err) } - r := &kqueueCancelReader{ + r := &kqueueInputReader{ file: file, kQueue: kQueue, } @@ -51,7 +54,7 @@ func newCancelReader(reader io.Reader) (cancelReader, error) { return r, nil } -type kqueueCancelReader struct { +type kqueueInputReader struct { file *os.File cancelSignalReader *os.File cancelSignalWriter *os.File @@ -60,9 +63,9 @@ type kqueueCancelReader struct { kQueueEvents [2]unix.Kevent_t } -func (r *kqueueCancelReader) Read(data []byte) (int, error) { +func (r *kqueueInputReader) ReadInput() ([]Msg, error) { if r.isCancelled() { - return 0, errCanceled + return nil, errCanceled } err := r.wait() @@ -72,17 +75,22 @@ func (r *kqueueCancelReader) Read(data []byte) (int, error) { var b [1]byte _, errRead := r.cancelSignalReader.Read(b[:]) if errRead != nil { - return 0, fmt.Errorf("reading cancel signal: %w", errRead) + return nil, fmt.Errorf("reading cancel signal: %w", errRead) } } - return 0, err + return nil, err + } + + msg, err := parseInputMsgFromReader(r.file) + if err != nil { + return nil, err } - return r.file.Read(data) + return []Msg{msg}, nil } -func (r *kqueueCancelReader) Cancel() bool { +func (r *kqueueInputReader) Cancel() bool { r.setCancelled() // send cancel signal @@ -90,7 +98,7 @@ func (r *kqueueCancelReader) Cancel() bool { return err == nil } -func (r *kqueueCancelReader) Close() error { +func (r *kqueueInputReader) Close() error { var errMsgs []string // close kqueue @@ -117,7 +125,7 @@ func (r *kqueueCancelReader) Close() error { return nil } -func (r *kqueueCancelReader) wait() error { +func (r *kqueueInputReader) wait() error { events := make([]unix.Kevent_t, 1) for { diff --git a/inputreader_default.go b/inputreader_default.go new file mode 100644 index 0000000000..c4481f14cc --- /dev/null +++ b/inputreader_default.go @@ -0,0 +1,14 @@ +//go:build !darwin && !windows && !linux && !solaris && !freebsd && !netbsd && !openbsd +// +build !darwin,!windows,!linux,!solaris,!freebsd,!netbsd,!openbsd + +package tea + +import ( + "io" +) + +// newInputReader returns a allbackInputReader that satisfies the inputReader +// but does not actually support cancelation. +func newInputReader(reader io.Reader) (inputReader, error) { + return newFallbackInputReader(reader) +} diff --git a/cancelreader_linux.go b/inputreader_linux.go similarity index 70% rename from cancelreader_linux.go rename to inputreader_linux.go index 343f7d1c98..525d4da8f2 100644 --- a/cancelreader_linux.go +++ b/inputreader_linux.go @@ -14,16 +14,16 @@ import ( "golang.org/x/sys/unix" ) -// newCancelReader returns a reader and a cancel function. If the input reader -// is an *os.File, the cancel function can be used to interrupt a blocking call -// read call. In this case, the cancel function returns true if the call was -// cancelled successfully. If the input reader is not a *os.File, the cancel -// function does nothing and always returns false. The linux implementation is -// based on the epoll mechanism. -func newCancelReader(reader io.Reader) (cancelReader, error) { +// newInputReader returns a cancelable input reader. If the passed reader is an +// *os.File, the cancel method can be used to interrupt a blocking call read +// call. In this case, the cancel method returns true if the call was cancelled +// successfully. If the input reader is not a *os.File, the cancel function does +// nothing and always returns false. The Linux implementation is based on the +// epoll mechanism. +func newInputReader(reader io.Reader) (inputReader, error) { file, ok := reader.(*os.File) if !ok { - return newFallbackCancelReader(reader) + return newFallbackInputReader(reader) } epoll, err := unix.EpollCreate1(0) @@ -31,7 +31,7 @@ func newCancelReader(reader io.Reader) (cancelReader, error) { return nil, fmt.Errorf("create epoll: %w", err) } - r := &epollCancelReader{ + r := &epollInputReader{ file: file, epoll: epoll, } @@ -60,7 +60,7 @@ func newCancelReader(reader io.Reader) (cancelReader, error) { return r, nil } -type epollCancelReader struct { +type epollInputReader struct { file *os.File cancelSignalReader *os.File cancelSignalWriter *os.File @@ -68,9 +68,9 @@ type epollCancelReader struct { epoll int } -func (r *epollCancelReader) Read(data []byte) (int, error) { +func (r *epollInputReader) ReadInput() ([]Msg, error) { if r.isCancelled() { - return 0, errCanceled + return nil, errCanceled } err := r.wait() @@ -80,17 +80,22 @@ func (r *epollCancelReader) Read(data []byte) (int, error) { var b [1]byte _, readErr := r.cancelSignalReader.Read(b[:]) if readErr != nil { - return 0, fmt.Errorf("reading cancel signal: %w", readErr) + return nil, fmt.Errorf("reading cancel signal: %w", readErr) } } - return 0, err + return nil, err + } + + msg, err := parseInputMsgFromReader(r.file) + if err != nil { + return nil, err } - return r.file.Read(data) + return []Msg{msg}, nil } -func (r *epollCancelReader) Cancel() bool { +func (r *epollInputReader) Cancel() bool { r.setCancelled() // send cancel signal @@ -98,7 +103,7 @@ func (r *epollCancelReader) Cancel() bool { return err == nil } -func (r *epollCancelReader) Close() error { +func (r *epollInputReader) Close() error { var errMsgs []string // close kqueue @@ -125,7 +130,7 @@ func (r *epollCancelReader) Close() error { return nil } -func (r *epollCancelReader) wait() error { +func (r *epollInputReader) wait() error { events := make([]unix.EpollEvent, 1) for { diff --git a/cancelreader_select.go b/inputreader_select.go similarity index 67% rename from cancelreader_select.go rename to inputreader_select.go index 0276c8d135..601aa2b04e 100644 --- a/cancelreader_select.go +++ b/inputreader_select.go @@ -14,19 +14,18 @@ import ( "golang.org/x/sys/unix" ) -// newSelectCancelReader returns a reader and a cancel function. If the input -// reader is an *os.File, the cancel function can be used to interrupt a -// blocking call read call. In this case, the cancel function returns true if -// the call was cancelled successfully. If the input reader is not a *os.File or -// the file descriptor is 1024 or larger, the cancel function does nothing and -// always returns false. The generic unix implementation is based on the posix -// select syscall. -func newSelectCancelReader(reader io.Reader) (cancelReader, error) { +// newSelectInputReader returns a cancelable reader. If the passed reader is an +// *os.File, the cancel method can be used to interrupt a blocking call read +// call. In this case, the cancel method returns true if the call was cancelled +// successfully. If the input reader is not a *os.File or the file descriptor is +// 1024 or larger, the cancel method does nothing and always returns false. The +// generic Unix implementation is based on the POSIX select syscall. +func newSelectInputReader(reader io.Reader) (inputReader, error) { file, ok := reader.(*os.File) if !ok || file.Fd() >= unix.FD_SETSIZE { - return newFallbackCancelReader(reader) + return newFallbackInputReader(reader) } - r := &selectCancelReader{file: file} + r := &selectInputReader{file: file} var err error @@ -38,16 +37,16 @@ func newSelectCancelReader(reader io.Reader) (cancelReader, error) { return r, nil } -type selectCancelReader struct { +type selectInputReader struct { file *os.File cancelSignalReader *os.File cancelSignalWriter *os.File cancelMixin } -func (r *selectCancelReader) Read(data []byte) (int, error) { +func (r *selectInputReader) ReadInput() ([]Msg, error) { if r.isCancelled() { - return 0, errCanceled + return nil, errCanceled } for { @@ -62,18 +61,23 @@ func (r *selectCancelReader) Read(data []byte) (int, error) { var b [1]byte _, readErr := r.cancelSignalReader.Read(b[:]) if readErr != nil { - return 0, fmt.Errorf("reading cancel signal: %w", readErr) + return nil, fmt.Errorf("reading cancel signal: %w", readErr) } } - return 0, err + return nil, err } - return r.file.Read(data) + msg, err := parseInputMsgFromReader(r.file) + if err != nil { + return nil, err + } + + return []Msg{msg}, nil } } -func (r *selectCancelReader) Cancel() bool { +func (r *selectInputReader) Cancel() bool { r.setCancelled() // send cancel signal @@ -81,7 +85,7 @@ func (r *selectCancelReader) Cancel() bool { return err == nil } -func (r *selectCancelReader) Close() error { +func (r *selectInputReader) Close() error { var errMsgs []string // close pipe diff --git a/inputreader_unix.go b/inputreader_unix.go new file mode 100644 index 0000000000..07c0b5a627 --- /dev/null +++ b/inputreader_unix.go @@ -0,0 +1,19 @@ +//go:build solaris +// +build solaris + +// nolint:revive +package tea + +import ( + "io" +) + +// newInputReader returns a cancelable input reader. If the passed reader is an +// *os.File, the cancel method can be used to interrupt a blocking call read +// call. In this case, the cancel method returns true if the call was cancelled +// successfully. If the input reader is not a *os.File or the file descriptor is +// 1024 or larger, the cancel method does nothing and always returns false. The +// generic Unix implementation is based on the POSIX select syscall. +func newInputReader(reader io.Reader) (inputReader, error) { + return newSelectInputReader(reader) +} diff --git a/inputreader_windows.go b/inputreader_windows.go new file mode 100644 index 0000000000..9f7e8b98fc --- /dev/null +++ b/inputreader_windows.go @@ -0,0 +1,322 @@ +//go:build windows +// +build windows + +package tea + +import ( + "fmt" + "io" + "os" + "time" + "unicode/utf16" + + "github.com/erikgeiser/coninput" + "golang.org/x/sys/windows" +) + +// newInputReader returns a cancelable input reader. If the input reader is an +// *os.File, the cancel method can be used to interrupt a blocking call read +// call. In this case, the cancel method returns true if the call was cancelled +// successfully. If the input reader is not a *os.File with the same file +// descriptor as os.Stdin, the cancel function does nothing and always returns +// false. The Windows implementation is based on WaitForMultipleObject. If +// os.Stdin is not a pipe, the events are read as input records, otherwise they +// are parsed from bytes using overlapping reads from CONIN$. +func newInputReader(reader io.Reader) (inputReader, error) { + if f, ok := reader.(*os.File); !ok || f.Fd() != os.Stdin.Fd() { + return newFallbackInputReader(reader) + } + + conin, err := windows.GetStdHandle(windows.STD_INPUT_HANDLE) + if err != nil { + return nil, fmt.Errorf("get std input handle: %w", err) + } + + // 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 use the compatibility reader. + var dummy uint32 + err = windows.GetConsoleMode(conin, &dummy) + if err != nil { + return newCompatibilityInputReader() + } + + return newInputRecordReader(conin) +} + +func newInputRecordReader(conin windows.Handle) (*winInputRecordReader, error) { + originalConsoleMode, err := prepareConsole(conin, + windows.ENABLE_MOUSE_INPUT, + windows.ENABLE_WINDOW_INPUT, + windows.ENABLE_EXTENDED_FLAGS, + ) + if err != nil { + return nil, fmt.Errorf("prepare console: %w", err) + } + + cancelEvent, err := windows.CreateEvent(nil, 0, 0, nil) + if err != nil { + return nil, fmt.Errorf("create stop event: %w", err) + } + + return &winInputRecordReader{ + conin: conin, + cancelEvent: cancelEvent, + originalConsoleMode: originalConsoleMode, + inputEvent: make([]coninput.InputRecord, 4), + }, nil + +} + +type winInputRecordReader struct { + conin windows.Handle + cancelEvent windows.Handle + cancelMixin + + originalConsoleMode uint32 + + // inputEvent holds the input event that was read in order to avoid + // unneccessary allocations. This re-use is possible because + // InputRecord.Unwarp which is called inparseInputMsgFromInputRecord returns + // an data structure that is independent of the passed InputRecord. + inputEvent []coninput.InputRecord +} + +func (r *winInputRecordReader) ReadInput() ([]Msg, error) { + if r.isCancelled() { + return nil, errCanceled + } + + err := waitForInput(r.conin, r.cancelEvent) + if err != nil { + return nil, err + } + + if r.isCancelled() { + return nil, errCanceled + } + + n, err := coninput.ReadConsoleInput(r.conin, r.inputEvent) + if err != nil { + return nil, fmt.Errorf("ReadConsoleInput: %w", err) + } + + return parseInputMsgsFromInputRecords(r.inputEvent[:n]) +} + +// Cancel cancels ongoing and future Read() calls and returns true if the +// cancelation of the ongoing Read() was successful. +func (r *winInputRecordReader) Cancel() bool { + r.setCancelled() + + err := windows.SetEvent(r.cancelEvent) + if err != nil { + return false + } + + return true +} + +func (r *winInputRecordReader) Close() error { + err := windows.CloseHandle(r.cancelEvent) + if err != nil { + return fmt.Errorf("closing cancel event handle: %w", err) + } + + if r.originalConsoleMode != 0 { + err := windows.SetConsoleMode(r.conin, r.originalConsoleMode) + if err != nil { + return fmt.Errorf("reset console mode: %w", err) + } + } + + return nil +} + +func newCompatibilityInputReader() (*winCompatibilityInputReader, error) { + conin, err := windows.CreateFile( + &(utf16.Encode([]rune("CONIN$\x00"))[0]), windows.GENERIC_READ|windows.GENERIC_WRITE, + windows.FILE_SHARE_WRITE|windows.FILE_SHARE_READ, nil, + windows.OPEN_EXISTING, windows.FILE_FLAG_OVERLAPPED, 0) + if err != nil { + return nil, fmt.Errorf("open CONIN$ in overlapped mode: %w", err) + } + + // set the *preferred* console mode, if data was piped to stdin this is not + // possible anymore, so we ignore errors + originalConsoleMode, _ := prepareConsole(conin, + windows.ENABLE_EXTENDED_FLAGS, + windows.ENABLE_INSERT_MODE, + windows.ENABLE_QUICK_EDIT_MODE, + // ENABLE_VIRTUAL_TERMINAL_INPUT causes unreadable inputs that trigger + // WaitForMultipleObjects but it's necessary to receive special keys and + // mouse events. + windows.ENABLE_VIRTUAL_TERMINAL_INPUT, + ) + + cancelEvent, err := windows.CreateEvent(nil, 0, 0, nil) + if err != nil { + return nil, fmt.Errorf("create stop event: %w", err) + } + + // flush input, otherwise it can contain events which trigger + // WaitForMultipleObjects but which ReadFile cannot read, resulting in an + // un-cancelable read + err = coninput.FlushConsoleInputBuffer(conin) + if err != nil { + return nil, fmt.Errorf("flush console input buffer: %w", err) + } + + return &winCompatibilityInputReader{ + conin: conin, + cancelEvent: cancelEvent, + originalConsoleMode: originalConsoleMode, + blockingReadSignal: make(chan struct{}, 1), + }, nil +} + +type winCompatibilityInputReader struct { + conin windows.Handle + cancelEvent windows.Handle + cancelMixin + + originalConsoleMode uint32 + blockingReadSignal chan struct{} +} + +func (r *winCompatibilityInputReader) ReadInput() ([]Msg, error) { + if r.isCancelled() { + return nil, errCanceled + } + + err := waitForInput(r.conin, r.cancelEvent) + if err != nil { + return nil, err + } + + if r.isCancelled() { + return nil, errCanceled + } + + r.blockingReadSignal <- struct{}{} + msg, err := parseInputMsgFromReader(overlappedReader(r.conin)) + <-r.blockingReadSignal + if err != nil { + return nil, fmt.Errorf("parse input message from overlapped reader: %w", err) + } + + return []Msg{msg}, nil +} + +// Cancel cancels ongoing and future ReadInput() calls and returns true if the +// cancelation of the ongoing ReadInput() was successful. On Windows Terminal, +// WaitForMultipleObjects sometimes immediately returns without input being +// available. In this case, graceful cancelation is not possible and Cancel() +// returns false. +func (r *winCompatibilityInputReader) Cancel() bool { + r.setCancelled() + + select { + case r.blockingReadSignal <- struct{}{}: + err := windows.SetEvent(r.cancelEvent) + if err != nil { + return false + } + <-r.blockingReadSignal + case <-time.After(50 * 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 +} + +func (r *winCompatibilityInputReader) Close() error { + err := windows.CloseHandle(r.cancelEvent) + if err != nil { + return fmt.Errorf("closing cancel event handle: %w", err) + } + + if r.originalConsoleMode != 0 { + err := windows.SetConsoleMode(r.conin, r.originalConsoleMode) + if err != nil { + return fmt.Errorf("reset console mode: %w", err) + } + } + + // this does not close os.Stdin, just the handle + err = windows.Close(r.conin) + if err != nil { + return fmt.Errorf("closing overlapped CONIN$ handle") + } + + return 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 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", error(err)) + } +} + +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 +} + +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 +} diff --git a/key.go b/key.go index ae4bf1a6a3..33f6ae5947 100644 --- a/key.go +++ b/key.go @@ -5,6 +5,8 @@ import ( "fmt" "io" "unicode/utf8" + + "github.com/erikgeiser/coninput" ) // KeyMsg contains information about a keypress. KeyMsgs are always sent to @@ -50,9 +52,10 @@ func (k KeyMsg) String() (str string) { // Key contains information about a keypress. type Key struct { - Type KeyType - Runes []rune - Alt bool + Type KeyType + Runes []rune + Alt bool + WinKeyEventRecord *coninput.KeyEventRecord } // String returns a friendly string representation for a key. It's safe (and @@ -292,41 +295,44 @@ var hexes = map[string]Key{ "1b4f44": {Type: KeyLeft, Alt: false}, } -// readInput reads keypress and mouse input from a TTY and returns a message -// containing information about the key or mouse event accordingly. -func readInput(input io.Reader) (Msg, error) { +// parseInputMsgsFromReader reads keypress and mouse input from a TTY and +// returns a message containing information about the key or mouse event +// accordingly. +func parseInputMsgFromReader(reader io.Reader) (Msg, error) { var buf [256]byte // Read and block - numBytes, err := input.Read(buf[:]) + numBytes, err := reader.Read(buf[:]) if err != nil { return nil, err } + inputBuffer := buf[:numBytes] + // See if it's a mouse event. For now we're parsing X10-type mouse events // only. - mouseEvent, err := parseX10MouseEvent(buf[:numBytes]) + mouseEvent, err := parseX10MouseEvent(inputBuffer) if err == nil { return MouseMsg(mouseEvent), nil } // Is it a special sequence, like an arrow key? - if k, ok := sequences[string(buf[:numBytes])]; ok { + if k, ok := sequences[string(inputBuffer)]; ok { return KeyMsg(Key{Type: k}), nil } // Some of these need special handling - hex := fmt.Sprintf("%x", buf[:numBytes]) + hex := fmt.Sprintf("%x", inputBuffer) if k, ok := hexes[hex]; ok { return KeyMsg(k), nil } // Is the alt key pressed? The buffer will be prefixed with an escape // sequence if so. - if numBytes > 1 && buf[0] == 0x1b { + if len(inputBuffer) > 1 && inputBuffer[0] == 0x1b { // Now remove the initial escape sequence and re-process to get the // character being pressed in combination with alt. - c, _ := utf8.DecodeRune(buf[1:]) + c, _ := utf8.DecodeRune(inputBuffer[1:]) if c == utf8.RuneError { return nil, errors.New("could not decode rune after removing initial escape") } @@ -334,7 +340,7 @@ func readInput(input io.Reader) (Msg, error) { } var runes []rune - b := buf[:numBytes] + b := inputBuffer // Translate input into runes. In most cases we'll receive exactly one // rune, but there are cases, particularly when an input method editor is @@ -358,7 +364,7 @@ func readInput(input io.Reader) (Msg, error) { // Is the first rune a control character? r := KeyType(runes[0]) - if numBytes == 1 && r <= keyUS || r == keyDEL { + if len(inputBuffer) == 1 && r <= keyUS || r == keyDEL { return KeyMsg(Key{Type: r}), nil } diff --git a/key_windows.go b/key_windows.go new file mode 100644 index 0000000000..a6e4520dfa --- /dev/null +++ b/key_windows.go @@ -0,0 +1,212 @@ +//go:build windows +// +build windows + +package tea + +import ( + "fmt" + + "github.com/erikgeiser/coninput" +) + +func parseInputMsgsFromInputRecords(events []coninput.InputRecord) ([]Msg, error) { + allMessages := make([]Msg, 0, len(events)) + + for _, event := range events { + msgs, err := parseInputMsgsFromInputRecord(event) + if err != nil { + return msgs, err + } + + allMessages = append(allMessages, msgs...) + } + + return allMessages, nil +} + +func parseInputMsgsFromInputRecord(event coninput.InputRecord) ([]Msg, error) { + var msgs []Msg + + switch e := event.Unwrap().(type) { + case coninput.KeyEventRecord: + if !e.KeyDown || e.VirtualKeyCode == coninput.VK_SHIFT { + return nil, nil + } + + msgs := make([]Msg, 0, e.RepeatCount) + + for i := 0; i < int(e.RepeatCount); i++ { + msgs = append(msgs, KeyMsg{ + Type: keyType(e), + Runes: []rune{e.Char}, + Alt: e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED), + WinKeyEventRecord: &e, + }) + } + + return msgs, nil + case coninput.WindowBufferSizeEventRecord: + return []Msg{WindowSizeMsg{ + Width: int(e.Size.X), + Height: int(e.Size.Y), + }}, nil + case coninput.MouseEventRecord: + event := MouseMsg{ + X: int(e.MousePositon.X), + Y: int(e.MousePositon.Y), + Type: mouseEventType(e), + Alt: e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED), + Ctrl: e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED), + WinMouseEventRecord: &e, + } + + if event.Type == MouseUnknown { + return nil, nil + } else if e.EventFlags&coninput.DOUBLE_CLICK > 0 { + return []Msg{event, event}, nil + } else { + return []Msg{event}, nil + } + case coninput.FocusEventRecord, coninput.MenuEventRecord: + // ignore + default: + return nil, fmt.Errorf("unknown record type: %T", e) + } + + return msgs, nil +} + +func mouseEventType(e coninput.MouseEventRecord) MouseEventType { + switch e.EventFlags { + case coninput.CLICK, coninput.DOUBLE_CLICK: + switch { + case e.ButtonState&coninput.FROM_LEFT_1ST_BUTTON_PRESSED > 0: + return MouseLeft + case e.ButtonState&coninput.FROM_LEFT_2ND_BUTTON_PRESSED > 0: + return MouseMiddle + case e.ButtonState&coninput.RIGHTMOST_BUTTON_PRESSED > 0: + return MouseRight + } + case coninput.MOUSE_WHEELED: + if e.WheelDirection > 0 { + return MouseWheelUp + } else { + return MouseWheelDown + } + case coninput.MOUSE_HWHEELED: + return MouseUnknown + case coninput.MOUSE_MOVED: + return MouseMotion + } + + return MouseUnknown +} + +func keyType(e coninput.KeyEventRecord) KeyType { + code := e.VirtualKeyCode + + switch code { + case coninput.VK_RETURN: + return KeyEnter + case coninput.VK_BACK: + return KeyBackspace + case coninput.VK_TAB: + 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: + return KeyUp + case coninput.VK_DOWN: + return KeyDown + case coninput.VK_RIGHT: + return KeyRight + case coninput.VK_LEFT: + return KeyLeft + case coninput.VK_HOME: + return KeyHome + case coninput.VK_END: + 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/mouse.go b/mouse.go index 6cca0b67d5..afc2da19ab 100644 --- a/mouse.go +++ b/mouse.go @@ -1,6 +1,10 @@ package tea -import "errors" +import ( + "errors" + + "github.com/erikgeiser/coninput" +) // 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 @@ -10,11 +14,12 @@ type MouseMsg MouseEvent // 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 - Type MouseEventType - Alt bool - Ctrl bool + X int + Y int + Type MouseEventType + Alt bool + Ctrl bool + WinMouseEventRecord *coninput.MouseEventRecord } // String returns a string representation of a mouse event. diff --git a/tea.go b/tea.go index 96b6aaed75..ae26373159 100644 --- a/tea.go +++ b/tea.go @@ -27,11 +27,11 @@ import ( "golang.org/x/term" ) -// Msg contain data from the result of a IO operation. Msgs trigger the update -// function and, henceforth, the UI. +// Msg represents an action and is usually the result of an IO operation. It +// triggers the Update function, and henceforth, the UI. type Msg interface{} -// Model contains the program's state as well as its core functions. +// Model contains the program's state as well as it's core functions. type Model interface { // Init is the first function that will be called. It returns an optional // initial command. To not perform an initial command return nil. @@ -46,13 +46,12 @@ type Model interface { View() string } -// Cmd is an IO operation that returns a message when it's complete. If it's -// nil it's considered a no-op. Use it for things like HTTP requests, timers, -// saving and loading from disk, and so on. +// Cmd is an IO operation. If it's nil it's considered a no-op. Use it for +// things like HTTP requests, timers, saving and loading from disk, and so on. // -// Note that there's almost never a reason to use a command to send a message -// to another part of your program. That can almost always be done in the -// update function. +// There's almost never a need to use a command to send a message to another +// part of your program. Instead, it can almost always be done in the update +// function. type Cmd func() Msg // Options to customize the program during its initialization. These are @@ -140,7 +139,7 @@ func Quit() Msg { type quitMsg struct{} // EnterAltScreen is a special command that tells the Bubble Tea program to -// enter the alternate screen buffer. +// enter alternate screen buffer. // // Because commands run asynchronously, this command should not be used in your // model's Init function. To initialize your program with the altscreen enabled @@ -241,7 +240,7 @@ func NewProgram(model Model, opts ...ProgramOption) *Program { CatchPanics: true, } - // Apply all options to the program. + // Apply all options to program for _, opt := range opts { opt(p) } @@ -258,7 +257,7 @@ func (p *Program) Start() error { errs = make(chan error) ) - // Channels for managing goroutine lifecycles. + // channels for managing goroutine lifecycles var ( readLoopDone = make(chan struct{}) sigintLoopDone = make(chan struct{}) @@ -271,9 +270,9 @@ func (p *Program) Start() error { select { case <-readLoopDone: case <-time.After(500 * time.Millisecond): - // The read loop hangs, which means the input - // cancelReader's cancel function has returned true even - // though it was not able to cancel the read. + // the read loop hangs, which means the input inputReader's + // cancel function has returned true even though it was not + // able to cancel the read } } <-cmdLoopDone @@ -284,7 +283,9 @@ func (p *Program) Start() error { ) ctx, cancelContext := context.WithCancel(context.Background()) - defer cancelContext() + defer func() { + cancelContext() + }() switch { case p.startupOptions.has(withInputTTY): @@ -373,7 +374,7 @@ func (p *Program) Start() error { p.EnableMouseAllMotion() } - // Initialize the program. + // Initialize program model := p.initialModel if initCmd := model.Init(); initCmd != nil { go func() { @@ -387,21 +388,21 @@ func (p *Program) Start() error { close(initSignalDone) } - // Start the renderer. + // Start renderer p.renderer.start() p.renderer.setAltScreen(p.altScreenActive) - // Render the initial view. + // Render initial view p.renderer.write(model.View()) - cancelReader, err := newCancelReader(p.input) + inputReader, err := newInputReader(p.input) if err != nil { return err } - defer cancelReader.Close() // nolint:errcheck + defer inputReader.Close() // nolint:errcheck - // Subscribe to user input. + // Subscribe to user input if p.input != nil { go func() { defer close(readLoopDone) @@ -411,7 +412,7 @@ func (p *Program) Start() error { return } - msg, err := readInput(cancelReader) + msgs, err := inputReader.ReadInput() if err != nil { if !errors.Is(err, io.EOF) && !errors.Is(err, errCanceled) { errs <- err @@ -420,7 +421,9 @@ func (p *Program) Start() error { return } - p.msgs <- msg + for _, msg := range msgs { + p.msgs <- msg + } } }() } else { @@ -428,7 +431,7 @@ func (p *Program) Start() error { } if f, ok := p.output.(*os.File); ok { - // Get the initial terminal size and send it to the program. + // Get initial terminal size and send it to the program go func() { w, h, err := term.GetSize(int(f.Fd())) if err != nil { @@ -441,13 +444,13 @@ func (p *Program) Start() error { } }() - // Listen for window resizes. + // Listen for window resizes go listenForResize(ctx, f, p.msgs, errs, resizeLoopDone) } else { close(resizeLoopDone) } - // Process commands. + // Process commands go func() { defer close(cmdLoopDone) @@ -476,22 +479,22 @@ func (p *Program) Start() error { } }() - // Handle updates and draw. + // Handle updates and draw for { select { case err := <-errs: cancelContext() - waitForGoroutines(cancelReader.Cancel()) + waitForGoroutines(inputReader.Cancel()) p.shutdown(false) return err case msg := <-p.msgs: - // Handle special internal messages. + // Handle special internal messages switch msg := msg.(type) { case quitMsg: cancelContext() - waitForGoroutines(cancelReader.Cancel()) + waitForGoroutines(inputReader.Cancel()) p.shutdown(false) return nil @@ -524,7 +527,7 @@ func (p *Program) Start() error { hideCursor(p.output) } - // Process internal messages for the renderer. + // Process internal messages for the renderer if r, ok := p.renderer.(*standardRenderer); ok { r.handleMessages(msg) }