From e086d98172211545c67d0d6f457689e0ad1b1fd6 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 9 Jan 2024 17:20:23 -0500 Subject: [PATCH] feat: reduce console/term dependencies Replace mattn/isatty and containerd/console with golang.org/x/term. This mostly affects Windows. On Windows, unlike Unix, the console (TTY) has different handles for input/output. Using the Console API, we need to enable VT input on the input handle (CONIN) and VT processing on the output handle (CONOUT). Doing so enables processing VT sequences on Windows i.e. ANSI colors, mouse sequences, cursor movements, etc. We already handle enabling VT processing for the program output using Termenv `EnableVirtualTerminalProcessing`. For the input side, we enable VT input right before setting the console to raw. By doing this, we can drop both containerd/console and mattn/isatty. --- go.mod | 5 ++--- go.sum | 3 --- tea.go | 22 +++++++--------------- tty.go | 28 +++++++++++----------------- tty_unix.go | 29 ++++++++--------------------- tty_windows.go | 47 +++++++++++++++++++++++------------------------ 6 files changed, 51 insertions(+), 83 deletions(-) diff --git a/go.mod b/go.mod index bc7c9e8..9eef2fa 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,21 @@ module github.com/charmbracelet/bubbletea go 1.17 require ( - github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 - 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 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.5.0 + golang.org/x/sys v0.9.0 golang.org/x/term v0.9.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/rivo/uniseg v0.2.0 // indirect - golang.org/x/sys v0.9.0 // indirect golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 549d542..a268ef3 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 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/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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= @@ -41,7 +39,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc 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= -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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= diff --git a/tea.go b/tea.go index f18cb87..34f49d6 100644 --- a/tea.go +++ b/tea.go @@ -21,11 +21,10 @@ import ( "sync/atomic" "syscall" - "github.com/containerd/console" - isatty "github.com/mattn/go-isatty" "github.com/muesli/cancelreader" "github.com/muesli/termenv" "golang.org/x/sync/errgroup" + "golang.org/x/term" ) // ErrProgramKilled is returned by [Program.Run] when the program got killed. @@ -147,24 +146,17 @@ type Program struct { renderer renderer // where to read inputs from, this will usually be os.Stdin. - input io.Reader + input io.Reader + // tty is null if input is not a TTY. + tty *os.File + ttyState *term.State cancelReader cancelreader.CancelReader readLoopDone chan struct{} - console console.Console // was the altscreen active before releasing the terminal? altScreenWasActive bool ignoreSignals uint32 - // Stores the original reference to stdin for cases where input is not a - // TTY on windows and we've automatically opened CONIN$ to receive input. - // When the program exits this will be restored. - // - // Lint ignore note: the linter will find false positive on unix systems - // as this value only comes into play on Windows, hence the ignore comment - // below. - windowsStdin *os.File //nolint:golint,structcheck,unused - filter func(Model, Msg) Msg // fps is the frames per second we should set on the renderer, if @@ -254,7 +246,7 @@ func (p *Program) handleSignals() chan struct{} { func (p *Program) handleResize() chan struct{} { ch := make(chan struct{}) - if f, ok := p.output.TTY().(*os.File); ok && isatty.IsTerminal(f.Fd()) { + if f, ok := p.output.TTY().(*os.File); ok && term.IsTerminal(int(f.Fd())) { // Get the initial terminal size and send it to the program. go p.checkResize() @@ -440,7 +432,7 @@ func (p *Program) Run() (Model, error) { if !isFile { break } - if isatty.IsTerminal(f.Fd()) { + if term.IsTerminal(int(f.Fd())) { break } diff --git a/tty.go b/tty.go index 01f084d..f4a295a 100644 --- a/tty.go +++ b/tty.go @@ -7,25 +7,16 @@ import ( "os" "time" - isatty "github.com/mattn/go-isatty" localereader "github.com/mattn/go-localereader" "github.com/muesli/cancelreader" "golang.org/x/term" ) func (p *Program) initTerminal() error { - err := p.initInput() - if err != nil { + if err := p.initInput(); err != nil { return err } - if p.console != nil { - err = p.console.SetRaw() - if err != nil { - return fmt.Errorf("error entering raw mode: %w", err) - } - } - p.renderer.hideCursor() return nil } @@ -45,14 +36,17 @@ func (p *Program) restoreTerminalState() error { } } - if p.console != nil { - err := p.console.Reset() - if err != nil { - return fmt.Errorf("error restoring terminal state: %w", err) + return p.restoreInput() +} + +// restoreInput restores the tty input to its original state. +func (p *Program) restoreInput() error { + if p.tty != nil && p.ttyState != nil { + if err := term.Restore(int(p.tty.Fd()), p.ttyState); err != nil { + return fmt.Errorf("error restoring console: %w", err) } } - - return p.restoreInput() + return nil } // initCancelReader (re)commences reading inputs. @@ -97,7 +91,7 @@ func (p *Program) waitForReadLoop() { // via a WindowSizeMsg. func (p *Program) checkResize() { f, ok := p.output.TTY().(*os.File) - if !ok || !isatty.IsTerminal(f.Fd()) { + if !ok || !term.IsTerminal(int(f.Fd())) { // can't query window size return } diff --git a/tty_unix.go b/tty_unix.go index a3a25b8..342e759 100644 --- a/tty_unix.go +++ b/tty_unix.go @@ -7,32 +7,19 @@ import ( "fmt" "os" - "github.com/containerd/console" + "golang.org/x/term" ) -func (p *Program) initInput() error { - // If input's a file, use console to manage it - if f, ok := p.input.(*os.File); ok { - c, err := console.ConsoleFromFile(f) +func (p *Program) initInput() (err error) { + // Check if input is a terminal + if f, ok := p.input.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + p.tty = f + p.ttyState, err = term.MakeRaw(int(p.tty.Fd())) if err != nil { - return nil //nolint:nilerr // ignore error, this was just a test - } - p.console = c - } - - return nil -} - -// On unix systems, RestoreInput closes any TTYs we opened for input. Note that -// we don't do this on Windows as it causes the prompt to not be drawn until -// the terminal receives a keypress rather than appearing promptly after the -// program exits. -func (p *Program) restoreInput() error { - if p.console != nil { - if err := p.console.Reset(); err != nil { - return fmt.Errorf("error restoring console: %w", err) + return fmt.Errorf("error entering raw mode: %w", err) } } + return nil } diff --git a/tty_windows.go b/tty_windows.go index be415ae..2703624 100644 --- a/tty_windows.go +++ b/tty_windows.go @@ -4,37 +4,36 @@ package tea import ( + "fmt" "os" - "github.com/containerd/console" + "golang.org/x/sys/windows" + "golang.org/x/term" ) -func (p *Program) initInput() error { - // If input's a file, use console to manage it - if f, ok := p.input.(*os.File); ok { - // Save a reference to the current stdin then replace stdin with our - // input. We do this so we can hand input off to containerd/console to - // set raw mode, and do it in this fashion because the method - // console.ConsoleFromFile isn't supported on Windows. - p.windowsStdin = os.Stdin - os.Stdin = f +func (p *Program) initInput() (err error) { + // Save stdin state and enable VT input + // We enable VT processing using Termenv, but we also need to enable VT + // input here. + if f, ok := p.input.(*os.File); ok && term.IsTerminal(int(f.Fd())) { + p.tty = f + p.ttyState, err = term.MakeRaw(int(p.tty.Fd())) + if err != nil { + return err + } - // Note: this will panic if it fails. - c := console.Current() - p.console = c + // Enable VT input + var mode uint32 + if err := windows.GetConsoleMode(windows.Handle(p.tty.Fd()), &mode); err != nil { + return fmt.Errorf("error getting console mode: %w", err) + } + + if err := windows.SetConsoleMode(windows.Handle(p.tty.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT); err != nil { + return fmt.Errorf("error setting console mode: %w", err) + } } - return nil -} - -// restoreInput restores stdout in the event that we placed it aside to handle -// input with CONIN$, above. -func (p *Program) restoreInput() error { - if p.windowsStdin != nil { - os.Stdin = p.windowsStdin - } - - return nil + return } // Open the Windows equivalent of a TTY.