Repository: charmbracelet/bubbletea
Pull request #878 (2023-12-03 23:11:53)

feat: support windows console input buffer
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: https://github.com/charmbracelet/bubbletea/issues/538
Fixes: https://github.com/charmbracelet/bubbletea/issues/121

grafviktor: @aymanbagabas could you please look through this [issue](https://github.com/charmbracelet/bubbletea/issues/923) which I raised? It's related to your changes in this PR. Maybe you can improve your patch to resolve it as well? I will be happy to help with testing / debugging.

Patch:
diff --git a/examples/go.mod b/examples/go.mod
index 155537a..abf11d2 100644
--- a/examples/go.mod
+++ b/examples/go.mod
@@ -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
diff --git a/examples/go.sum b/examples/go.sum
index 38003dc..aaaf7ba 100644
--- a/examples/go.sum
+++ b/examples/go.sum
@@ -28,6 +28,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=
@@ -80,6 +82,7 @@ golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
 golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
 golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 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-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/go.mod b/go.mod
index 04ee57b..9ca1c03 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.18
 
 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.20
 	github.com/mattn/go-localereader v0.0.1
 	github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b
@@ -11,6 +12,7 @@ require (
 	github.com/muesli/reflow v0.3.0
 	github.com/muesli/termenv v0.15.2
 	golang.org/x/sync v0.5.0
+	golang.org/x/sys v0.16.0
 	golang.org/x/term v0.16.0
 )
 
@@ -19,6 +21,5 @@ require (
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/rivo/uniseg v0.4.6 // indirect
-	golang.org/x/sys v0.16.0 // indirect
 	golang.org/x/text v0.3.8 // indirect
 )
diff --git a/go.sum b/go.sum
index 13bd01e..14c30c1 100644
--- a/go.sum
+++ b/go.sum
@@ -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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -25,6 +27,7 @@ github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
 github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
 golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
diff --git a/inputreader_other.go b/inputreader_other.go
new file mode 100644
index 0000000..8e63a87
--- /dev/null
+++ b/inputreader_other.go
@@ -0,0 +1,14 @@
+//go:build !windows
+// +build !windows
+
+package tea
+
+import (
+	"io"
+
+	"github.com/muesli/cancelreader"
+)
+
+func newInputReader(r io.Reader) (cancelreader.CancelReader, error) {
+	return cancelreader.NewReader(r)
+}
diff --git a/inputreader_windows.go b/inputreader_windows.go
new file mode 100644
index 0000000..8daa6e6
--- /dev/null
+++ b/inputreader_windows.go
@@ -0,0 +1,154 @@
+//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, error) {
+	fallback := func(io.Reader) (cancelreader.CancelReader, error) {
+		return cancelreader.NewReader(r)
+	}
+	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, fmt.Errorf("failed to prepare console input: %w", err)
+	}
+
+	cancelEvent, err := windows.CreateEvent(nil, 0, 0, nil)
+	if err != nil {
+		return nil, fmt.Errorf("create stop event: %w", err)
+	}
+
+	return &conInputReader{
+		conin:        conin,
+		cancelEvent:  cancelEvent,
+		originalMode: originalMode,
+	}, 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", err)
+	}
+}
+
+// cancelMixin represents a goroutine-safe cancelation status.
+type cancelMixin struct {
+	unsafeCanceled bool
+	lock           sync.Mutex
+}
+
+func (c *cancelMixin) setCanceled() {
+	c.lock.Lock()
+	defer c.lock.Unlock()
+
+	c.unsafeCanceled = true
+}
diff --git a/key.go b/key.go
index 05d0440..f659a06 100644
--- a/key.go
+++ b/key.go
@@ -553,9 +553,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
diff --git a/key_other.go b/key_other.go
new file mode 100644
index 0000000..b8c4608
--- /dev/null
+++ b/key_other.go
@@ -0,0 +1,13 @@
+//go:build !windows
+// +build !windows
+
+package tea
+
+import (
+	"context"
+	"io"
+)
+
+func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
+	return readAnsiInputs(ctx, msgs, input)
+}
diff --git a/key_windows.go b/key_windows.go
new file mode 100644
index 0000000..c4b8aa7
--- /dev/null
+++ b/key_windows.go
@@ -0,0 +1,277 @@
+//go:build windows
+// +build windows
+
+package tea
+
+import (
+	"context"
+	"fmt"
+	"io"
+
+	"github.com/erikgeiser/coninput"
+	localereader "github.com/mattn/go-localereader"
+	"golang.org/x/sys/windows"
+)
+
+func readInputs(ctx context.Context, msgs chan<- Msg, input io.Reader) error {
+	if coninReader, ok := input.(*conInputReader); ok {
+		return readConInputs(ctx, msgs, coninReader.conin)
+	}
+
+	return readAnsiInputs(ctx, msgs, localereader.NewReader(input))
+}
+
+func readConInputs(ctx context.Context, msgsch chan<- Msg, con windows.Handle) error {
+	var ps coninput.ButtonState // keep track of previous mouse state
+	for {
+		events, err := coninput.ReadNConsoleInputs(con, 16)
+		if err != nil {
+			return fmt.Errorf("read coninput events: %w", err)
+		}
+
+		for _, event := range events {
+			var msgs []Msg
+			switch e := event.Unwrap().(type) {
+			case coninput.KeyEventRecord:
+				if !e.KeyDown || e.VirtualKeyCode == coninput.VK_SHIFT {
+					continue
+				}
+
+				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),
+					})
+				}
+			case coninput.WindowBufferSizeEventRecord:
+				msgs = append(msgs, WindowSizeMsg{
+					Width:  int(e.Size.X),
+					Height: int(e.Size.Y),
+				})
+			case coninput.MouseEventRecord:
+				event := mouseEvent(ps, e)
+				if event.Type != MouseUnknown {
+					msgs = append(msgs, event)
+				}
+				ps = e.ButtonState
+			case coninput.FocusEventRecord, coninput.MenuEventRecord:
+				// ignore
+			default: // unknown event
+				continue
+			}
+
+			// Send all messages to the channel
+			for _, msg := range msgs {
+				select {
+				case msgsch <- msg:
+				case <-ctx.Done():
+					err := ctx.Err()
+					if err != nil {
+						return fmt.Errorf("coninput context error: %w", err)
+					}
+					return err
+				}
+			}
+		}
+	}
+}
+
+func mouseEventButton(p, s coninput.ButtonState) (button MouseButton, action MouseAction) {
+	btn := p ^ s
+	action = MouseActionPress
+	if btn&s == 0 {
+		action = MouseActionRelease
+	}
+
+	if btn == 0 {
+		switch {
+		case s&coninput.FROM_LEFT_1ST_BUTTON_PRESSED > 0:
+			button = MouseButtonLeft
+		case s&coninput.FROM_LEFT_2ND_BUTTON_PRESSED > 0:
+			button = MouseButtonMiddle
+		case s&coninput.RIGHTMOST_BUTTON_PRESSED > 0:
+			button = MouseButtonRight
+		case s&coninput.FROM_LEFT_3RD_BUTTON_PRESSED > 0:
+			button = MouseButtonBackward
+		case s&coninput.FROM_LEFT_4TH_BUTTON_PRESSED > 0:
+			button = MouseButtonForward
+		}
+		return
+	}
+
+	switch {
+	case btn == coninput.FROM_LEFT_1ST_BUTTON_PRESSED: // left button
+		button = MouseButtonLeft
+	case btn == coninput.RIGHTMOST_BUTTON_PRESSED: // right button
+		button = MouseButtonRight
+	case btn == coninput.FROM_LEFT_2ND_BUTTON_PRESSED: // middle button
+		button = MouseButtonMiddle
+	case btn == coninput.FROM_LEFT_3RD_BUTTON_PRESSED: // unknown (possibly mouse backward)
+		button = MouseButtonBackward
+	case btn == coninput.FROM_LEFT_4TH_BUTTON_PRESSED: // unknown (possibly mouse forward)
+		button = MouseButtonForward
+	}
+
+	return button, action
+}
+
+func mouseEvent(p coninput.ButtonState, e coninput.MouseEventRecord) MouseMsg {
+	ev := MouseMsg{
+		X:     int(e.MousePositon.X),
+		Y:     int(e.MousePositon.Y),
+		Alt:   e.ControlKeyState.Contains(coninput.LEFT_ALT_PRESSED | coninput.RIGHT_ALT_PRESSED),
+		Ctrl:  e.ControlKeyState.Contains(coninput.LEFT_CTRL_PRESSED | coninput.RIGHT_CTRL_PRESSED),
+		Shift: e.ControlKeyState.Contains(coninput.SHIFT_PRESSED),
+	}
+	switch e.EventFlags {
+	case coninput.CLICK, coninput.DOUBLE_CLICK:
+		ev.Button, ev.Action = mouseEventButton(p, e.ButtonState)
+		if ev.Action == MouseActionRelease {
+			ev.Type = MouseRelease
+		}
+		switch ev.Button {
+		case MouseButtonLeft:
+			ev.Type = MouseLeft
+		case MouseButtonMiddle:
+			ev.Type = MouseMiddle
+		case MouseButtonRight:
+			ev.Type = MouseRight
+		case MouseButtonBackward:
+			ev.Type = MouseBackward
+		case MouseButtonForward:
+			ev.Type = MouseForward
+		}
+	case coninput.MOUSE_WHEELED:
+		if e.WheelDirection > 0 {
+			ev.Button = MouseButtonWheelUp
+			ev.Type = MouseWheelUp
+		} else {
+			ev.Button = MouseButtonWheelDown
+			ev.Type = MouseWheelDown
+		}
+	case coninput.MOUSE_HWHEELED:
+		if e.WheelDirection > 0 {
+			ev.Button = MouseButtonWheelRight
+			ev.Type = MouseWheelRight
+		} else {
+			ev.Button = MouseButtonWheelLeft
+			ev.Type = MouseWheelLeft
+		}
+	case coninput.MOUSE_MOVED:
+		ev.Button, _ = mouseEventButton(p, e.ButtonState)
+		ev.Action = MouseActionMotion
+		ev.Type = MouseMotion
+	}
+
+	return ev
+}
+
+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/tty.go b/tty.go
index 015720d..36d1cb5 100644
--- a/tty.go
+++ b/tty.go
@@ -8,7 +8,6 @@ import (
 	"time"
 
 	isatty "github.com/mattn/go-isatty"
-	localereader "github.com/mattn/go-localereader"
 	"github.com/muesli/cancelreader"
 	"golang.org/x/term"
 )
