theme.go RawHistoryFolder

1package theme
2
3import (
4	"bytes"
5	"strconv"
6	"strings"
7
8	vt "github.com/bnema/vev-vt"
9	renderer "github.com/bnema/vev-vt/ansi"
10)
11
12const maxPending = 64
13
14// Theme describes terminal default colors and ANSI palette entries.
15type Theme struct {
16	Foreground   renderer.RGB
17	Background   renderer.RGB
18	Palette      [16]renderer.RGB
19	PaletteKnown uint16
20	HasFG        bool
21	HasBG        bool
22	// TrueColor records output capability for transport compatibility. Semantic
23	// styles remain RGB; the attachment renderer quantizes them when necessary.
24	TrueColor   bool
25	Known       bool
26	SchemeKnown bool
27	Light       bool
28	UsePalette  bool
29}
30
31// PaletteColor returns a palette color only when palette inheritance is
32// enabled and the terminal reported the requested ANSI slot.
33func (t Theme) PaletteColor(slot int) (renderer.RGB, bool) {
34	if !t.UsePalette || slot < 0 || slot >= len(t.Palette) || t.PaletteKnown&(uint16(1)<<slot) == 0 {
35		return renderer.RGB{}, false
36	}
37	return t.Palette[slot], true
38}
39
40var (
41	BuiltinDark = Theme{
42		Foreground:  renderer.RGB{R: 0xd8, G: 0xd8, B: 0xd8},
43		Background:  renderer.RGB{R: 0x18, G: 0x18, B: 0x18},
44		HasFG:       true,
45		HasBG:       true,
46		TrueColor:   true,
47		Known:       true,
48		SchemeKnown: true,
49		Light:       false,
50	}
51	BuiltinLight = Theme{
52		Foreground:  renderer.RGB{R: 0x20, G: 0x20, B: 0x20},
53		Background:  renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8},
54		HasFG:       true,
55		HasBG:       true,
56		TrueColor:   true,
57		Known:       true,
58		SchemeKnown: true,
59		Light:       true,
60	}
61)
62
63// ParseXColor parses the XParseColor formats commonly returned by OSC 10/11.
64func ParseXColor(s string) (renderer.RGB, bool) {
65	if strings.HasPrefix(s, "#") {
66		if len(s) != 7 {
67			return renderer.RGB{}, false
68		}
69		r, ok := parseHexByte(s[1:3])
70		if !ok {
71			return renderer.RGB{}, false
72		}
73		g, ok := parseHexByte(s[3:5])
74		if !ok {
75			return renderer.RGB{}, false
76		}
77		b, ok := parseHexByte(s[5:7])
78		if !ok {
79			return renderer.RGB{}, false
80		}
81		return renderer.RGB{R: r, G: g, B: b}, true
82	}
83
84	if !strings.HasPrefix(s, "rgb:") {
85		return renderer.RGB{}, false
86	}
87	parts := strings.Split(s[len("rgb:"):], "/")
88	if len(parts) != 3 {
89		return renderer.RGB{}, false
90	}
91	var out [3]uint8
92	for i, part := range parts {
93		component, ok := parseXColorComponent(part)
94		if !ok {
95			return renderer.RGB{}, false
96		}
97		out[i] = component
98	}
99	return renderer.RGB{R: out[0], G: out[1], B: out[2]}, true
100}
101
102// parseXColorComponent scales an XParseColor component of one to four hex
103// digits to an 8-bit channel.
104func parseXColorComponent(s string) (uint8, bool) {
105	if len(s) < 1 || len(s) > 4 {
106		return 0, false
107	}
108	value, err := strconv.ParseUint(s, 16, 16)
109	if err != nil {
110		return 0, false
111	}
112	maxValue := uint64((1 << (4 * len(s))) - 1)
113	return uint8(value * 255 / maxValue), true
114}
115
116func parseHexByte(s string) (uint8, bool) {
117	v, err := strconv.ParseUint(s, 16, 8)
118	if err != nil {
119		return 0, false
120	}
121	return uint8(v), true
122}
123
124// Scanner decodes terminal color responses while preserving ordinary bytes.
125type Scanner struct {
126	pending []byte
127}
128
129// EndBatch forwards an admitted batch's undecided suffix as ordinary input.
130// It neither invents a terminal response nor closes the reusable scanner.
131func (s *Scanner) EndBatch(onBytes func([]byte)) {
132	pending := s.pending
133	s.pending = nil
134	if len(pending) != 0 {
135		onBytes(pending)
136	}
137}
138
139// Scan extracts ESC ] 10;<color>, ESC ] 11;<color>, and ESC ] 4;<slot>;<color>
140// responses terminated by BEL or ST.
141// All non-matching bytes are emitted through onBytes in original order, and a
142// contiguous run of ordinary bytes (including any ESC that does not start a
143// color-OSC sequence, e.g. keyboard/mouse escape sequences like SGR mouse
144// reports or arrow keys) is always delivered as a single onBytes call so
145// callers never see it split. Partial OSC color responses are buffered across
146// calls, but the buffer is bounded so an unterminated OSC cannot block
147// unrelated input forever.
148func (s *Scanner) Scan(data []byte, onColor func(kind int, rgb renderer.RGB), onPalette func(slot int, rgb renderer.RGB), onScheme func(light bool), onBytes func([]byte)) {
149	if len(s.pending) > 0 {
150		combined := make([]byte, 0, len(s.pending)+len(data))
151		combined = append(combined, s.pending...)
152		combined = append(combined, data...)
153		data = combined
154		s.pending = nil
155	}
156
157	byteStart := 0
158	for i := 0; i < len(data); i++ {
159		if data[i] != '\x1b' {
160			continue
161		}
162
163		if complete, possible, light := schemeCSINotification(data[i:]); complete {
164			if byteStart < i {
165				onBytes(data[byteStart:i])
166			}
167			onScheme(light)
168			i += len(vt.ColorSchemeReportDark) - 1
169			byteStart = i + 1
170			continue
171		} else if possible {
172			if byteStart < i {
173				onBytes(data[byteStart:i])
174			}
175			s.bufferOrFlush(data[i:], onBytes)
176			return
177		}
178
179		kind, prefixLen, completePrefix, possiblePrefix := colorOSCPrefix(data[i:])
180		if !completePrefix {
181			if possiblePrefix {
182				if byteStart < i {
183					onBytes(data[byteStart:i])
184				}
185				s.bufferOrFlush(data[i:], onBytes)
186				return
187			}
188			// Not the start of a color-OSC sequence: leave this ESC inside
189			// the current passthrough run instead of splitting it out. It
190			// may be the introducer of an unrelated escape sequence (SGR
191			// mouse report, arrow key, etc.) that must reach the reader
192			// intact.
193			continue
194		}
195
196		if byteStart < i {
197			onBytes(data[byteStart:i])
198		}
199
200		termStart, termEnd := findTerminator(data[i+prefixLen:])
201		if termStart < 0 {
202			s.bufferOrFlush(data[i:], onBytes)
203			return
204		}
205		termStart += i + prefixLen
206		termEnd += i + prefixLen
207		raw := data[i:termEnd]
208		body := string(data[i+prefixLen : termStart])
209		switch kind {
210		case 4:
211			slot, rgb, ok := parsePaletteResponse(body)
212			if ok {
213				onPalette(slot, rgb)
214			} else {
215				onBytes(raw)
216			}
217		default:
218			rgb, ok := ParseXColor(body)
219			if ok {
220				onColor(kind, rgb)
221			} else {
222				onBytes(raw)
223			}
224		}
225		i = termEnd - 1
226		byteStart = termEnd
227	}
228
229	if byteStart < len(data) {
230		onBytes(data[byteStart:])
231	}
232}
233
234func (s *Scanner) bufferOrFlush(partial []byte, onBytes func([]byte)) {
235	if len(partial) > maxPending {
236		onBytes(partial)
237		s.pending = nil
238		return
239	}
240	s.pending = append(s.pending, partial...)
241}
242
243func colorOSCPrefix(data []byte) (kind, prefixLen int, complete bool, possible bool) {
244	if len(data) < 2 {
245		return 0, 0, false, false
246	}
247	prefixes := []struct {
248		kind   int
249		prefix []byte
250	}{
251		{kind: 10, prefix: []byte("\x1b]10;")},
252		{kind: 11, prefix: []byte("\x1b]11;")},
253		{kind: 4, prefix: []byte("\x1b]4;")},
254	}
255	for _, candidate := range prefixes {
256		if len(data) >= len(candidate.prefix) {
257			if bytes.Equal(data[:len(candidate.prefix)], candidate.prefix) {
258				return candidate.kind, len(candidate.prefix), true, true
259			}
260			continue
261		}
262		if bytes.Equal(data, candidate.prefix[:len(data)]) {
263			return candidate.kind, len(candidate.prefix), false, true
264		}
265	}
266	return 0, 0, false, false
267}
268
269func parsePaletteResponse(body string) (int, renderer.RGB, bool) {
270	indexEnd := strings.IndexByte(body, ';')
271	if indexEnd < 1 {
272		return 0, renderer.RGB{}, false
273	}
274	slot := 0
275	for _, b := range []byte(body[:indexEnd]) {
276		if b < '0' || b > '9' {
277			return 0, renderer.RGB{}, false
278		}
279		slot = slot*10 + int(b-'0')
280		if slot > 15 {
281			return 0, renderer.RGB{}, false
282		}
283	}
284	rgb, ok := ParseXColor(body[indexEnd+1:])
285	if !ok {
286		return 0, renderer.RGB{}, false
287	}
288	return slot, rgb, true
289}
290
291// schemeCSIPrefixes lists the CSI ?997 scheme-notification sequences vev
292// recognizes. Package-level so schemeCSINotification (called per ESC byte in
293// the stdin hot path) doesn't allocate on every call.
294var schemeCSIPrefixes = []struct {
295	seq   []byte
296	light bool
297}{
298	{seq: []byte(vt.ColorSchemeReportDark), light: false},
299	{seq: []byte(vt.ColorSchemeReportLight), light: true},
300}
301
302func schemeCSINotification(data []byte) (complete bool, possible bool, light bool) {
303	// A bare trailing ESC (handled elsewhere) or "ESC [" (2 bytes) is
304	// something a user can type; unlike OSC's "ESC ]" it must never be
305	// withheld as a partial match with nothing following, or those keystrokes
306	// would be buffered and never forwarded. Partial buffering only starts
307	// from "ESC [ ?" onward; a complete match is always >= 9 bytes, so this
308	// guard cannot reject one.
309	if len(data) < 3 {
310		return false, false, false
311	}
312	for _, prefix := range schemeCSIPrefixes {
313		if len(data) >= len(prefix.seq) {
314			if bytes.Equal(data[:len(prefix.seq)], prefix.seq) {
315				return true, true, prefix.light
316			}
317			continue
318		}
319		if bytes.Equal(data, prefix.seq[:len(data)]) {
320			return false, true, false
321		}
322	}
323	return false, false, false
324}
325
326func findTerminator(data []byte) (start int, end int) {
327	for i := range data {
328		switch data[i] {
329		case '\x07':
330			return i, i + 1
331		case '\x1b':
332			if i+1 < len(data) && data[i+1] == '\\' {
333				return i, i + 2
334			}
335		}
336	}
337	return -1, -1
338}
339
340// Blend linearly interpolates between a and b.
341func Blend(a, b renderer.RGB, t float64) renderer.RGB {
342	if t < 0 {
343		t = 0
344	}
345	if t > 1 {
346		t = 1
347	}
348	return renderer.RGB{
349		R: blendByte(a.R, b.R, t),
350		G: blendByte(a.G, b.G, t),
351		B: blendByte(a.B, b.B, t),
352	}
353}
354
355func blendByte(a, b uint8, t float64) uint8 {
356	return uint8(float64(a) + (float64(b)-float64(a))*t + 0.5)
357}
358
359func StatusBarStyle(t Theme) renderer.Style {
360	if !usable(t) {
361		return renderer.DefaultStyle()
362	}
363	style := renderer.DefaultStyle()
364	style.HasForegroundRGB = true
365	style.ForegroundRGB = t.Foreground
366	style.HasBackgroundRGB = true
367	style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.12)
368	return style
369}
370
371// The compatibility helpers below retain the neutral styles for isolated
372// callers. Accent-aware construction goes exclusively through Resolve.
373func AccentStyle(t Theme) renderer.Style {
374	return neutralStyles(t).SurfaceActive
375}
376
377func BorderStyle(t Theme) renderer.Style {
378	return neutralStyles(t).BorderMuted
379}
380
381func MutedTextStyle(t Theme) renderer.Style {
382	return legacyMutedText(t)
383}
384
385// mutedVariantBlend is the fraction MutedVariantStyle blends a style's own
386// foreground toward its own background.
387const mutedVariantBlend = 0.4
388
389// EmphasisStyle returns base with bold applied when the client theme supports
390// styled output; otherwise base is returned unchanged.
391func EmphasisStyle(base renderer.Style, t Theme) renderer.Style {
392	if !usable(t) {
393		return base
394	}
395	out := base
396	out.Bold = true
397	return out
398}
399
400// MutedVariantStyle fades base's foreground toward its own background.
401func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
402	if !usable(t) || !base.HasForegroundRGB {
403		return base
404	}
405	background := base.BackgroundRGB
406	if !base.HasBackgroundRGB {
407		if !t.HasBG {
408			return base
409		}
410		background = t.Background
411	}
412	out := base
413	out.ForegroundRGB = Blend(base.ForegroundRGB, background, mutedVariantBlend)
414	return out
415}
416
417const defaultDimmingPercent = 35
418
419// Dimmer transforms resolved terminal colors for subdued UI states. Construct
420// it once and reuse it while rendering cells with the same theme and policy.
421type Dimmer struct {
422	theme             Theme
423	backgroundPercent int
424	foregroundPercent int
425}
426
427type dimmingTarget uint8
428
429const (
430	dimBackground dimmingTarget = iota
431	dimForeground
432)
433
434// DimmerOption customizes a Dimmer without allocating a closure.
435type DimmerOption struct {
436	target  dimmingTarget
437	percent int
438}
439
440// WithBackgroundDimming overrides the default 35 percent background fade.
441func WithBackgroundDimming(percent int) DimmerOption {
442	return DimmerOption{target: dimBackground, percent: percent}
443}
444
445// WithForegroundDimming overrides the default 35 percent foreground fade.
446func WithForegroundDimming(percent int) DimmerOption {
447	return DimmerOption{target: dimForeground, percent: percent}
448}
449
450// NewDimmer returns a reusable dimmer with optional per-channel overrides.
451func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
452	d := Dimmer{
453		theme:             t,
454		backgroundPercent: defaultDimmingPercent,
455		foregroundPercent: defaultDimmingPercent,
456	}
457	for _, opt := range opts {
458		switch opt.target {
459		case dimBackground:
460			d.backgroundPercent = opt.percent
461		case dimForeground:
462			d.foregroundPercent = opt.percent
463		}
464	}
465	d.backgroundPercent = clampPercent(d.backgroundPercent)
466	d.foregroundPercent = clampPercent(d.foregroundPercent)
467	return d
468}
469
470// Dim fades a style's resolved background toward the terminal background,
471// then fades its foreground and custom underline toward that dimmed background.
472func (d Dimmer) Dim(style renderer.Style) renderer.Style {
473	if !usable(d.theme) {
474		return style
475	}
476
477	foreground := resolveForeground(style, d.theme)
478	background := resolveBackground(style, d.theme)
479	if style.Inverse {
480		foreground, background = background, foreground
481	}
482	background = blendPercent(background, d.theme.Background, d.backgroundPercent)
483
484	out := style
485	out.Inverse = false
486	out.HasForegroundRGB = true
487	out.ForegroundRGB = blendPercent(foreground, background, d.foregroundPercent)
488	out.HasBackgroundRGB = true
489	out.BackgroundRGB = background
490	if style.HasUnderlineColorRGB || style.HasUnderlineColor {
491		underline := style.UnderlineColorRGB
492		if !style.HasUnderlineColorRGB {
493			underline = indexedToRGB(d.theme, style.UnderlineColor)
494		}
495		out.HasUnderlineColor = false
496		out.HasUnderlineColorRGB = true
497		out.UnderlineColorRGB = blendPercent(underline, background, d.foregroundPercent)
498	}
499	return out
500}
501
502func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
503	if style.HasForegroundRGB {
504		return style.ForegroundRGB
505	}
506	if style.Foreground >= 0 {
507		return indexedToRGB(t, style.Foreground)
508	}
509	return t.Foreground
510}
511
512func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
513	if style.HasBackgroundRGB {
514		return style.BackgroundRGB
515	}
516	if style.Background >= 0 {
517		return indexedToRGB(t, style.Background)
518	}
519	return t.Background
520}
521
522// indexedToRGB resolves an ANSI color index through the terminal's reported
523// palette when available, so dimming blends toward what the client actually
524// renders rather than the hardcoded classic xterm table. It falls back to
525// the xterm table for the 256-cube, for unreported/unknown slots, and when
526// palette inheritance is disabled.
527func indexedToRGB(t Theme, index int) renderer.RGB {
528	if rgb, ok := t.PaletteColor(index); ok {
529		return rgb
530	}
531	return xterm256Color(index)
532}
533
534func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
535	return renderer.RGB{
536		R: blendBytePercent(a.R, b.R, percent),
537		G: blendBytePercent(a.G, b.G, percent),
538		B: blendBytePercent(a.B, b.B, percent),
539	}
540}
541
542func blendBytePercent(a, b uint8, percent int) uint8 {
543	return uint8((int(a)*(100-percent) + int(b)*percent + 50) / 100)
544}
545
546func clampPercent(percent int) int {
547	if percent < 0 {
548		return 0
549	}
550	if percent > 100 {
551		return 100
552	}
553	return percent
554}
555
556var (
557	xterm256Base = [...]renderer.RGB{
558		{R: 0x00, G: 0x00, B: 0x00}, {R: 0x80, G: 0x00, B: 0x00}, {R: 0x00, G: 0x80, B: 0x00}, {R: 0x80, G: 0x80, B: 0x00},
559		{R: 0x00, G: 0x00, B: 0x80}, {R: 0x80, G: 0x00, B: 0x80}, {R: 0x00, G: 0x80, B: 0x80}, {R: 0xc0, G: 0xc0, B: 0xc0},
560		{R: 0x80, G: 0x80, B: 0x80}, {R: 0xff, G: 0x00, B: 0x00}, {R: 0x00, G: 0xff, B: 0x00}, {R: 0xff, G: 0xff, B: 0x00},
561		{R: 0x00, G: 0x00, B: 0xff}, {R: 0xff, G: 0x00, B: 0xff}, {R: 0x00, G: 0xff, B: 0xff}, {R: 0xff, G: 0xff, B: 0xff},
562	}
563	xterm256Levels = [...]uint8{0, 95, 135, 175, 215, 255}
564)
565
566func xterm256Color(index int) renderer.RGB {
567	if index < 0 {
568		return renderer.RGB{}
569	}
570	if index < len(xterm256Base) {
571		return xterm256Base[index]
572	}
573	if index < 232 {
574		index -= 16
575		return renderer.RGB{R: xterm256Levels[index/36], G: xterm256Levels[(index/6)%6], B: xterm256Levels[index%6]}
576	}
577	if index < 256 {
578		level := uint8(8 + (index-232)*10)
579		return renderer.RGB{R: level, G: level, B: level}
580	}
581	return xterm256Base[index%len(xterm256Base)]
582}
583
584func SelectionStyle(t Theme) renderer.Style {
585	if !usable(t) {
586		return inverseStyle()
587	}
588	return AccentStyle(t)
589}
590
591func usable(t Theme) bool {
592	return t.Known && t.HasFG && t.HasBG
593}
594
595func inverseStyle() renderer.Style {
596	style := renderer.DefaultStyle()
597	style.Inverse = true
598	return style
599}

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 12

Tags 11

↑↓ moveenter openesc close