From 8209394ff29aa8370b16d3223c100d875d6e1c85 Mon Sep 17 00:00:00 2001 From: Erik G Date: Mon, 4 Oct 2021 17:41:03 +0200 Subject: [PATCH 01/13] Rework input reader to support reading input record on Windows --- cancelreader.go | 72 ---- cancelreader_unix.go | 20 -- cancelreader_windows.go | 245 ------------- examples/textinput/main.go | 7 + go.mod | 1 + go.sum | 5 +- inputreader.go | 78 +++++ cancelreader_bsd.go => inputreader_bsd.go | 45 +-- 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 ++-- 17 files changed, 794 insertions(+), 445 deletions(-) delete mode 100644 cancelreader.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 (65%) create mode 100644 inputreader_default.go rename cancelreader_linux.go => inputreader_linux.go (70%) rename cancelreader_select.go => inputreader_select.go (68%) 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_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 a44904c63d..cbadada539 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ 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.13 github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b github.com/muesli/reflow v0.3.0 diff --git a/go.sum b/go.sum index db989e1098..1e3c296f4f 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.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= @@ -20,7 +22,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 65% rename from cancelreader_bsd.go rename to inputreader_bsd.go index e122fa2aa9..fe5b279921 100644 --- a/cancelreader_bsd.go +++ b/inputreader_bsd.go @@ -14,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() @@ -36,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, } @@ -52,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 @@ -61,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() @@ -73,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 @@ -91,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 @@ -118,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 68% rename from cancelreader_select.go rename to inputreader_select.go index 1749945c96..164bdc4c0c 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 7c9b901301..add7c435ca 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 @@ -141,7 +140,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 @@ -242,7 +241,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) } @@ -259,7 +258,7 @@ func (p *Program) StartReturningModel() (Model, error) { errs = make(chan error) ) - // Channels for managing goroutine lifecycles. + // channels for managing goroutine lifecycles var ( readLoopDone = make(chan struct{}) sigintLoopDone = make(chan struct{}) @@ -272,9 +271,9 @@ func (p *Program) StartReturningModel() (Model, 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 @@ -285,7 +284,9 @@ func (p *Program) StartReturningModel() (Model, error) { ) ctx, cancelContext := context.WithCancel(context.Background()) - defer cancelContext() + defer func() { + cancelContext() + }() switch { case p.startupOptions.has(withInputTTY): @@ -374,7 +375,7 @@ func (p *Program) StartReturningModel() (Model, error) { p.EnableMouseAllMotion() } - // Initialize the program. + // Initialize program model := p.initialModel if initCmd := model.Init(); initCmd != nil { go func() { @@ -388,21 +389,21 @@ func (p *Program) StartReturningModel() (Model, 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 model, 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) @@ -412,7 +413,7 @@ func (p *Program) StartReturningModel() (Model, 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 @@ -421,7 +422,9 @@ func (p *Program) StartReturningModel() (Model, error) { return } - p.msgs <- msg + for _, msg := range msgs { + p.msgs <- msg + } } }() } else { @@ -429,7 +432,7 @@ func (p *Program) StartReturningModel() (Model, 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 { @@ -442,13 +445,13 @@ func (p *Program) StartReturningModel() (Model, 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) @@ -477,22 +480,22 @@ func (p *Program) StartReturningModel() (Model, 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 model, 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 model, nil @@ -525,7 +528,7 @@ func (p *Program) StartReturningModel() (Model, 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) } From 75c86134b793036a0a727f5367589930045a1640 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 14:15:23 -0500 Subject: [PATCH 02/13] Update build tags in renamed cancelreader_default, post-rebase --- inputreader_default.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/inputreader_default.go b/inputreader_default.go index c4481f14cc..6809088c53 100644 --- a/inputreader_default.go +++ b/inputreader_default.go @@ -1,5 +1,5 @@ -//go:build !darwin && !windows && !linux && !solaris && !freebsd && !netbsd && !openbsd -// +build !darwin,!windows,!linux,!solaris,!freebsd,!netbsd,!openbsd +//go:build !darwin && !windows && !linux && !solaris && !freebsd && !netbsd && !openbsd && !dragonfly +// +build !darwin,!windows,!linux,!solaris,!freebsd,!netbsd,!openbsd,!dragonfly package tea From 1ae5c009221df0b05ec1206d03aa5c36a5acc8cc Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 14:21:40 -0500 Subject: [PATCH 03/13] go mod tidy --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index cbadada539..8ab598f814 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,6 @@ require ( github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.9.0 - 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 ) From 08ca9feaeb74a6901d7d64026e334d5d5b489e62 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 14:42:25 -0500 Subject: [PATCH 04/13] For now, note windows input events as provisional --- key.go | 13 ++++++++++--- mouse.go | 16 +++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/key.go b/key.go index 33f6ae5947..6d27288bc0 100644 --- a/key.go +++ b/key.go @@ -52,9 +52,16 @@ 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 contains additional metadata about key events in + // Windows. If the program is not running on Windows this value will be + // nil. + // + // This member is provisional and may not appear in future versions of the + // library. WinKeyEventRecord *coninput.KeyEventRecord } diff --git a/mouse.go b/mouse.go index afc2da19ab..b3ea601cad 100644 --- a/mouse.go +++ b/mouse.go @@ -14,11 +14,17 @@ 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 contains additional metadata about mouse events in + // Windows. If the program is not running on Windows this value will be + // nil. + // + // This member is provisional and may not appear in future of the library. WinMouseEventRecord *coninput.MouseEventRecord } From 5aea9ba8d5bcda57d76eff3abb9cb8ba608c1f00 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 14:43:19 -0500 Subject: [PATCH 05/13] Bring comments to parity with master --- inputreader_linux.go | 6 +++--- inputreader_select.go | 6 +++--- inputreader_unix.go | 6 +++--- inputreader_windows.go | 14 ++++++------- tea.go | 47 +++++++++++++++++++++--------------------- 5 files changed, 40 insertions(+), 39 deletions(-) diff --git a/inputreader_linux.go b/inputreader_linux.go index 525d4da8f2..b5b300759e 100644 --- a/inputreader_linux.go +++ b/inputreader_linux.go @@ -17,9 +17,9 @@ import ( // 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. +// 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 { diff --git a/inputreader_select.go b/inputreader_select.go index 164bdc4c0c..ee38e3ff18 100644 --- a/inputreader_select.go +++ b/inputreader_select.go @@ -17,9 +17,9 @@ import ( // 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. +// 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 { diff --git a/inputreader_unix.go b/inputreader_unix.go index 07c0b5a627..49789bec0b 100644 --- a/inputreader_unix.go +++ b/inputreader_unix.go @@ -11,9 +11,9 @@ import ( // 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. +// 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 index 9f7e8b98fc..449e150187 100644 --- a/inputreader_windows.go +++ b/inputreader_windows.go @@ -32,9 +32,9 @@ func newInputReader(reader io.Reader) (inputReader, error) { 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. + // 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 { @@ -77,8 +77,8 @@ type winInputRecordReader struct { // 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. + // InputRecord.Unwarp which is called inparseInputMsgFromInputRecord + // returns an data structure that is independent of the passed InputRecord. inputEvent []coninput.InputRecord } @@ -149,8 +149,8 @@ func newCompatibilityInputReader() (*winCompatibilityInputReader, error) { 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. + // WaitForMultipleObjects but it's necessary to receive special keys + // and mouse events. windows.ENABLE_VIRTUAL_TERMINAL_INPUT, ) diff --git a/tea.go b/tea.go index add7c435ca..25d29d9bff 100644 --- a/tea.go +++ b/tea.go @@ -27,11 +27,11 @@ import ( "golang.org/x/term" ) -// Msg represents an action and is usually the result of an IO operation. It -// triggers the Update function, and henceforth, the UI. +// Msg contain the result from an I/O operation. Msgs trigger the update +// function and, henceforth, the UI. type Msg interface{} -// Model contains the program's state as well as it's core functions. +// Model contains the program's state as well as its 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,12 +46,13 @@ type Model interface { View() string } -// 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. +// Cmd is used to perform I/O operations, returning 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. // -// 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. +// You should avoid using commands to merely send messages to other parts of +// your program. That can almost always be done entirely in the update function +// by simply updating the appropriate parts of your model. type Cmd func() Msg // Options to customize the program during its initialization. These are @@ -140,7 +141,7 @@ func Quit() Msg { type quitMsg struct{} // EnterAltScreen is a special command that tells the Bubble Tea program to -// enter alternate screen buffer. +// enter the 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 +242,7 @@ func NewProgram(model Model, opts ...ProgramOption) *Program { CatchPanics: true, } - // Apply all options to program + // Apply all options to the program. for _, opt := range opts { opt(p) } @@ -258,7 +259,7 @@ func (p *Program) StartReturningModel() (Model, 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 +272,9 @@ func (p *Program) StartReturningModel() (Model, error) { select { case <-readLoopDone: case <-time.After(500 * time.Millisecond): - // 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 + // 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. } } <-cmdLoopDone @@ -375,7 +376,7 @@ func (p *Program) StartReturningModel() (Model, error) { p.EnableMouseAllMotion() } - // Initialize program + // Initialize the program. model := p.initialModel if initCmd := model.Init(); initCmd != nil { go func() { @@ -389,11 +390,11 @@ func (p *Program) StartReturningModel() (Model, error) { close(initSignalDone) } - // Start renderer + // Start the renderer. p.renderer.start() p.renderer.setAltScreen(p.altScreenActive) - // Render initial view + // Render the initial view. p.renderer.write(model.View()) inputReader, err := newInputReader(p.input) @@ -403,7 +404,7 @@ func (p *Program) StartReturningModel() (Model, error) { defer inputReader.Close() // nolint:errcheck - // Subscribe to user input + // Subscribe to user input. if p.input != nil { go func() { defer close(readLoopDone) @@ -432,7 +433,7 @@ func (p *Program) StartReturningModel() (Model, error) { } if f, ok := p.output.(*os.File); ok { - // Get initial terminal size and send it to the program + // Get the initial terminal size and send it to the program. go func() { w, h, err := term.GetSize(int(f.Fd())) if err != nil { @@ -445,7 +446,7 @@ func (p *Program) StartReturningModel() (Model, error) { } }() - // Listen for window resizes + // Listen for window resizes. go listenForResize(ctx, f, p.msgs, errs, resizeLoopDone) } else { close(resizeLoopDone) @@ -480,7 +481,7 @@ func (p *Program) StartReturningModel() (Model, error) { } }() - // Handle updates and draw + // Handle updates and draw. for { select { case err := <-errs: @@ -491,7 +492,7 @@ func (p *Program) StartReturningModel() (Model, error) { case msg := <-p.msgs: - // Handle special internal messages + // Handle special internal messages. switch msg := msg.(type) { case quitMsg: cancelContext() @@ -528,7 +529,7 @@ func (p *Program) StartReturningModel() (Model, 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) } From 4c818f7ecab291a01a1f1267838fa7b41aae7645 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 14:43:58 -0500 Subject: [PATCH 06/13] Simplify defer statement (per master) --- tea.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tea.go b/tea.go index 25d29d9bff..e150a92455 100644 --- a/tea.go +++ b/tea.go @@ -285,9 +285,7 @@ func (p *Program) StartReturningModel() (Model, error) { ) ctx, cancelContext := context.WithCancel(context.Background()) - defer func() { - cancelContext() - }() + defer cancelContext() switch { case p.startupOptions.has(withInputTTY): From 767c1746917e819d9d6e33fe74206f420e43eea8 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 15:41:02 -0500 Subject: [PATCH 07/13] Removed old cancelreader file left over from rebase --- cancelreader_default.go | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 cancelreader_default.go diff --git a/cancelreader_default.go b/cancelreader_default.go deleted file mode 100644 index eba557e547..0000000000 --- a/cancelreader_default.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build !darwin && !windows && !linux && !solaris && !freebsd && !netbsd && !openbsd && !dragonfly -// +build !darwin,!windows,!linux,!solaris,!freebsd,!netbsd,!openbsd,!dragonfly - -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) -} From f55bd6e042cdd4bf47639f7aa886b8ddce903de3 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 15:41:54 -0500 Subject: [PATCH 08/13] Fix typo in comment --- inputreader_default.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inputreader_default.go b/inputreader_default.go index 6809088c53..1970a0e173 100644 --- a/inputreader_default.go +++ b/inputreader_default.go @@ -7,7 +7,7 @@ import ( "io" ) -// newInputReader returns a allbackInputReader that satisfies the inputReader +// newInputReader returns a fallbackInputReader that satisfies the inputReader // but does not actually support cancelation. func newInputReader(reader io.Reader) (inputReader, error) { return newFallbackInputReader(reader) From ca07af2bf2ff7a5ad4d17673de753a8ca0d6c469 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 15:56:39 -0500 Subject: [PATCH 09/13] Pin coninput to latest commit --- examples/go.sum | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/examples/go.sum b/examples/go.sum index a4d6b85d90..0e2c14b693 100644 --- a/examples/go.sum +++ b/examples/go.sum @@ -29,6 +29,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +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/fogleman/ease v0.0.0-20170301025033-8da417bf1776 h1:VRIbnDWRmAh5yBdz+J6yFMF5vso1It6vn+WmM/5l7MA= github.com/fogleman/ease v0.0.0-20170301025033-8da417bf1776/go.mod h1:9wvnDu3YOfxzWM9Cst40msBF1C2UdQgDv962oTxSuMs= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= @@ -88,8 +90,9 @@ golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44 h1:Bli41pIlzTzf3KEY06n+xnzK/BESIg2ze4Pgfh/aI8c= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 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= From 9ac9852ace420c0049b02568c375bf2b1c8a4df9 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 21:10:47 -0500 Subject: [PATCH 10/13] Fix key test --- key_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/key_test.go b/key_test.go index ecb551c124..32b8d47b60 100644 --- a/key_test.go +++ b/key_test.go @@ -58,7 +58,7 @@ func TestReadInput(t *testing.T) { "shift+tab": {'\x1b', '[', 'Z'}, } { t.Run(out, func(t *testing.T) { - msg, err := readInput(bytes.NewReader(in)) + msg, err := parseInputMsgFromReader(bytes.NewReader(in)) if err != nil { t.Fatalf("unexpected error: %v", err) } From 2a39c1b724e7b08a5c37d74fe8d82aba5c97ac9d Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 21:17:44 -0500 Subject: [PATCH 11/13] Remove back-to-back programs in textinput example --- examples/textinput/main.go | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/examples/textinput/main.go b/examples/textinput/main.go index 212caafc04..64e55702f3 100644 --- a/examples/textinput/main.go +++ b/examples/textinput/main.go @@ -5,25 +5,17 @@ package main import ( "fmt" - "log" + "os" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) func main() { - p := tea.NewProgram(initialModel()) - - if err := p.Start(); err != nil { - log.Fatal(err) + if err := tea.NewProgram(newModel()).Start(); err != nil { + fmt.Println("Oh no, we encountered an error:", err) + os.Exit(1) } - - p = tea.NewProgram(initialModel()) - - if err := p.Start(); err != nil { - log.Fatal(err) - } - } type tickMsg struct{} @@ -34,7 +26,7 @@ type model struct { err error } -func initialModel() model { +func newModel() model { ti := textinput.NewModel() ti.Placeholder = "Pikachu" ti.Focus() From 85f59fa2db06ad0067c7d6de0b83a121f82e8395 Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 21:49:00 -0500 Subject: [PATCH 12/13] Add window resize example --- examples/resize/main.go | 75 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 examples/resize/main.go diff --git a/examples/resize/main.go b/examples/resize/main.go new file mode 100644 index 0000000000..db8fff1e8a --- /dev/null +++ b/examples/resize/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "os" + "strconv" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +var ( + windowStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.AdaptiveColor{Light: "#874BFD", Dark: "#7D56F4"}). + Align(lipgloss.Center) + + keywordStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F25D94")) +) + +type model struct { + width, height int +} + +func (m model) Init() tea.Cmd { + return nil +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + // The window dimensions are sent to update when the program first + // starts as well as after a resize. + m.width, m.height = msg.Width, msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + default: + return m, nil + } + default: + return m, nil + } +} + +func (m model) View() string { + if m.width == 0 || m.height == 0 { + return "Waiting for dimensions..." + } + + s := fmt.Sprintf( + "Window is %s x %s cells.\n\nResize to update. Press q to exit.", + keywordStyle.Render(strconv.Itoa(m.width)), + keywordStyle.Render(strconv.Itoa(m.height)), + ) + s = strings.Repeat("\n", m.height/2-lipgloss.Height(s)) + s + + return windowStyle.Copy(). + Width(m.width - windowStyle.GetHorizontalBorderSize()). + Height(m.height - windowStyle.GetVerticalBorderSize()). + Render(s) +} + +func main() { + p := tea.NewProgram(model{}, tea.WithAltScreen()) + if err := p.Start(); err != nil { + fmt.Println("Error running program:", err) + os.Exit(1) + } +} From b7ad5fcd81e0a022f7867268111c95e0474e77af Mon Sep 17 00:00:00 2001 From: Christian Rocha Date: Sun, 19 Dec 2021 21:54:24 -0500 Subject: [PATCH 13/13] Fix tutorial builds --- tutorials/go.mod | 2 +- tutorials/go.sum | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tutorials/go.mod b/tutorials/go.mod index e09ff52c81..21914be6b1 100644 --- a/tutorials/go.mod +++ b/tutorials/go.mod @@ -2,6 +2,6 @@ module tutorial go 1.14 -require github.com/charmbracelet/bubbletea v0.17.0 +require github.com/charmbracelet/bubbletea v0.19.2 replace github.com/charmbracelet/bubbletea => ../ diff --git a/tutorials/go.sum b/tutorials/go.sum index db989e1098..1e3c296f4f 100644 --- a/tutorials/go.sum +++ b/tutorials/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.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= @@ -20,7 +22,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=