v0.7.0 4c16dd8

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

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