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, foreground, style.ForegroundRGB)
164				}
165			}
166			require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
167		})
168	}
169}
170
171func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
172	ramp := neutralRamp(rampTheme(false))
173	require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
174}
175
176func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) {
177	theme := rampTheme(false)
178	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
179	theme.Palette[10] = theme.Palette[2]
180	theme.PaletteKnown = 1<<2 | 1<<10
181	resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})
182
183	require.True(t, resolved.Accent.Known)
184	require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar)
185	require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive)
186	require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive)
187	require.Equal(t, resolved.Ramp.BorderWarn, resolved.Styles.BorderWarn)
188	require.Equal(t, foregroundStyle(Blend(theme.Foreground, theme.Background, 0.40)), resolved.Styles.NeutralBorder)
189	require.True(t, resolved.Styles.TabActive.Bold)
190	require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent)
191	require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PickerBase)
192	require.Equal(t, renderer.DefaultStyle(), resolved.Styles.PromptBase)
193	require.False(t, resolved.Styles.PickerDescription.HasBackgroundRGB)
194	require.False(t, resolved.Styles.PickerSeparator.HasBackgroundRGB)
195	require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB)
196	require.True(t, resolved.Styles.PickerSelection.Bold)
197}
198
199func TestResolveANSI256AndPaletteOffSurfaces(t *testing.T) {
200	theme := rampTheme(false)
201	theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
202	theme.PaletteKnown = 1 << 2
203	indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
204	require.False(t, indexed.Accent.IndexedOnly)
205	for name, style := range map[string]renderer.Style{
206		"active tab":       indexed.Styles.TabActive,
207		"active title":     indexed.Styles.TabActiveTitle,
208		"picker selection": indexed.Styles.PickerSelection,
209		"search selection": indexed.Styles.SearchSelection,
210	} {
211		t.Run(name, func(t *testing.T) {
212			require.True(t, style.HasBackgroundRGB)
213			require.Equal(t, indexed.Styles.SurfaceActive.BackgroundRGB, style.BackgroundRGB)
214		})
215	}
216
217	theme.UsePalette = false
218	off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
219	require.False(t, off.Accent.Known)
220	require.Equal(t, neutralStyles(theme), off.Styles)
221
222	insufficient := rampTheme(false)
223	insufficient.Foreground = insufficient.Background
224	insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}
225	insufficient.PaletteKnown = 1 << 2
226	neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2})
227	require.Equal(t, neutralStyles(insufficient), neutral.Styles)
228}
229
230func rampTheme(light bool) Theme {
231	if light {
232		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}
233	}
234	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}
235}

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