v0.15.0 6b178f8

detect_bg.go RawHistoryFolder

1package tui
2
3import (
4	"os"
5	"strconv"
6	"strings"
7	"time"
8
9	"golang.org/x/term"
10)
11
12// DetectTrueColor reports whether TERM or COLORTERM advertises direct color
13// support. This follows the same conservative signals used by ../vev.
14func DetectTrueColor(termEnv, colorTerm string) bool {
15	switch strings.ToLower(strings.TrimSpace(colorTerm)) {
16	case "truecolor", "24bit":
17		return true
18	}
19
20	termEnv = strings.ToLower(strings.TrimSpace(termEnv))
21	return termEnv == "xterm-direct" || strings.HasSuffix(termEnv, "-direct")
22}
23
24// DetectThemeFromBackground queries the controlling tty for its current
25// foreground, background, and ANSI palette using OSC 10/11/4. The returned
26// theme keeps the detected light/dark built-in colors for the normal auto
27// theme and carries the terminal snapshot for the inherited theme.
28//
29// The query / parse runs synchronously before the TUI is initialised so the
30// returned snapshot can drive the entire session. We briefly put stdin into
31// raw mode and disable echo so OSC replies do not leak onto the user's screen.
32func DetectThemeFromBackground(timeout time.Duration) Theme {
33	profile := detectTerminalProfile(timeout)
34	base := Dark
35
36	// Honour explicit override env vars first; some users and CI environments
37	// know better than the heuristic. The terminal snapshot is still retained
38	// so selecting the inherited theme later remains useful.
39	switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) {
40	case "dark":
41		base = Dark
42	case "light":
43		base = Light
44	default:
45		if profile.HasBackground {
46			luma := 0.2126*float64(profile.Background.R) +
47				0.7152*float64(profile.Background.G) +
48				0.0722*float64(profile.Background.B)
49			if luma >= 127.5 {
50				base = Light
51			}
52		}
53	}
54
55	base.Terminal = profile
56	return base
57}
58
59type oscColorResponse struct {
60	kind  int
61	slot  int
62	color TerminalColor
63}
64
65func detectTerminalProfile(timeout time.Duration) TerminalProfile {
66	profile := TerminalProfile{TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
67	if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {
68		return profile
69	}
70
71	fd := int(os.Stdin.Fd())
72	st, err := term.MakeRaw(fd)
73	if err != nil {
74		return profile
75	}
76	defer term.Restore(fd, st)
77
78	queries := terminalColorQueries()
79	if _, err := os.Stdout.Write([]byte(queries)); err != nil {
80		return profile
81	}
82
83	responses := readOSCColorResponses(time.Now().Add(timeout), terminalColorQueryCount)
84	for _, response := range responses {
85		switch response.kind {
86		case 10:
87			profile.Foreground = response.color
88			profile.HasForeground = true
89		case 11:
90			profile.Background = response.color
91			profile.HasBackground = true
92		case 4:
93			if response.slot >= 0 && response.slot < len(profile.Palette) {
94				profile.Palette[response.slot] = response.color
95				profile.PaletteKnown |= uint16(1) << response.slot
96			}
97		}
98	}
99	if profile.HasBackground {
100		luma := 0.2126*float64(profile.Background.R) +
101			0.7152*float64(profile.Background.G) +
102			0.0722*float64(profile.Background.B)
103		profile.Light = luma >= 127.5
104		profile.SchemeKnown = true
105	}
106	return profile
107}
108
109const (
110	terminalColorQueryCount    = 18
111	terminalColorResponseLimit = 64
112)
113
114func terminalColorQueries() string {
115	var b strings.Builder
116	b.Grow(terminalColorQueryCount * 12)
117	b.WriteString("\x1b]10;?\x07\x1b]11;?\x07")
118	for slot := 0; slot < 16; slot++ {
119		b.WriteString("\x1b]4;")
120		b.WriteString(itoa(slot))
121		b.WriteString(";?\x07")
122	}
123	return b.String()
124}
125
126// readOSCColorResponses drains terminal replies until all expected replies
127// arrive, the deadline expires, or stdin hits EOF. It only consumes complete
128// BEL/ST-terminated OSC color responses; an incomplete tail is discarded at
129// timeout so a failed query cannot poison the next interactive read.
130func readOSCColorResponses(deadline time.Time, expected int) []oscColorResponse {
131	responses := make([]oscColorResponse, 0, expected)
132	buf := make([]byte, 0, 64)
133	for len(responses) < expected && time.Now().Before(deadline) {
134		remaining := time.Until(deadline)
135		if remaining <= 0 {
136			break
137		}
138		b, ok, err := peekStdin(os.Stdin, remaining)
139		if err != nil || !ok {
140			break
141		}
142		if len(buf) >= terminalColorResponseLimit {
143			buf = buf[:0]
144		}
145		buf = append(buf, b)
146		terminated := b == '\x07' || (len(buf) >= 2 && buf[len(buf)-2] == '\x1b' && buf[len(buf)-1] == '\\')
147		if !terminated {
148			continue
149		}
150		if response, ok := parseOSCColorResponse(buf); ok {
151			responses = append(responses, response)
152		}
153		buf = buf[:0]
154	}
155	return responses
156}
157
158func parseOSCColorResponse(raw []byte) (oscColorResponse, bool) {
159	s := string(raw)
160	s = strings.TrimSuffix(s, "\x07")
161	s = strings.TrimSuffix(s, "\x1b\\")
162
163	if strings.HasPrefix(s, "\x1b]10;") {
164		color, ok := parseTerminalRGB(s[len("\x1b]10;"):])
165		return oscColorResponse{kind: 10, color: color}, ok
166	}
167	if strings.HasPrefix(s, "\x1b]11;") {
168		color, ok := parseTerminalRGB(s[len("\x1b]11;"):])
169		return oscColorResponse{kind: 11, color: color}, ok
170	}
171	if strings.HasPrefix(s, "\x1b]4;") {
172		body := s[len("\x1b]4;"):]
173		parts := strings.SplitN(body, ";", 2)
174		if len(parts) != 2 || parts[0] == "" {
175			return oscColorResponse{}, false
176		}
177		slot, err := strconv.Atoi(parts[0])
178		if err != nil || slot < 0 || slot >= 16 {
179			return oscColorResponse{}, false
180		}
181		color, ok := parseTerminalRGB(parts[1])
182		return oscColorResponse{kind: 4, slot: slot, color: color}, ok
183	}
184	return oscColorResponse{}, false
185}
186
187func parseTerminalRGB(value string) (TerminalColor, bool) {
188	value = strings.TrimSpace(value)
189	if strings.HasPrefix(value, "#") {
190		if len(value) != 7 {
191			return TerminalColor{}, false
192		}
193		r, ok := parseHexByte(value[1:3])
194		if !ok {
195			return TerminalColor{}, false
196		}
197		g, ok := parseHexByte(value[3:5])
198		if !ok {
199			return TerminalColor{}, false
200		}
201		b, ok := parseHexByte(value[5:7])
202		if !ok {
203			return TerminalColor{}, false
204		}
205		return ColorRGB(r, g, b), true
206	}
207	if !strings.HasPrefix(value, "rgb:") {
208		return TerminalColor{}, false
209	}
210	parts := strings.Split(value[len("rgb:"):], "/")
211	if len(parts) != 3 {
212		return TerminalColor{}, false
213	}
214	var channels [3]int
215	for i, part := range parts {
216		if len(part) < 1 || len(part) > 4 {
217			return TerminalColor{}, false
218		}
219		v, err := strconv.ParseUint(part, 16, 16)
220		if err != nil {
221			return TerminalColor{}, false
222		}
223		max := uint64(1)
224		for range part {
225			max *= 16
226		}
227		max--
228		channels[i] = int(v * 255 / max)
229	}
230	return ColorRGB(channels[0], channels[1], channels[2]), true
231}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close