v0.3.0 99dc266

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, TrueColor: 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 TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) {
125	theme := rampTheme(false)
126	ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
127	require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1))
128	require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
129	require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB)
130	require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB)
131}
132
133func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
134	theme := rampTheme(false)
135	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
136	theme.Palette[10] = theme.Palette[2]
137	theme.PaletteKnown = 1<<2 | 1<<10
138	resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
139
140	require.True(t, resolved.Accent.Known)
141	require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
142	require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
143	require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
144	require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
145	require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
146	require.True(t, resolved.Styles.TabActive.Bold)
147	require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
148	require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PickerBase)
149	require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PromptBase)
150	require.False(t, resolved.Styles.PickerDescription.HasBackgroundRGB)
151	require.False(t, resolved.Styles.PickerSeparator.HasBackgroundRGB)
152	require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
153	require.True(t, resolved.Styles.PickerSelection.Bold)
154}
155
156func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) {
157	theme := rampTheme(false)
158	theme.TrueColor = false
159	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
160	theme.PaletteKnown = 1 << 2
161	indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
162	require.True(t, indexed.Accent.IndexedOnly)
163	require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB)
164	require.Equal(t, 2, indexed.Styles.BorderActive.Foreground)
165	require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB)
166	require.Equal(t, 2, indexed.Styles.BorderWarn.Foreground)
167	require.False(t, indexed.Styles.BorderWarn.HasBackgroundRGB)
168
169	theme.TrueColor = true
170	theme.UsePalette = false
171	off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
172	require.False(t, off.Accent.Known)
173	require.Equal(t, neutralStyles(theme), off.Styles)
174
175	insufficient := rampTheme(false)
176	insufficient.Foreground = insufficient.Background
177	insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
178	insufficient.PaletteKnown = 1 << 2
179	neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
180	require.Equal(t, neutralStyles(insufficient), neutral.Styles)
181}
182
183func rampTheme(light bool) Theme {
184	if light {
185		return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
186	}
187	return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true}
188}

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