summaryrefslogtreecommitdiff
path: root/internal/tui/tui.go
blob: e7872c88839f64b8ce1198eed007d335e079e009 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
// 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
}