// 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 } // Width is the terminal's column count, 0 when w is not a terminal or the // size cannot be read: callers wrap text to it, and treat 0 as "never // wrap", which keeps output piped to a file one field per line. func Width(w io.Writer) int { f, ok := w.(*os.File) if !ok || !isTerminal(int(f.Fd())) { return 0 } cols, _, err := termSize(int(f.Fd())) if err != nil { return 0 } return cols } // 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 }