diff --git a/modules/bonfire/README.md b/modules/bonfire/README.md index 6d346f48..e52f4388 100644 --- a/modules/bonfire/README.md +++ b/modules/bonfire/README.md @@ -27,7 +27,7 @@ Use cases: - Usage strings and argument-count checks derived from the declared arguments (`` for required, `[name]` for optional). - Scalar flags, bare flags (`bonfire.Flag.Bare`, so `--force` alone stores `true`, read as a switch with `bonfire.FlagBool`, which also accepts `--force=1` and `--force=false`) and ordered repeatable flags (`bonfire.Flag.Repeatable`, read back through `bonfire.Input.Flags`). - Styled status lines: `bonfire.Output.Info`, `bonfire.Output.Success`, `bonfire.Output.Warning` and `bonfire.Output.Error` (the last one writes to the error stream). -- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command: Ctrl+C or SIGTERM during a view restores the cursor and the terminal, then stops the command as before (or reaches its own signal handler). Log lines, and output the command prints through the console that started the view, appear above a running view (`bonfire.PrintAbove`); other consoles (a `bonfire.Call` buffer, a console on a file) keep writing to their own writers. When the context given to `bonfire.Process` is cancelled, the view ends with its last frame and later lines print plainly below it. Table cells, the item names and errors of `bonfire.Process` (in the process view and in its plain lines), the process view's operation label, and failure lines are stripped of control characters (`bonfire.Sanitize`). +- Widgets on the charm stack, ported from SSU's terminal UI: box-drawn tables (`bonfire.Output.Table`, lipgloss), a spinner around a function (`bonfire.Output.Spinner`), a gradient progress bar (`bonfire.Output.Progress`, whose `bonfire.Progress` also implements `bonfire.ItemProgress`) and a multi-row process view for bulk loops (`bonfire.Process`), each a bubbletea program that keeps stdin and signals to the command: Ctrl+C or SIGTERM during a view restores the cursor and the terminal, then stops the command as before (or reaches its own signal handler). Log lines, and output the command prints through the console that started the view (and its `bonfire.Output.Error` lines when stderr is a terminal too), appear above a running view (`bonfire.PrintAbove`); other consoles (a `bonfire.Call` buffer, a console on a file) keep writing to their own writers. When the context given to `bonfire.Process` is cancelled, the view ends with its last frame and later lines print plainly below it. Table cells, the item names and errors of `bonfire.Process` (in the process view and in its plain lines), the process view's operation label, and failure lines are stripped of control characters (`bonfire.Sanitize`). - Prompts: `bonfire.Output.Ask`, `bonfire.Output.Confirm` and `bonfire.Output.Secret` use huh fields on an interactive terminal, and `bonfire.Output.Choice` an arrow-key selector with a confirmation screen. Off a terminal they read lines from stdin as before (a piped password still works), return their defaults when input ends, and `bonfire.Output.Confirm` returns its default without asking. A cancelled prompt returns `bonfire.ErrAborted`. `bonfire.ConfirmProceed` asks before a destructive step only on an interactive terminal. - Degradation: every widget follows the terminal and the global options, and the `bonfire.Output` interface is unchanged for plugins: diff --git a/modules/bonfire/live_test.go b/modules/bonfire/live_test.go index d6a89af9..e889e14c 100644 --- a/modules/bonfire/live_test.go +++ b/modules/bonfire/live_test.go @@ -743,3 +743,29 @@ func TestPrintAboveBlankLine(t *testing.T) { t.Errorf("lines inserted above the view = %q, want %q\nraw: %q", lines, want, out.String()) } } + +// TestErrorPrintsAboveView (IN-10): an Error line during a live view this +// console owns prints above the view when the console's error writer is a +// terminal (it would otherwise land mid-frame); when it is not, the line +// goes to the error writer as before. +func TestErrorPrintsAboveView(t *testing.T) { + for _, errTTY := range []bool{true, false} { + out, errW := &lockedBuffer{}, &lockedBuffer{} + c := newConsole(strings.NewReader(""), out, errW, noEnv, boolPtr(true), boolPtr(true)) + c.errTTY = errTTY + if err := c.Spinner("working", func() error { + c.Error("view-error-line") + return nil + }); err != nil { + t.Fatal(err) + } + inOut := strings.Count(ansi.Strip(out.String()), "view-error-line") + inErr := strings.Count(ansi.Strip(errW.String()), "view-error-line") + if errTTY && (inOut != 1 || inErr != 0) { + t.Errorf("terminal stderr: line %d times above the view, %d times on stderr; want 1 and 0\nout: %q\nerr: %q", inOut, inErr, out.String(), errW.String()) + } + if !errTTY && (inOut != 0 || inErr != 1) { + t.Errorf("redirected stderr: line %d times on stdout, %d times on stderr; want 0 and 1", inOut, inErr) + } + } +} diff --git a/modules/bonfire/output.go b/modules/bonfire/output.go index 8845b545..8c6901bc 100644 --- a/modules/bonfire/output.go +++ b/modules/bonfire/output.go @@ -51,6 +51,9 @@ type console struct { // tty is true when stdout is a terminal; it picks animated widgets // and boxed tables. tty bool + // errTTY is true when the error writer is a terminal: a live view on + // stdout then shares the screen with it. + errTTY bool // interactive is true when stdin and stdout are terminals. interactive bool // inTTY is true when stdin is a terminal; Secret then reads a hidden @@ -89,6 +92,7 @@ func newConsole(in io.Reader, out, errW io.Writer, lookup func(string) string, t } else { c.tty = isTerminal(out) } + c.errTTY = isTerminal(errW) c.inTTY = isTerminal(in) if interactive != nil { c.interactive = *interactive @@ -173,12 +177,14 @@ func (c *console) styled(w io.Writer) io.Writer { // emit writes s, a styled line or block, to w. While a live view this // console started runs on stdout, lines for stdout print above it instead -// of tearing it. +// of tearing it, and so do lines for the error writer when stdout and the +// error writer are both terminals (they share the screen with the view). func (c *console) emit(w io.Writer, s string) { if w == nil { return } - if w == c.out && printAbove(c, s) { + above := w == c.out || (w == c.err && c.tty && c.errTTY) + if above && printAbove(c, s) { return } _, _ = io.WriteString(c.styled(w), s) diff --git a/scripts/check-phase19.8.sh b/scripts/check-phase19.8.sh index faea0f52..8a501f7f 100755 --- a/scripts/check-phase19.8.sh +++ b/scripts/check-phase19.8.sh @@ -134,8 +134,8 @@ BONFIRE_TESTS=( # quick 261028-rqd (info findings): IN-03, IN-06, IN-02, IN-04. TestPluginErrorWordedUnknownCommand TestExecuteKeepsRootHelp TestErrorBlockUsesParsedGlobals TestFlagBool - # quick 261028-rqd: IN-11. - TestPrintAboveBlankLine + # quick 261028-rqd: IN-11, IN-10. + TestPrintAboveBlankLine TestErrorPrintsAboveView ) DOCSITE_TESTS=( # Plan 04: the docs checker reads past the global options.