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 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 TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
157	theme := rampTheme(false)
158	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
159	theme.PaletteKnown = 1 << 2
160	indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
161	require.False(t, indexed.Accent.IndexedOnly)
162	for name, style := range map[string]renderer.Style{
163		"active tab":       indexed.Styles.TabActive,
164		"active title":     indexed.Styles.TabActiveTitle,
165		"picker selection": indexed.Styles.PickerSelection,
166		"search selection": indexed.Styles.SearchSelection,
167	} {
168		t.Run(name, func(t *testing.T) {
169			require.True(t, style.HasBackgroundRGB)
170			require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
171		})
172	}
173
174	theme.UsePalette = false
175	off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
176	require.False(t, off.Accent.Known)
177	require.Equal(t, neutralStyles(theme), off.Styles)
178
179	insufficient := rampTheme(false)
180	insufficient.Foreground = insufficient.Background
181	insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
182	insufficient.PaletteKnown = 1 << 2
183	neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
184	require.Equal(t, neutralStyles(insufficient), neutral.Styles)
185}
186
187func rampTheme(light bool) Theme {
188	if light {
189		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}
190	}
191	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}
192}

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