diff options
Diffstat (limited to 'internal/tui/tui.go')
| -rw-r--r-- | internal/tui/tui.go | 107 |
1 files changed, 107 insertions, 0 deletions
diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..96bb728 --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package tui is krino's terminal layer: colour policy, paging a plan +// through $PAGER when it does not fit the screen, and single-key input for +// the interactive review prompt. See docs/design.md §8.2. +package tui + +import ( + "io" + "os" + "os/exec" + "strings" + + "golang.org/x/term" +) + +// isTerminal and termSize hold term.IsTerminal and term.GetSize so tests +// can replace them; that is the only way to test this package without a +// pty. +var ( + isTerminal = term.IsTerminal + termSize = term.GetSize +) + +// defaultPager is used when $PAGER is unset. +const defaultPager = "less -FRX" + +// Colour reports whether to emit ANSI colour: w is a terminal and NO_COLOR +// is unset (spec §8.2). +func Colour(w io.Writer) bool { + f, ok := w.(*os.File) + if !ok || !isTerminal(int(f.Fd())) { + return false + } + _, noColour := os.LookupEnv("NO_COLOR") + return !noColour +} + +// Height is the terminal's row count, 0 when it is not a terminal or the +// size cannot be read. +func Height(w io.Writer) int { + f, ok := w.(*os.File) + if !ok || !isTerminal(int(f.Fd())) { + return 0 + } + _, h, err := termSize(int(f.Fd())) + if err != nil { + return 0 + } + return h +} + +// Page writes text through $PAGER (default "less -FRX") when it is taller +// than the terminal, and directly otherwise. Height is judged from +// os.Stdout regardless of which writer w is: that is the terminal a +// spawned pager would inherit, not necessarily w. A missing or broken +// pager never loses the plan: Page falls back to writing directly when +// the pager cannot start. +func Page(w io.Writer, text string) error { + if fitsWithoutPaging(text) { + _, err := io.WriteString(w, text) + return err + } + if runPager(text) { + return nil + } + _, err := io.WriteString(w, text) + return err +} + +// fitsWithoutPaging reports whether text has no more lines than the +// terminal's height. It looks at the process's own stdout, since that is +// the terminal the pager would inherit, not the writer text is otherwise +// sent to. +func fitsWithoutPaging(text string) bool { + h := Height(os.Stdout) + if h <= 0 { + return true + } + lines := strings.Count(text, "\n") + if text != "" && !strings.HasSuffix(text, "\n") { + lines++ // the final, unterminated line still occupies a row + } + return lines <= h +} + +// runPager sends text through $PAGER (default "less -FRX") and reports +// whether it started. $PAGER is split with strings.Fields, not a shell. +func runPager(text string) bool { + spec := os.Getenv("PAGER") + if spec == "" { + spec = defaultPager + } + fields := strings.Fields(spec) + if len(fields) == 0 { + return false + } + cmd := exec.Command(fields[0], fields[1:]...) + cmd.Stdin = strings.NewReader(text) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + return false + } + _ = cmd.Wait() + return true +} |
