v0.27.1 131edba

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// DetectColorDepth conservatively chooses the highest color mode advertised
25// by the environment. Terminals without a 256-color signal stay ANSI16.
26func DetectColorDepth(termEnv, colorTerm string) ColorDepth {
27	if DetectTrueColor(termEnv, colorTerm) {
28		return ColorDepthTrueColor
29	}
30	termEnv = strings.ToLower(strings.TrimSpace(termEnv))
31	if strings.Contains(termEnv, "256color") {
32		return ColorDepthIndexed256
33	}
34	return ColorDepthANSI16
35}
36
37// DetectThemeFromBackground queries the controlling tty for its current
38// foreground, background, and ANSI palette using OSC 10/11/4. Auto is always
39// terminal-owned; the snapshot is retained for subsequent runtime resolution.
40//
41// The query / parse runs synchronously before the TUI is initialised so the
42// returned snapshot can drive the entire session. We briefly put stdin into
43// raw mode and disable echo so OSC replies do not leak onto the user's screen.
44func DetectThemeFromBackground(timeout time.Duration) Theme {
45	return TerminalTheme(detectTerminalProfile(timeout))
46}
47
48type oscColorResponse struct {
49	kind  int
50	slot  int
51	color TerminalColor
52}
53
54func detectTerminalProfile(timeout time.Duration) TerminalProfile {
55	profile := TerminalProfile{Depth: DetectColorDepth(os.Getenv("TERM"), os.Getenv("COLORTERM")), TrueColor: DetectTrueColor(os.Getenv("TERM"), os.Getenv("COLORTERM"))}
56	if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) || timeout <= 0 {
57		return profile
58	}
59
60	fd := int(os.Stdin.Fd())
61	st, err := term.MakeRaw(fd)
62	if err != nil {
63		return profile
64	}
65	defer term.Restore(fd, st)
66
67	queries := terminalColorQueries()
68	if _, err := os.Stdout.Write([]byte(queries)); err != nil {
69		return profile
70	}
71
72	responses := readOSCColorResponses(time.Now().Add(timeout), terminalColorQueryCount)
73	for _, response := range responses {
74		switch response.kind {
75		case 10:
76			profile.Foreground = response.color
77			profile.HasForeground = true
78		case 11:
79			profile.Background = response.color
80			profile.HasBackground = true
81		case 4:
82			if response.slot >= 0 && response.slot < len(profile.Palette) {
83				profile.Palette[response.slot] = response.color
84				profile.PaletteKnown |= uint16(1) << response.slot
85			}
86		}
87	}
88	if profile.HasBackground {
89		// This is a branch-selection fallback, not a reported scheme. Runtime
90		// CSI 997 replies alone set SchemeKnown.
91		profile.Light = 0.2126*float64(profile.Background.R)+
92			0.7152*float64(profile.Background.G)+
93			0.0722*float64(profile.Background.B) >= 127.5
94	}
95	return profile
96}
97
98const (
99	terminalColorQueryCount    = 18
100	terminalColorResponseLimit = 64
101)
102
103func terminalColorQueries() string {
104	var b strings.Builder
105	b.Grow(terminalColorQueryCount * 12)
106	b.WriteString("\x1b]10;?\x07\x1b]11;?\x07")
107	for slot := 0; slot < 16; slot++ {
108		b.WriteString("\x1b]4;")
109		b.WriteString(itoa(slot))
110		b.WriteString(";?\x07")
111	}
112	return b.String()
113}
114
115// readOSCColorResponses drains terminal replies until all expected replies
116// arrive, the deadline expires, or stdin hits EOF. It only consumes complete
117// BEL/ST-terminated OSC color responses; an incomplete tail is discarded at
118// timeout so a failed query cannot poison the next interactive read.
119func readOSCColorResponses(deadline time.Time, expected int) []oscColorResponse {
120	responses := make([]oscColorResponse, 0, expected)
121	buf := make([]byte, 0, 64)
122	for len(responses) < expected && time.Now().Before(deadline) {
123		remaining := time.Until(deadline)
124		if remaining <= 0 {
125			break
126		}
127		b, ok, err := peekStdin(os.Stdin, remaining)
128		if err != nil || !ok {
129			break
130		}
131		if len(buf) >= terminalColorResponseLimit {
132			buf = buf[:0]
133		}
134		buf = append(buf, b)
135		terminated := b == '\x07' || (len(buf) >= 2 && buf[len(buf)-2] == '\x1b' && buf[len(buf)-1] == '\\')
136		if !terminated {
137			continue
138		}
139		if response, ok := parseOSCColorResponse(buf); ok {
140			responses = append(responses, response)
141		}
142		buf = buf[:0]
143	}
144	return responses
145}
146
147func parseOSCColorResponse(raw []byte) (oscColorResponse, bool) {
148	s := string(raw)
149	s = strings.TrimSuffix(s, "\x07")
150	s = strings.TrimSuffix(s, "\x1b\\")
151
152	if strings.HasPrefix(s, "\x1b]10;") {
153		color, ok := parseTerminalRGB(s[len("\x1b]10;"):])
154		return oscColorResponse{kind: 10, color: color}, ok
155	}
156	if strings.HasPrefix(s, "\x1b]11;") {
157		color, ok := parseTerminalRGB(s[len("\x1b]11;"):])
158		return oscColorResponse{kind: 11, color: color}, ok
159	}
160	if strings.HasPrefix(s, "\x1b]4;") {
161		body := s[len("\x1b]4;"):]
162		parts := strings.SplitN(body, ";", 2)
163		if len(parts) != 2 || parts[0] == "" {
164			return oscColorResponse{}, false
165		}
166		slot, err := strconv.Atoi(parts[0])
167		if err != nil || slot < 0 || slot >= 16 {
168			return oscColorResponse{}, false
169		}
170		color, ok := parseTerminalRGB(parts[1])
171		return oscColorResponse{kind: 4, slot: slot, color: color}, ok
172	}
173	return oscColorResponse{}, false
174}
175
176func parseTerminalRGB(value string) (TerminalColor, bool) {
177	value = strings.TrimSpace(value)
178	if strings.HasPrefix(value, "#") {
179		if len(value) != 7 {
180			return TerminalColor{}, false
181		}
182		r, ok := parseHexByte(value[1:3])
183		if !ok {
184			return TerminalColor{}, false
185		}
186		g, ok := parseHexByte(value[3:5])
187		if !ok {
188			return TerminalColor{}, false
189		}
190		b, ok := parseHexByte(value[5:7])
191		if !ok {
192			return TerminalColor{}, false
193		}
194		return ColorRGB(r, g, b), true
195	}
196	if !strings.HasPrefix(value, "rgb:") {
197		return TerminalColor{}, false
198	}
199	parts := strings.Split(value[len("rgb:"):], "/")
200	if len(parts) != 3 {
201		return TerminalColor{}, false
202	}
203	var channels [3]int
204	for i, part := range parts {
205		if len(part) < 1 || len(part) > 4 {
206			return TerminalColor{}, false
207		}
208		v, err := strconv.ParseUint(part, 16, 16)
209		if err != nil {
210			return TerminalColor{}, false
211		}
212		max := uint64(1)
213		for range part {
214			max *= 16
215		}
216		max--
217		channels[i] = int(v * 255 / max)
218	}
219	return ColorRGB(channels[0], channels[1], channels[2]), true
220}

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