diff options
Diffstat (limited to 'internal/tui')
| -rw-r--r-- | internal/tui/keys.go | 37 | ||||
| -rw-r--r-- | internal/tui/tui.go | 107 | ||||
| -rw-r--r-- | internal/tui/tui_test.go | 129 |
3 files changed, 273 insertions, 0 deletions
diff --git a/internal/tui/keys.go b/internal/tui/keys.go new file mode 100644 index 0000000..e88a326 --- /dev/null +++ b/internal/tui/keys.go @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package tui + +import ( + "fmt" + "os" + + "golang.org/x/term" +) + +// ReadKey reads one keypress without Enter, restoring the terminal before +// it returns - on every path, including an error. It always reads a +// single byte; only a terminal is first put into raw mode, so a +// non-terminal (a pipe, in tests) needs no pty to exercise it. +func ReadKey(in *os.File) (rune, error) { + fd := int(in.Fd()) + if !isTerminal(fd) { + return readByte(in) + } + + state, err := term.MakeRaw(fd) + if err != nil { + return 0, fmt.Errorf("tui: %w", err) + } + defer term.Restore(fd, state) + + return readByte(in) +} + +func readByte(in *os.File) (rune, error) { + var b [1]byte + if _, err := in.Read(b[:]); err != nil { + return 0, err + } + return rune(b[0]), nil +} 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 +} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go new file mode 100644 index 0000000..6918859 --- /dev/null +++ b/internal/tui/tui_test.go @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package tui + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestColourNeedsTerminalAndNoNOCOLOR(t *testing.T) { + var buf bytes.Buffer + if Colour(&buf) { + t.Error("colour on a non-terminal writer") + } + + old := isTerminal + t.Cleanup(func() { isTerminal = old }) + isTerminal = func(fd int) bool { return true } + + t.Setenv("NO_COLOR", "") + os.Unsetenv("NO_COLOR") + if !Colour(os.Stdout) { + t.Error("no colour on a terminal with NO_COLOR unset") + } + t.Setenv("NO_COLOR", "1") + if Colour(os.Stdout) { + t.Error("colour emitted with NO_COLOR set") + } + t.Setenv("NO_COLOR", "") + if Colour(os.Stdout) { + t.Error("NO_COLOR set to the empty string must still disable colour") + } +} + +func TestHeight(t *testing.T) { + var buf bytes.Buffer + if h := Height(&buf); h != 0 { + t.Errorf("Height on a non-terminal writer = %d, want 0", h) + } + + oldT, oldS := isTerminal, termSize + t.Cleanup(func() { isTerminal, termSize = oldT, oldS }) + isTerminal = func(fd int) bool { return true } + termSize = func(fd int) (int, int, error) { return 80, 24, nil } + + if h := Height(os.Stdout); h != 24 { + t.Errorf("Height = %d, want 24", h) + } +} + +func TestPageUsesPagerOnlyWhenTaller(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "paged") + // A pager that records what it was given. + t.Setenv("PAGER", "tee "+marker) + + oldT, oldS := isTerminal, termSize + t.Cleanup(func() { isTerminal, termSize = oldT, oldS }) + isTerminal = func(fd int) bool { return true } + termSize = func(fd int) (int, int, error) { return 80, 5, nil } + + var buf bytes.Buffer + if err := Page(&buf, "one\ntwo\n"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Error("short text went through the pager") + } + if buf.String() != "one\ntwo\n" { + t.Errorf("short text = %q", buf.String()) + } + + tall := strings.Repeat("line\n", 20) + if err := Page(&buf, tall); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("tall text did not reach the pager: %v", err) + } + if string(b) != tall { + t.Errorf("the pager received %q", b) + } +} + +func TestPageCountsFinalLineWithoutTrailingNewline(t *testing.T) { + dir := t.TempDir() + marker := filepath.Join(dir, "paged") + t.Setenv("PAGER", "tee "+marker) + + oldT, oldS := isTerminal, termSize + t.Cleanup(func() { isTerminal, termSize = oldT, oldS }) + isTerminal = func(fd int) bool { return true } + termSize = func(fd int) (int, int, error) { return 80, 5, nil } + + // Six lines but only five newlines: one more line than the terminal's + // height, with no trailing newline after the last one. + text := "one\ntwo\nthree\nfour\nfive\nsix" + + var buf bytes.Buffer + if err := Page(&buf, text); err != nil { + t.Fatal(err) + } + b, err := os.ReadFile(marker) + if err != nil { + t.Fatalf("six lines with no trailing newline, one more than the terminal's height, did not reach the pager: %v", err) + } + if string(b) != text { + t.Errorf("the pager received %q", b) + } +} + +func TestReadKeyOnAPipeReadsOneByte(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + go func() { w.WriteString("ay"); w.Close() }() + got, err := ReadKey(r) + if err != nil { + t.Fatal(err) + } + if got != 'a' { + t.Errorf("key = %q, want 'a'", got) + } +} |