@@ -59,7 +58,7 @@ func (p *Program) restoreTerminalState() error {
 // initCancelReader (re)commences reading inputs.
 func (p *Program) initCancelReader() error {
 	var err error
-	p.cancelReader, err = cancelreader.NewReader(p.input)
+	p.cancelReader, err = newInputReader(p.input)
 	if err != nil {
 		return fmt.Errorf("error creating cancelreader: %w", err)
 	}
@@ -73,8 +72,7 @@ func (p *Program) initCancelReader() error {
 func (p *Program) readLoop() {
 	defer close(p.readLoopDone)
 
-	input := localereader.NewReader(p.cancelReader)
-	err := readInputs(p.ctx, p.msgs, input)
+	err := readInputs(p.ctx, p.msgs, p.cancelReader)
 	if !errors.Is(err, io.EOF) && !errors.Is(err, cancelreader.ErrCanceled) {
 		select {
 		case <-p.ctx.Done():
diff --git a/tutorials/go.mod b/tutorials/go.mod
index 546f70d..9f0e737 100644
--- a/tutorials/go.mod
+++ b/tutorials/go.mod
@@ -7,6 +7,7 @@ require github.com/charmbracelet/bubbletea v0.25.0
 require (
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
+	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.20 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
diff --git a/tutorials/go.sum b/tutorials/go.sum
index fb8aedf..3278238 100644
--- a/tutorials/go.sum
+++ b/tutorials/go.sum
@@ -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.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -25,6 +27,7 @@ github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
 github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE=
 golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=


Test patch:
diff --git a/key_test.go b/key_test.go
index 830b8cf..578faef 100644
--- a/key_test.go
+++ b/key_test.go
@@ -528,7 +528,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
 	}()