detached ece7682

ramp.go RawHistoryFolder

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

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