aboutsummaryrefslogtreecommitdiff
path: root/internal/tui
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-09-12 20:14:47 +0200
commit3f8679be9373ee7508d512dfdfc1dda0839c7f90 (patch)
treeec02eb075f6c4e90f21baa2fe674e86a2f7f6a62 /internal/tui
parent24a84671ace373ae331fa83a1ff484990f4dff0e (diff)
downloadkrino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.tar.gz
krino-3f8679be9373ee7508d512dfdfc1dda0839c7f90.zip
krino: acting — trash, journal, apply, lock, review, undo
Diffstat (limited to 'internal/tui')
-rw-r--r--internal/tui/keys.go37
-rw-r--r--internal/tui/tui.go107
-rw-r--r--internal/tui/tui_test.go129
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)
+ }
+}