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

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