cli/pkg/iostreams/iostreams.go
Kynan Ware f283d6d11c feat(iostreams): textual progress indicator does not clear screen
This bypasses the `spinner` package for the textual progress indicator for users
with the spinner disabled out of concerns for accessibility, specifically
with screen readers:

- The `spinner` package will continuously re-draw the screen. I wasn't
able to have this cause problems with my Mac screen reader, but it's
nonetheless a concern that other screen readers may not handle this
screen re-drawing well.
- The `spinner` package clears any progress indicator messages from the screen
when stopping the progress indicator or changing its label.
This is a problem because it interrupts screen readers and leaves no way
to recover what the loading message was by scrolling up in the terminal.

NOTE: this new implementation still interrupts the screen reader when
the a new label is printed, but it does not clear the screen. This makes
the loading messages recoverable, at least.
2025-04-14 19:30:05 -06:00

594 lines
12 KiB
Go

package iostreams
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strings"
"sync"
"time"
"github.com/briandowns/spinner"
ghTerm "github.com/cli/go-gh/v2/pkg/term"
"github.com/cli/safeexec"
"github.com/google/shlex"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
)
const DefaultWidth = 80
// ErrClosedPagerPipe is the error returned when writing to a pager that has been closed.
type ErrClosedPagerPipe struct {
error
}
type fileWriter interface {
io.Writer
Fd() uintptr
}
type fileReader interface {
io.ReadCloser
Fd() uintptr
}
type term interface {
IsTerminalOutput() bool
IsColorEnabled() bool
Is256ColorSupported() bool
IsTrueColorSupported() bool
Theme() string
Size() (int, int, error)
}
type IOStreams struct {
term term
In fileReader
Out fileWriter
ErrOut fileWriter
terminalTheme string
progressIndicatorEnabled bool
progressIndicator *spinner.Spinner
progressIndicatorMu sync.Mutex
alternateScreenBufferEnabled bool
alternateScreenBufferActive bool
alternateScreenBufferMu sync.Mutex
stdinTTYOverride bool
stdinIsTTY bool
stdoutTTYOverride bool
stdoutIsTTY bool
stderrTTYOverride bool
stderrIsTTY bool
colorOverride bool
colorEnabled bool
colorLabels bool
accessibleColorsEnabled bool
pagerCommand string
pagerProcess *os.Process
neverPrompt bool
spinnerDisabled bool
TempFileOverride *os.File
}
func (s *IOStreams) ColorEnabled() bool {
if s.colorOverride {
return s.colorEnabled
}
return s.term.IsColorEnabled()
}
func (s *IOStreams) ColorSupport256() bool {
if s.colorOverride {
return s.colorEnabled
}
return s.term.Is256ColorSupported()
}
func (s *IOStreams) HasTrueColor() bool {
if s.colorOverride {
return s.colorEnabled
}
return s.term.IsTrueColorSupported()
}
func (s *IOStreams) ColorLabels() bool {
return s.colorLabels
}
// DetectTerminalTheme is a utility to call before starting the output pager so that the terminal background
// can be reliably detected.
func (s *IOStreams) DetectTerminalTheme() {
if !s.ColorEnabled() || s.pagerProcess != nil {
s.terminalTheme = "none"
return
}
style := os.Getenv("GLAMOUR_STYLE")
if style != "" && style != "auto" {
// ensure GLAMOUR_STYLE takes precedence over "light" and "dark" themes
s.terminalTheme = "none"
return
}
s.terminalTheme = s.term.Theme()
}
// TerminalTheme returns "light", "dark", or "none" depending on the background color of the terminal.
func (s *IOStreams) TerminalTheme() string {
if s.terminalTheme == "" {
s.DetectTerminalTheme()
}
return s.terminalTheme
}
func (s *IOStreams) SetColorEnabled(colorEnabled bool) {
s.colorOverride = true
s.colorEnabled = colorEnabled
}
func (s *IOStreams) SetColorLabels(colorLabels bool) {
s.colorLabels = colorLabels
}
func (s *IOStreams) SetStdinTTY(isTTY bool) {
s.stdinTTYOverride = true
s.stdinIsTTY = isTTY
}
func (s *IOStreams) IsStdinTTY() bool {
if s.stdinTTYOverride {
return s.stdinIsTTY
}
if stdin, ok := s.In.(*os.File); ok {
return isTerminal(stdin)
}
return false
}
func (s *IOStreams) SetStdoutTTY(isTTY bool) {
s.stdoutTTYOverride = true
s.stdoutIsTTY = isTTY
}
func (s *IOStreams) IsStdoutTTY() bool {
if s.stdoutTTYOverride {
return s.stdoutIsTTY
}
// support GH_FORCE_TTY
if s.term.IsTerminalOutput() {
return true
}
stdout, ok := s.Out.(*os.File)
return ok && isCygwinTerminal(stdout.Fd())
}
func (s *IOStreams) SetStderrTTY(isTTY bool) {
s.stderrTTYOverride = true
s.stderrIsTTY = isTTY
}
func (s *IOStreams) IsStderrTTY() bool {
if s.stderrTTYOverride {
return s.stderrIsTTY
}
if stderr, ok := s.ErrOut.(*os.File); ok {
return isTerminal(stderr)
}
return false
}
func (s *IOStreams) SetPager(cmd string) {
s.pagerCommand = cmd
}
func (s *IOStreams) GetPager() string {
return s.pagerCommand
}
func (s *IOStreams) StartPager() error {
if s.pagerCommand == "" || s.pagerCommand == "cat" || !s.IsStdoutTTY() {
return nil
}
pagerArgs, err := shlex.Split(s.pagerCommand)
if err != nil {
return err
}
pagerEnv := os.Environ()
for i := len(pagerEnv) - 1; i >= 0; i-- {
if strings.HasPrefix(pagerEnv[i], "PAGER=") {
pagerEnv = append(pagerEnv[0:i], pagerEnv[i+1:]...)
}
}
if _, ok := os.LookupEnv("LESS"); !ok {
pagerEnv = append(pagerEnv, "LESS=FRX")
}
if _, ok := os.LookupEnv("LV"); !ok {
pagerEnv = append(pagerEnv, "LV=-c")
}
pagerExe, err := safeexec.LookPath(pagerArgs[0])
if err != nil {
return err
}
pagerCmd := exec.Command(pagerExe, pagerArgs[1:]...)
pagerCmd.Env = pagerEnv
pagerCmd.Stdout = s.Out
pagerCmd.Stderr = s.ErrOut
pagedOut, err := pagerCmd.StdinPipe()
if err != nil {
return err
}
s.Out = &fdWriteCloser{
fd: s.Out.Fd(),
WriteCloser: &pagerWriter{pagedOut},
}
err = pagerCmd.Start()
if err != nil {
return err
}
s.pagerProcess = pagerCmd.Process
return nil
}
func (s *IOStreams) StopPager() {
if s.pagerProcess == nil {
return
}
// if a pager was started, we're guaranteed to have a WriteCloser
_ = s.Out.(io.WriteCloser).Close()
_, _ = s.pagerProcess.Wait()
s.pagerProcess = nil
}
func (s *IOStreams) CanPrompt() bool {
if s.neverPrompt {
return false
}
return s.IsStdinTTY() && s.IsStdoutTTY()
}
func (s *IOStreams) GetNeverPrompt() bool {
return s.neverPrompt
}
func (s *IOStreams) SetNeverPrompt(v bool) {
s.neverPrompt = v
}
func (s *IOStreams) GetSpinnerDisabled() bool {
return s.spinnerDisabled
}
func (s *IOStreams) SetSpinnerDisabled(v bool) {
s.spinnerDisabled = v
}
func (s *IOStreams) StartProgressIndicator() {
s.StartProgressIndicatorWithLabel("")
}
func (s *IOStreams) StartProgressIndicatorWithLabel(label string) {
if !s.progressIndicatorEnabled {
return
}
if s.spinnerDisabled {
s.startTextualProgressIndicator(label)
return
}
s.progressIndicatorMu.Lock()
defer s.progressIndicatorMu.Unlock()
if s.progressIndicator != nil {
if label == "" {
s.progressIndicator.Prefix = ""
} else {
s.progressIndicator.Prefix = label + " "
}
return
}
// https://github.com/briandowns/spinner#available-character-sets
// ⣾ ⣷ ⣽ ⣻ ⡿
spinnerStyle := spinner.CharSets[11]
sp := spinner.New(spinnerStyle, 120*time.Millisecond, spinner.WithWriter(s.ErrOut), spinner.WithColor("fgCyan"))
if label != "" {
sp.Prefix = label + " "
}
sp.Start()
s.progressIndicator = sp
}
func (s *IOStreams) startTextualProgressIndicator(label string) {
s.progressIndicatorMu.Lock()
defer s.progressIndicatorMu.Unlock()
// Default label when spinner disabled is "Working..."
if label == "" {
label = "Working..."
}
// Add an ellipsis to the label if it doesn't already have one.
ellipsis := "..."
if !strings.HasSuffix(label, ellipsis) {
label = label + ellipsis
}
fmt.Fprintf(s.ErrOut, "%s%s", s.ColorScheme().Cyan(label), "\n")
}
func (s *IOStreams) StopProgressIndicator() {
s.progressIndicatorMu.Lock()
defer s.progressIndicatorMu.Unlock()
if s.progressIndicator == nil {
return
}
s.progressIndicator.Stop()
s.progressIndicator = nil
}
func (s *IOStreams) RunWithProgress(label string, run func() error) error {
s.StartProgressIndicatorWithLabel(label)
defer s.StopProgressIndicator()
return run()
}
func (s *IOStreams) StartAlternateScreenBuffer() {
if s.alternateScreenBufferEnabled {
s.alternateScreenBufferMu.Lock()
defer s.alternateScreenBufferMu.Unlock()
if _, err := fmt.Fprint(s.Out, "\x1b[?1049h"); err == nil {
s.alternateScreenBufferActive = true
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt)
go func() {
<-ch
s.StopAlternateScreenBuffer()
os.Exit(1)
}()
}
}
}
func (s *IOStreams) StopAlternateScreenBuffer() {
s.alternateScreenBufferMu.Lock()
defer s.alternateScreenBufferMu.Unlock()
if s.alternateScreenBufferActive {
fmt.Fprint(s.Out, "\x1b[?1049l")
s.alternateScreenBufferActive = false
}
}
func (s *IOStreams) SetAlternateScreenBufferEnabled(enabled bool) {
s.alternateScreenBufferEnabled = enabled
}
func (s *IOStreams) RefreshScreen() {
if s.IsStdoutTTY() {
// Move cursor to 0,0
fmt.Fprint(s.Out, "\x1b[0;0H")
// Clear from cursor to bottom of screen
fmt.Fprint(s.Out, "\x1b[J")
}
}
// TerminalWidth returns the width of the terminal that controls the process
func (s *IOStreams) TerminalWidth() int {
w, _, err := s.term.Size()
if err == nil && w > 0 {
return w
}
return DefaultWidth
}
func (s *IOStreams) ColorScheme() *ColorScheme {
return &ColorScheme{
Enabled: s.ColorEnabled(),
EightBitColor: s.ColorSupport256(),
TrueColor: s.HasTrueColor(),
Accessible: s.AccessibleColorsEnabled(),
ColorLabels: s.ColorLabels(),
Theme: s.TerminalTheme(),
}
}
func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) {
var r io.ReadCloser
if fn == "-" {
r = s.In
} else {
var err error
r, err = os.Open(fn)
if err != nil {
return nil, err
}
}
defer r.Close()
return io.ReadAll(r)
}
func (s *IOStreams) TempFile(dir, pattern string) (*os.File, error) {
if s.TempFileOverride != nil {
return s.TempFileOverride, nil
}
return os.CreateTemp(dir, pattern)
}
func (s *IOStreams) SetAccessibleColorsEnabled(enabled bool) {
s.accessibleColorsEnabled = enabled
}
func (s *IOStreams) AccessibleColorsEnabled() bool {
return s.accessibleColorsEnabled
}
func System() *IOStreams {
terminal := ghTerm.FromEnv()
var stdout fileWriter = os.Stdout
// On Windows with no virtual terminal processing support, translate ANSI escape
// sequences to console syscalls.
if colorableStdout := colorable.NewColorable(os.Stdout); colorableStdout != os.Stdout {
// Ensure that the file descriptor of the original stdout is preserved.
stdout = &fdWriter{
fd: os.Stdout.Fd(),
Writer: colorableStdout,
}
}
var stderr fileWriter = os.Stderr
// On Windows with no virtual terminal processing support, translate ANSI escape
// sequences to console syscalls.
if colorableStderr := colorable.NewColorable(os.Stderr); colorableStderr != os.Stderr {
// Ensure that the file descriptor of the original stderr is preserved.
stderr = &fdWriter{
fd: os.Stderr.Fd(),
Writer: colorableStderr,
}
}
io := &IOStreams{
In: os.Stdin,
Out: stdout,
ErrOut: stderr,
pagerCommand: os.Getenv("PAGER"),
term: &terminal,
}
stdoutIsTTY := io.IsStdoutTTY()
stderrIsTTY := io.IsStderrTTY()
if stdoutIsTTY && stderrIsTTY {
io.progressIndicatorEnabled = true
}
if stdoutIsTTY && hasAlternateScreenBuffer(terminal.IsTrueColorSupported()) {
io.alternateScreenBufferEnabled = true
}
return io
}
type fakeTerm struct{}
func (t fakeTerm) IsTerminalOutput() bool {
return false
}
func (t fakeTerm) IsColorEnabled() bool {
return false
}
func (t fakeTerm) Is256ColorSupported() bool {
return false
}
func (t fakeTerm) IsTrueColorSupported() bool {
return false
}
func (t fakeTerm) Theme() string {
return ""
}
func (t fakeTerm) Size() (int, int, error) {
return 80, -1, nil
}
func Test() (*IOStreams, *bytes.Buffer, *bytes.Buffer, *bytes.Buffer) {
in := &bytes.Buffer{}
out := &bytes.Buffer{}
errOut := &bytes.Buffer{}
io := &IOStreams{
In: &fdReader{
fd: 0,
ReadCloser: io.NopCloser(in),
},
Out: &fdWriter{fd: 1, Writer: out},
ErrOut: &fdWriter{fd: 2, Writer: errOut},
term: &fakeTerm{},
}
io.SetStdinTTY(false)
io.SetStdoutTTY(false)
io.SetStderrTTY(false)
return io, in, out, errOut
}
func isTerminal(f *os.File) bool {
return ghTerm.IsTerminal(f) || isCygwinTerminal(f.Fd())
}
func isCygwinTerminal(fd uintptr) bool {
return isatty.IsCygwinTerminal(fd)
}
// pagerWriter implements a WriteCloser that wraps all EPIPE errors in an ErrClosedPagerPipe type.
type pagerWriter struct {
io.WriteCloser
}
func (w *pagerWriter) Write(d []byte) (int, error) {
n, err := w.WriteCloser.Write(d)
if err != nil && (errors.Is(err, io.ErrClosedPipe) || isEpipeError(err)) {
return n, &ErrClosedPagerPipe{err}
}
return n, err
}
// fdWriter represents a wrapped stdout Writer that preserves the original file descriptor
type fdWriter struct {
io.Writer
fd uintptr
}
func (w *fdWriter) Fd() uintptr {
return w.fd
}
// fdWriteCloser represents a wrapped stdout Writer that preserves the original file descriptor
type fdWriteCloser struct {
io.WriteCloser
fd uintptr
}
func (w *fdWriteCloser) Fd() uintptr {
return w.fd
}
// fdReader represents a wrapped stdin ReadCloser that preserves the original file descriptor
type fdReader struct {
io.ReadCloser
fd uintptr
}
func (r *fdReader) Fd() uintptr {
return r.fd
}