v0.3.0 99dc266

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

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