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.
This commit is contained in:
Ayman Bagabas 2024-01-09 17:20:23 -05:00
parent cb1a1d79ea
commit e086d98172
6 changed files with 51 additions and 83 deletions

5
go.mod
View File

@ -3,22 +3,21 @@ module github.com/charmbracelet/bubbletea
go 1.17 go 1.17
require ( 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/mattn/go-localereader v0.0.1
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b
github.com/muesli/cancelreader v0.2.2 github.com/muesli/cancelreader v0.2.2
github.com/muesli/reflow v0.3.0 github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.15.2 github.com/muesli/termenv v0.15.2
golang.org/x/sync v0.5.0 golang.org/x/sync v0.5.0
golang.org/x/sys v0.9.0
golang.org/x/term v0.9.0 golang.org/x/term v0.9.0
) )
require ( require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // 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/mattn/go-runewidth v0.0.14 // indirect
github.com/rivo/uniseg v0.2.0 // 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 golang.org/x/text v0.3.8 // indirect
) )

3
go.sum
View File

@ -1,7 +1,5 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 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/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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 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= 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-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-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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.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= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s=

20
tea.go
View File

@ -21,11 +21,10 @@ import (
"sync/atomic" "sync/atomic"
"syscall" "syscall"
"github.com/containerd/console"
isatty "github.com/mattn/go-isatty"
"github.com/muesli/cancelreader" "github.com/muesli/cancelreader"
"github.com/muesli/termenv" "github.com/muesli/termenv"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/term"
) )
// ErrProgramKilled is returned by [Program.Run] when the program got killed. // ErrProgramKilled is returned by [Program.Run] when the program got killed.
@ -148,23 +147,16 @@ type Program struct {
// where to read inputs from, this will usually be os.Stdin. // 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 cancelReader cancelreader.CancelReader
readLoopDone chan struct{} readLoopDone chan struct{}
console console.Console
// was the altscreen active before releasing the terminal? // was the altscreen active before releasing the terminal?
altScreenWasActive bool altScreenWasActive bool
ignoreSignals uint32 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 filter func(Model, Msg) Msg
// fps is the frames per second we should set on the renderer, if // 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{} { func (p *Program) handleResize() chan struct{} {
ch := make(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. // Get the initial terminal size and send it to the program.
go p.checkResize() go p.checkResize()
@ -440,7 +432,7 @@ func (p *Program) Run() (Model, error) {
if !isFile { if !isFile {
break break
} }
if isatty.IsTerminal(f.Fd()) { if term.IsTerminal(int(f.Fd())) {
break break
} }

30
tty.go
View File

@ -7,25 +7,16 @@ import (
"os" "os"
"time" "time"
isatty "github.com/mattn/go-isatty"
localereader "github.com/mattn/go-localereader" localereader "github.com/mattn/go-localereader"
"github.com/muesli/cancelreader" "github.com/muesli/cancelreader"
"golang.org/x/term" "golang.org/x/term"
) )
func (p *Program) initTerminal() error { func (p *Program) initTerminal() error {
err := p.initInput() if err := p.initInput(); err != nil {
if err != nil {
return err 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() p.renderer.hideCursor()
return nil return nil
} }
@ -45,16 +36,19 @@ 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() 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 nil
}
// initCancelReader (re)commences reading inputs. // initCancelReader (re)commences reading inputs.
func (p *Program) initCancelReader() error { func (p *Program) initCancelReader() error {
var err error var err error
@ -97,7 +91,7 @@ func (p *Program) waitForReadLoop() {
// via a WindowSizeMsg. // via a WindowSizeMsg.
func (p *Program) checkResize() { func (p *Program) checkResize() {
f, ok := p.output.TTY().(*os.File) 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 // can't query window size
return return
} }

View File

@ -7,35 +7,22 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/containerd/console" "golang.org/x/term"
) )
func (p *Program) initInput() error { func (p *Program) initInput() (err error) {
// If input's a file, use console to manage it // Check if input is a terminal
if f, ok := p.input.(*os.File); ok { if f, ok := p.input.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
c, err := console.ConsoleFromFile(f) p.tty = f
p.ttyState, err = term.MakeRaw(int(p.tty.Fd()))
if err != nil { if err != nil {
return nil //nolint:nilerr // ignore error, this was just a test return fmt.Errorf("error entering raw mode: %w", err)
} }
p.console = c
} }
return nil 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 nil
}
func openInputTTY() (*os.File, error) { func openInputTTY() (*os.File, error) {
f, err := os.Open("/dev/tty") f, err := os.Open("/dev/tty")
if err != nil { if err != nil {

View File

@ -4,37 +4,36 @@
package tea package tea
import ( import (
"fmt"
"os" "os"
"github.com/containerd/console" "golang.org/x/sys/windows"
"golang.org/x/term"
) )
func (p *Program) initInput() error { func (p *Program) initInput() (err error) {
// If input's a file, use console to manage it // Save stdin state and enable VT input
if f, ok := p.input.(*os.File); ok { // We enable VT processing using Termenv, but we also need to enable VT
// Save a reference to the current stdin then replace stdin with our // input here.
// input. We do this so we can hand input off to containerd/console to if f, ok := p.input.(*os.File); ok && term.IsTerminal(int(f.Fd())) {
// set raw mode, and do it in this fashion because the method p.tty = f
// console.ConsoleFromFile isn't supported on Windows. p.ttyState, err = term.MakeRaw(int(p.tty.Fd()))
p.windowsStdin = os.Stdin if err != nil {
os.Stdin = f return err
// Note: this will panic if it fails.
c := console.Current()
p.console = c
} }
return nil // Enable VT input
} var mode uint32
if err := windows.GetConsoleMode(windows.Handle(p.tty.Fd()), &mode); err != nil {
// restoreInput restores stdout in the event that we placed it aside to handle return fmt.Errorf("error getting console mode: %w", err)
// input with CONIN$, above.
func (p *Program) restoreInput() error {
if p.windowsStdin != nil {
os.Stdin = p.windowsStdin
} }
return nil 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
} }
// Open the Windows equivalent of a TTY. // Open the Windows equivalent of a TTY.