diff --git a/examples/pipe/main.go b/examples/pipe/main.go new file mode 100644 index 0000000..0ca240c --- /dev/null +++ b/examples/pipe/main.go @@ -0,0 +1,94 @@ +package main + +// An example of how to pipe in data to a Bubble Tea application. It's actually +// more of a proof that Bubble Tea will automatically listen for keystrokes +// when input is not a TTY, such as when data is piped or redirected in. +// +// In the case of this example we're listing for a single keystroke used to +// exit the program. + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +func main() { + stat, err := os.Stdin.Stat() + if err != nil { + panic(err) + } + + if stat.Mode()&os.ModeNamedPipe == 0 && stat.Size() == 0 { + fmt.Println("Try piping in some text.") + os.Exit(1) + } + + reader := bufio.NewReader(os.Stdin) + var b strings.Builder + + for { + r, _, err := reader.ReadRune() + if err != nil && err == io.EOF { + break + } + _, err = b.WriteRune(r) + if err != nil { + fmt.Println("Error getting input:", err) + os.Exit(1) + } + } + + model := newModel(strings.TrimSpace(b.String())) + + if err := tea.NewProgram(model).Start(); err != nil { + fmt.Println("Couldn't start program:", err) + os.Exit(1) + } +} + +type model struct { + userInput textinput.Model +} + +func newModel(initialValue string) (m model) { + i := textinput.NewModel() + i.Prompt = "" + i.CursorColor = "63" + i.Width = 48 + i.SetValue(initialValue) + i.CursorEnd() + i.Focus() + + m.userInput = i + return +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if key, ok := msg.(tea.KeyMsg); ok { + switch key.Type { + case tea.KeyCtrlC, tea.KeyEscape, tea.KeyEnter: + return m, tea.Quit + } + } + + var cmd tea.Cmd + m.userInput, cmd = m.userInput.Update(msg) + return m, cmd +} + +func (m model) View() string { + return fmt.Sprintf( + "\nYou piped in: %s\n\nPress ^C to exit", + m.userInput.View(), + ) +} diff --git a/tea.go b/tea.go index 1ac6504..7da8e09 100644 --- a/tea.go +++ b/tea.go @@ -19,6 +19,7 @@ import ( "sync" "syscall" + "github.com/containerd/console" isatty "github.com/mattn/go-isatty" te "github.com/muesli/termenv" "golang.org/x/crypto/ssh/terminal" @@ -83,6 +84,7 @@ func WithOutput(output *os.File) ProgramOption { func WithInput(input io.Reader) ProgramOption { return func(m *Program) { m.input = input + m.inputStatus = customInput } } @@ -96,6 +98,25 @@ func WithoutCatchPanics() ProgramOption { } } +// inputStatus indicates the current state of the input. By default, input is +// stdin, however we'll change this if input's not a TTY. The user can also set +// the input. +type inputStatus int + +const ( + defaultInput = iota // generally, this will be stdin + customInput // the user explicitly set the input + managedInput // we've opened a TTY for input +) + +func (i inputStatus) String() string { + return [...]string{ + "default input", + "custom input", + "managed input", + }[i] +} + // Program is a terminal user interface. type Program struct { initialModel Model @@ -113,8 +134,15 @@ type Program struct { // is on by default. CatchPanics bool + inputStatus inputStatus inputIsTTY bool outputIsTTY bool + console console.Console + + // 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. + windowsStdin *os.File } // Quit is a special command that tells the Bubble Tea program to exit. @@ -195,6 +223,19 @@ func (p *Program) Start() error { p.inputIsTTY = isatty.IsTerminal(f.Fd()) } + // If input is not a terminal, and the user hasn't set a custom input, open + // a TTY so we can capture input as normal. This will allow things to "just + // work" in cases where data was piped or redirected into this application. + if !p.inputIsTTY && p.inputStatus != customInput { + f, err := openInputTTY() + if err != nil { + return err + } + p.input = f + p.inputIsTTY = true + p.inputStatus = managedInput + } + // Listen for SIGINT. Note that in most cases ^C will not send an // interrupt because the terminal will be in raw mode and thus capture // that keystroke and send it along to Program.Update. If input is not a diff --git a/tty.go b/tty.go index ac8999d..fe4fc02 100644 --- a/tty.go +++ b/tty.go @@ -1,18 +1,22 @@ package tea import ( - "github.com/containerd/console" + "errors" ) -var tty console.Console +var errInputIsNotAFile = errors.New("input is not a file") -func (p Program) initTerminal() error { - if p.outputIsTTY { - tty = console.Current() +func (p *Program) initTerminal() error { + err := p.initInput() + if err != nil { + return err } if p.inputIsTTY { - err := tty.SetRaw() + if p.console == nil { + return errors.New("no console") + } + err = p.console.SetRaw() if err != nil { return err } @@ -27,9 +31,21 @@ func (p Program) initTerminal() error { } func (p Program) restoreTerminal() error { - if !p.outputIsTTY { - return nil + if p.outputIsTTY { + showCursor(p.output) } - showCursor(p.output) - return tty.Reset() + + if err := p.restoreInput(); err != nil { + return err + } + + // Console will only be set if input is a TTY. + if p.console != nil { + err := p.console.Reset() + if err != nil { + return err + } + } + + return nil } diff --git a/tty_unix.go b/tty_unix.go index 4bfa843..1fc7091 100644 --- a/tty_unix.go +++ b/tty_unix.go @@ -2,7 +2,59 @@ package tea -import "io" +import ( + "errors" + "io" + "os" + + "github.com/containerd/console" +) + +func (p *Program) initInput() error { + if !p.inputIsTTY { + return nil + } + + // If input's a TTY this should always succeed. + f, ok := p.input.(*os.File) + if !ok { + return errInputIsNotAFile + } + + c, err := console.ConsoleFromFile(f) + if err != nil { + return nil + } + 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.inputStatus == managedInput { + f, ok := p.input.(*os.File) + if !ok { + return errors.New("could not close input") + } + err := f.Close() + if err != nil { + return err + } + } + return nil +} + +func openInputTTY() (*os.File, error) { + f, err := os.Open("/dev/tty") + if err != nil { + return nil, err + } + return f, nil +} // enableAnsiColors is only needed for Windows, so for other systems this is // a no-op. diff --git a/tty_windows.go b/tty_windows.go index cfb0351..d255f3f 100644 --- a/tty_windows.go +++ b/tty_windows.go @@ -6,9 +6,55 @@ import ( "io" "os" + "github.com/containerd/console" "golang.org/x/sys/windows" ) +func (p *Program) initInput() error { + if !p.inputIsTTY { + return nil + } + + // If input's a TTY this should always succeed. + f, ok := p.input.(*os.File) + if !ok { + return errInputIsNotAFile + } + + if p.inputStatus == managedInput { + // 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 + } + + // Note: this will panic if it fails. + c := console.Current() + p.console = c + + 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 +} + +func openInputTTY() (*os.File, error) { + f, err := os.OpenFile("CONIN$", os.O_RDWR, 0644) + if err != nil { + return nil, err + } + return f, nil +} + // enableAnsiColors enables support for ANSI color sequences in Windows // default console. Note that this only works with Windows 10. func enableAnsiColors(w io.Writer) { @@ -20,6 +66,6 @@ func enableAnsiColors(w io.Writer) { stdout := windows.Handle(f.Fd()) var originalMode uint32 - windows.GetConsoleMode(stdout, &originalMode) - windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) + _ = windows.GetConsoleMode(stdout, &originalMode) + _ = windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) }