summaryrefslogtreecommitdiff
path: root/internal/tui/tui.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/tui.go')
-rw-r--r--internal/tui/tui.go107
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
+}