Skip to content

Commit

Permalink
feat: support windows console input buffer
Browse files Browse the repository at this point in the history
This adds support to the Windows Console Input Buffer API which access
the console API directly without the need for virtual terminal input
(i.e. the current mode that emulates unix inputs).
Since this uses the console input api, we can finally read window size
events.

This is mearly based on the awesome work of @erikgeiser in #140.

Fixes: #538
Fixes: #121
  • Loading branch information
aymanbagabas committed Dec 4, 2023
1 parent bc1c475 commit f7f0e43
Show file tree
Hide file tree
Showing 12 changed files with 433 additions and 6 deletions.
1 change: 1 addition & 0 deletions examples/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ require (
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
Expand Down
3 changes: 3 additions & 0 deletions examples/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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=
Expand Down Expand Up @@ -101,6 +103,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ go 1.17

require (
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f
github.com/mattn/go-isatty v0.0.18
github.com/mattn/go-localereader v0.0.1
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b
github.com/muesli/cancelreader v0.2.2
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.15.2
golang.org/x/sync v0.1.0
golang.org/x/sys v0.7.0
golang.org/x/term v0.6.0
)

Expand All @@ -19,6 +21,5 @@ require (
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
golang.org/x/sys v0.7.0 // indirect
golang.org/x/text v0.3.8 // indirect
)
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
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.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
Expand Down Expand Up @@ -37,6 +39,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
15 changes: 15 additions & 0 deletions inputreader_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build !windows

Check failure on line 1 in inputreader_other.go

View workflow job for this annotation

GitHub Actions / lint

File is not `goimports`-ed (goimports)

Check failure on line 1 in inputreader_other.go

View workflow job for this annotation

GitHub Actions / lint

File is not `goimports`-ed (goimports)
// +build !windows

package tea

import (
"io"

"github.com/muesli/cancelreader"
)

func newInputReader(r io.Reader) (cancelreader.CancelReader, bool, error) {
c, err := cancelreader.NewReader(r)
return c, false, err

Check failure on line 14 in inputreader_other.go

View workflow job for this annotation

GitHub Actions / lint-soft

error returned from external package is unwrapped: sig: func github.com/muesli/cancelreader.NewReader(reader io.Reader) (github.com/muesli/cancelreader.CancelReader, error) (wrapcheck)

Check failure on line 14 in inputreader_other.go

View workflow job for this annotation

GitHub Actions / lint-soft

error returned from external package is unwrapped: sig: func github.com/muesli/cancelreader.NewReader(reader io.Reader) (github.com/muesli/cancelreader.CancelReader, error) (wrapcheck)
}
162 changes: 162 additions & 0 deletions inputreader_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//go:build windows
// +build windows

package tea

import (
"fmt"
"io"
"os"
"sync"

"github.com/erikgeiser/coninput"
"github.com/muesli/cancelreader"
"golang.org/x/sys/windows"
)

type conInputReader struct {
cancelMixin

conin windows.Handle
cancelEvent windows.Handle

originalMode uint32

// 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
}

var _ cancelreader.CancelReader = &conInputReader{}

func newInputReader(r io.Reader) (cancelreader.CancelReader, bool, error) {
fallback := func(io.Reader) (cancelreader.CancelReader, bool, error) {
c, err := cancelreader.NewReader(r)
return c, false, err
}
if f, ok := r.(*os.File); !ok || f.Fd() != os.Stdin.Fd() {
return fallback(r)
}

conin, err := coninput.NewStdinHandle()
if err != nil {
return fallback(r)
}

originalMode, err := prepareConsole(conin,
windows.ENABLE_MOUSE_INPUT,
windows.ENABLE_WINDOW_INPUT,
windows.ENABLE_PROCESSED_INPUT,
windows.ENABLE_EXTENDED_FLAGS,
)
if err != nil {
return nil, false, fmt.Errorf("failed to prepare console input: %w", err)
}

cancelEvent, err := windows.CreateEvent(nil, 0, 0, nil)
if err != nil {
return nil, false, fmt.Errorf("create stop event: %w", err)
}

return &conInputReader{
conin: conin,
cancelEvent: cancelEvent,
originalMode: originalMode,
}, true, nil
}

// Cancel implements cancelreader.CancelReader.
func (r *conInputReader) Cancel() bool {
r.setCanceled()

err := windows.SetEvent(r.cancelEvent)
if err != nil {
return false
}

return true
}

// Close implements cancelreader.CancelReader.
func (r *conInputReader) Close() error {
err := windows.CloseHandle(r.cancelEvent)
if err != nil {
return fmt.Errorf("closing cancel event handle: %w", err)
}

if r.originalMode != 0 {
err := windows.SetConsoleMode(r.conin, r.originalMode)
if err != nil {
return fmt.Errorf("reset console mode: %w", err)
}
}

return nil
}

// Read implements cancelreader.CancelReader.
func (*conInputReader) Read(_ []byte) (n int, err error) {
return 0, 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
}

func waitForInput(conin, cancel windows.Handle) error {
event, err := windows.WaitForMultipleObjects([]windows.Handle{conin, cancel}, false, windows.INFINITE)
switch {
case windows.WAIT_OBJECT_0 <= event && event < windows.WAIT_OBJECT_0+2:
if event == windows.WAIT_OBJECT_0+1 {
return cancelreader.ErrCanceled
}

if event == windows.WAIT_OBJECT_0 {
return nil
}

return fmt.Errorf("unexpected wait object is ready: %d", event-windows.WAIT_OBJECT_0)
case windows.WAIT_ABANDONED <= event && event < windows.WAIT_ABANDONED+2:
return fmt.Errorf("abandoned")
case event == uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("timeout")
case event == windows.WAIT_FAILED:
return fmt.Errorf("failed")
default:
return fmt.Errorf("unexpected error: %w", error(err))
}
}

// cancelMixin represents a goroutine-safe cancelation status.
type cancelMixin struct {
unsafeCanceled bool
lock sync.Mutex
}

func (c *cancelMixin) isCanceled() bool {
c.lock.Lock()
defer c.lock.Unlock()

return c.unsafeCanceled
}

func (c *cancelMixin) setCanceled() {
c.lock.Lock()
defer c.lock.Unlock()

c.unsafeCanceled = true
}
4 changes: 2 additions & 2 deletions key.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,9 +538,9 @@ func (u unknownCSISequenceMsg) String() string {

var spaceRunes = []rune{' '}

// readInputs reads keypress and mouse inputs from a TTY and produces messages
// readAnsiInputs reads keypress and mouse inputs from a TTY and produces messages
// containing information about the key or mouse events accordingly.
func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
func readAnsiInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
var buf [256]byte

var leftOverFromPrevIteration []byte
Expand Down
13 changes: 13 additions & 0 deletions key_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//go:build !windows

Check failure on line 1 in key_other.go

View workflow job for this annotation

GitHub Actions / lint

File is not `goimports`-ed (goimports)

Check failure on line 1 in key_other.go

View workflow job for this annotation

GitHub Actions / lint

File is not `goimports`-ed (goimports)
// +build !windows

package tea

import (
"context"
"io"
)

func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
return readAnsiInputs(ctx, msgs, input)
}
2 changes: 1 addition & 1 deletion key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -515,7 +515,7 @@ func testReadInputs(t *testing.T, input io.Reader) []Msg {
wg.Add(1)
go func() {
defer wg.Done()
inputErr = readInputs(ctx, msgsC, input)
inputErr = readAnsiInputs(ctx, msgsC, input)
msgsC <- nil
}()

Expand Down
Loading

0 comments on commit f7f0e43

Please sign in to comment.