main ece7682

ramp_test.go RawHistoryFolder

1package theme
2
3import (
4	"math"
5	"testing"
6
7	renderer "github.com/bnema/vev-vt"
8	"github.com/bnema/vev/internal/domain"
9	"github.com/stretchr/testify/require"
10)
11
12func TestBuildRampUsesExactSemanticWeights(t *testing.T) {
13	theme := rampTheme(false)
14	accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true}
15	ramp := BuildRamp(theme, accent)
16
17	for name, got := range map[string]renderer.Style{
18		"bar":      ramp.SurfaceBar,
19		"inactive": ramp.SurfaceInactive,
20		"recent":   ramp.SurfaceRecent,
21	} {
22		require.True(t, got.HasBackgroundRGB, name)
23	}
24	require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB)
25	require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB)
26	require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB)
27	require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB)
28	require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB)
29	require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB)
30	require.False(t, ramp.BorderMuted.HasBackgroundRGB)
31	require.False(t, ramp.BorderActive.HasBackgroundRGB)
32}
33
34func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) {
35	for _, light := range []bool{false, true} {
36		t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) {
37			theme := rampTheme(light)
38			accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
39			ramp := BuildRamp(theme, accent)
40			for name, style := range map[string]renderer.Style{
41				"bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive,
42				"recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive,
43			} {
44				require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name)
45			}
46			for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} {
47				require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name)
48			}
49		})
50	}
51}
52
53func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) {
54	theme := Theme{
55		Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0},
56		Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59},
57		HasFG:      true, HasBG: true, Known: true, UsePalette: true,
58	}
59	accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}
60	ramp := BuildRamp(theme, accent)
61
62	wantWeight := -1
63	for weight := 100; weight >= 0; weight-- {
64		surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100)
65		primary, ok := primaryText(theme, surface)
66		if !ok {
67			continue
68		}
69		if _, ok := secondaryText(primary, surface); ok {
70			wantWeight = weight
71			break
72		}
73	}
74	require.NotEqual(t, 100, wantWeight)
75	require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB)
76}
77
78func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
79	theme := rampTheme(false)
80
81	for name, accentRGB := range map[string]renderer.RGB{
82		"blue accent": {R: 0x6c, G: 0x9b, B: 0xd9},
83		"red accent":  {R: 0xcc, G: 0x66, B: 0x66},
84	} {
85		t.Run(name, func(t *testing.T) {
86			accent := Accent{RGB: accentRGB, Known: true}
87			ramp := BuildRamp(theme, accent)
88
89			require.True(t, ramp.BorderWarn.HasForegroundRGB)
90			require.False(t, ramp.BorderWarn.HasBackgroundRGB)
91
92			accentHue := oklabToOKLCh(rgbToOKLab(accentRGB)).H
93			warnHue := oklabToOKLCh(rgbToOKLab(ramp.BorderWarn.ForegroundRGB)).H
94			distanceToTarget := func(h float64) float64 {
95				d := math.Abs(h - warnHueDegrees)
96				if d > 180 {
97					d = 360 - d
98				}
99				return d
100			}
101			// Warn must land measurably closer to the amber target than the
102			// accent itself sits, in both directions (blue and the
103			// adversarial case of a red accent, which sits close to zero
104			// degrees and could otherwise get confused with an
105			// under-rotated "warm" accent border).
106			require.Less(t, distanceToTarget(warnHue), distanceToTarget(accentHue))
107			require.Less(t, distanceToTarget(warnHue), 5.0)
108
109			// Distinct from the sibling borders by more than a rounding
110			// difference: use the same OKLab distance scale accent
111			// clustering already treats as "different colors"
112			// (accentClusterDistance groups colors within 0.04).
113			warnLab := rgbToOKLab(ramp.BorderWarn.ForegroundRGB)
114			mutedLab := rgbToOKLab(ramp.BorderMuted.ForegroundRGB)
115			activeLab := rgbToOKLab(ramp.BorderActive.ForegroundRGB)
116			require.Greater(t, okLabDistance(warnLab, mutedLab), accentClusterDistance)
117			require.Greater(t, okLabDistance(warnLab, activeLab), accentClusterDistance)
118
119			require.GreaterOrEqual(t, ContrastRatio(ramp.BorderWarn.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast)
120		})
121	}
122}
123
124func TestMRUWeight(t *testing.T) {
125	tests := []struct {
126		count int
127		want  []float64
128	}{
129		{count: 1, want: []float64{0.5}},
130		{count: 2, want: []float64{0.5, 0.25}},
131		{count: 3, want: []float64{0.5, 1.0 / 3, 1.0 / 6}},
132		{count: 4, want: []float64{0.5, 0.375, 0.25, 0.125}},
133	}
134	for _, tt := range tests {
135		for index, want := range tt.want {
136			require.InDelta(t, want, mruWeight(index, tt.count), 1e-9, "count=%d index=%d", tt.count, index)
137		}
138	}
139}
140
141func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
142	tests := []struct {
143		name   string
144		theme  Theme
145		accent renderer.RGB
146	}{
147		{name: "dark teal", theme: rampTheme(false), accent: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},
148		{name: "dark blue", theme: rampTheme(false), accent: renderer.RGB{R: 0x3b, G: 0x82, B: 0xf6}},
149		{name: "light teal", theme: rampTheme(true), accent: renderer.RGB{R: 0x2a, G: 0x7a, B: 0x7a}},
150		{name: "low contrast grey with blue", theme: Theme{Foreground: renderer.RGB{R: 160, G: 160, B: 160}, Background: renderer.RGB{R: 32, G: 32, B: 32}, HasFG: true, HasBG: true, Known: true, UsePalette: true}, accent: renderer.RGB{R: 0, G: 102, B: 255}},
151	}
152	for _, tt := range tests {
153		t.Run(tt.name, func(t *testing.T) {
154			ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true})
155			require.True(t, ramp.rgb)
156			bar, active := ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB
157			for count := 1; count <= 9; count++ {
158				for index := range count {
159					weight := mruWeight(index, count)
160					style := MRUStyle(ramp, index, count)
161					require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)
162					foreground, _ := primaryText(tt.theme, style.BackgroundRGB)
163					require.Equal(t, dimText(foreground, style.BackgroundRGB, mruTextDim(index, count)), style.ForegroundRGB)
164					if ContrastRatio(foreground, style.BackgroundRGB) >= normalTextContrast {
165						require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, "count=%d index=%d keeps normal-text contrast", count, index)
166					}
167				}
168			}
169			require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
170		})
171	}
172}
173
174func TestMRUTextDimFadesOldestToSeventyPercent(t *testing.T) {
175	tests := []struct {
176		count int
177		want  []float64
178	}{
179		{count: 1, want: []float64{0}},
180		{count: 2, want: []float64{0, 0.3}},
181		{count: 3, want: []float64{0, 0.15, 0.3}},
182		{count: 4, want: []float64{0, 0.1, 0.2, 0.3}},
183	}
184	for _, tt := range tests {
185		for index, want := range tt.want {
186			require.InDelta(t, want, mruTextDim(index, tt.count), 1e-9, "count=%d index=%d", tt.count, index)
187		}
188	}
189}
190
191func TestDimTextStopsAtNormalTextContrast(t *testing.T) {
192	black, white, grey := renderer.RGB{}, renderer.RGB{R: 255, G: 255, B: 255}, renderer.RGB{R: 0x80, G: 0x80, B: 0x80}
193	tests := []struct {
194		name    string
195		text    renderer.RGB
196		surface renderer.RGB
197		want    renderer.RGB
198	}{
199		{name: "high contrast fades fully", text: white, surface: black, want: okLabLerp(white, black, 0.3)},
200		{name: "marginal contrast fades only to the floor", text: grey, surface: black},
201		{name: "low contrast stays undimmed", text: grey, surface: grey, want: grey},
202	}
203	for _, tt := range tests {
204		t.Run(tt.name, func(t *testing.T) {
205			got := dimText(tt.text, tt.surface, 0.3)
206			if tt.want != (renderer.RGB{}) {
207				require.Equal(t, tt.want, got)
208			}
209			if ContrastRatio(tt.text, tt.surface) >= normalTextContrast {
210				require.GreaterOrEqual(t, ContrastRatio(got, tt.surface), normalTextContrast)
211			}
212		})
213	}
214}
215
216func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
217	ramp := neutralRamp(rampTheme(false))
218	require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
219}
220
221func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
222	theme := rampTheme(false)
223	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
224	theme.Palette[10] = theme.Palette[2]
225	theme.PaletteKnown = 1<<2 | 1<<10
226	resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
227
228	require.True(t, resolved.Accent.Known)
229	require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
230	require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
231	require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
232	require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
233	require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
234	require.True(t, resolved.Styles.TabActive.Bold)
235	require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
236	require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PickerBase)
237	require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PromptBase)
238	require.False(t, resolved.Styles.PickerDescription.HasBackgroundRGB)
239	require.False(t, resolved.Styles.PickerSeparator.HasBackgroundRGB)
240	require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
241	require.True(t, resolved.Styles.PickerSelection.Bold)
242}
243
244func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
245	theme := rampTheme(false)
246	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
247	theme.PaletteKnown = 1 << 2
248	indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
249	require.False(t, indexed.Accent.IndexedOnly)
250	for name, style := range map[string]renderer.Style{
251		"active tab":       indexed.Styles.TabActive,
252		"active title":     indexed.Styles.TabActiveTitle,
253		"picker selection": indexed.Styles.PickerSelection,
254		"search selection": indexed.Styles.SearchSelection,
255	} {
256		t.Run(name, func(t *testing.T) {
257			require.True(t, style.HasBackgroundRGB)
258			require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
259		})
260	}
261
262	theme.UsePalette = false
263	off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
264	require.False(t, off.Accent.Known)
265	require.Equal(t, neutralStyles(theme), off.Styles)
266
267	insufficient := rampTheme(false)
268	insufficient.Foreground = insufficient.Background
269	insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
270	insufficient.PaletteKnown = 1 << 2
271	neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
272	require.Equal(t, neutralStyles(insufficient), neutral.Styles)
273}
274
275func rampTheme(light bool) Theme {
276	if light {
277		return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
278	}
279	return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, Known: true, UsePalette: true}
280}

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