ramp.go RawHistoryFolder

1package theme
2
3import renderer "github.com/bnema/vev-vt"
4
5const (
6	normalTextContrast = 4.5
7	borderContrast     = 3.0
8
9	// warnHueDegrees is the fixed OKLCh hue target for the Warn border role.
10	// Reference amber hues under this package's own rgbToOKLab: conventional
11	// "amber" #FFBF00 sits at ~84 degrees, Tailwind's amber-500 (#F59E0B) at
12	// ~70 degrees, CSS "orange" at ~71 degrees. 75 degrees sits centrally in
13	// that band, so a warn border reads as amber/gold regardless of which
14	// hue the user's single accent happens to occupy (blue ~256, red ~29,
15	// green ~145 under the same conversion).
16	warnHueDegrees = 75.0
17)
18
19// Ramp is the contrast-safe set of semantic terminal chrome colors.
20type Ramp struct {
21	SurfaceBar      renderer.Style
22	SurfaceInactive renderer.Style
23	SurfaceRecent   renderer.Style
24	SurfaceActive   renderer.Style
25	BorderMuted     renderer.Style
26	BorderActive    renderer.Style
27	BorderWarn      renderer.Style
28
29	background renderer.RGB
30	foreground renderer.RGB
31	rgb        bool
32}
33
34// BuildRamp derives every RGB surface from the terminal background and one
35// resolved accent. It deliberately never uses indexed colors for backgrounds.
36func BuildRamp(t Theme, accent Accent) Ramp {
37	if !accent.Known || accent.IndexedOnly || !usable(t) {
38		return neutralRamp(t)
39	}
40
41	bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
42	if !ok {
43		return neutralRamp(t)
44	}
45	inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
46	if !ok {
47		return neutralRamp(t)
48	}
49	recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
50	if !ok {
51		return neutralRamp(t)
52	}
53	active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
54	if !ok {
55		return neutralRamp(t)
56	}
57
58	return Ramp{
59		SurfaceBar:      bar,
60		SurfaceInactive: inactive,
61		SurfaceRecent:   recent,
62		SurfaceActive:   active,
63		BorderMuted:     mutedBorder(t, accent.RGB, bar.BackgroundRGB),
64		BorderActive:    activeBorder(t, accent.RGB, bar.BackgroundRGB),
65		BorderWarn:      warnBorder(t, accent.RGB, bar.BackgroundRGB),
66		background:      t.Background,
67		foreground:      t.Foreground,
68		rgb:             true,
69	}
70}
71
72// surfaceAtOrBelow searches from the requested intensity to neutral. The
73// integer sequence is intentional: it makes contrast adaptation stable.
74func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
75	for weight := target; weight >= 0; weight-- {
76		background := okLabLerp(t.Background, accent, float64(weight)/100)
77		foreground, ok := primaryText(t, background)
78		if !ok {
79			continue
80		}
81		if _, ok := secondaryText(foreground, background); !ok {
82			continue
83		}
84		return rgbSurface(foreground, background), true
85	}
86	return renderer.Style{}, false
87}
88
89func rgbSurface(foreground, background renderer.RGB) renderer.Style {
90	style := renderer.DefaultStyle()
91	style.HasForegroundRGB = true
92	style.ForegroundRGB = foreground
93	style.HasBackgroundRGB = true
94	style.BackgroundRGB = background
95	return style
96}
97
98// primaryText chooses only the reported default foreground or background.
99func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) {
100	best := t.Foreground
101	bestContrast := ContrastRatio(t.Foreground, surface)
102	backgroundContrast := ContrastRatio(t.Background, surface)
103	if backgroundContrast > bestContrast {
104		best, bestContrast = t.Background, backgroundContrast
105	}
106	return best, bestContrast >= normalTextContrast
107}
108
109// secondaryText mutes toward the actual surface while retaining normal-text
110// contrast. Searching down from 40 selects the greatest permitted mute.
111func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) {
112	for weight := 40; weight >= 0; weight-- {
113		candidate := okLabLerp(primary, surface, float64(weight)/100)
114		if ContrastRatio(candidate, surface) >= normalTextContrast {
115			return candidate, true
116		}
117	}
118	return renderer.RGB{}, false
119}
120
121// mutedBorder starts at 60 percent and only moves toward the accent, choosing
122// the lowest weight that separates from its adjacent surface.
123func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
124	for weight := 60; weight <= 100; weight++ {
125		candidate := okLabLerp(t.Background, accent, float64(weight)/100)
126		if ContrastRatio(candidate, adjacent) >= borderContrast {
127			return foregroundStyle(candidate)
128		}
129	}
130	return neutralBorder(t)
131}
132
133// activeBorder starts at the accent endpoint and reduces only when needed.
134func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
135	for weight := 100; weight >= 0; weight-- {
136		candidate := okLabLerp(t.Background, accent, float64(weight)/100)
137		if ContrastRatio(candidate, adjacent) >= borderContrast {
138			return foregroundStyle(candidate)
139		}
140	}
141	return neutralBorder(t)
142}
143
144// warnBorder searches the same accent-derived weight scale activeBorder
145// uses, but replaces each candidate's hue with the fixed amber target before
146// checking contrast: the result keeps the accent's lightness/chroma band
147// (so it belongs to the ramp) while always reading as amber, regardless of
148// the user's accent hue.
149func warnBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style {
150	for weight := 100; weight >= 0; weight-- {
151		candidate := okLabLerp(t.Background, accent, float64(weight)/100)
152		amber := shiftHue(candidate, warnHueDegrees)
153		if ContrastRatio(amber, adjacent) >= borderContrast {
154			return foregroundStyle(amber)
155		}
156	}
157	return neutralBorder(t)
158}
159
160func foregroundStyle(color renderer.RGB) renderer.Style {
161	style := renderer.DefaultStyle()
162	style.HasForegroundRGB = true
163	style.ForegroundRGB = color
164	return style
165}
166
167func neutralRamp(t Theme) Ramp {
168	styles := neutralStyles(t)
169	return Ramp{
170		SurfaceBar:      styles.SurfaceBar,
171		SurfaceInactive: styles.SurfaceInactive,
172		SurfaceRecent:   styles.SurfaceRecent,
173		SurfaceActive:   styles.SurfaceActive,
174		BorderMuted:     styles.BorderMuted,
175		BorderActive:    styles.BorderActive,
176		BorderWarn:      styles.BorderWarn,
177	}
178}
179
180// mruWeight is the share of the way from the bar to the active surface for a
181// history entry. The newest entry always sits halfway; the remaining entries
182// split that lower half into equal steps, so the oldest stays one step above
183// the bar: 1 entry → 50; 2 → 50, 25; 3 → 50, 33, 17.
184func mruWeight(index, count int) float64 {
185	return 0.5 * (1 - float64(index)/float64(count))
186}
187
188// MRUStyle places history entries by mruWeight between the bar and the active
189// surface. The background is never moved for contrast; only the text picks the
190// better of the theme foreground and background.
191func MRUStyle(ramp Ramp, index, count int) renderer.Style {
192	if !ramp.rgb || count <= 0 {
193		return ramp.SurfaceRecent
194	}
195	index = max(0, min(index, count-1))
196	weight := mruWeight(index, count)
197	background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
198	foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
199	return rgbSurface(foreground, background)
200}

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