detached 6f8be80

fix(theme): place MRU history by fixed halfway step and even split (#309)

* fix(theme): space MRU history evenly between active and bar surfaces

* fix(theme): pin newest MRU entry halfway and split the rest evenly

* refactor(theme): drop unused ramp accent field
commit
6f8be80047eb31fd90910c8cd2193cf1765ea852
author
bnema · · 2026-10-04 10:07 UTC
committer
GitHub · · 2026-10-04 10:07 UTC
parent
13bce97
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
4 files · +55 -100
M internal/usecase/theme/neutral_history_test.go +1 -1
@@ -18,6 +18,6 @@ func TestNeutralHistoryGradient(t *testing.T) {
1818 require.Less(t, style.BackgroundRGB.R, previous)
1919 previous = style.BackgroundRGB.R
2020 }
21- require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.MRUStyle(count-1, count).BackgroundRGB)
21+ require.Greater(t, styles.MRUStyle(count-1, count).BackgroundRGB.R, styles.SurfaceBar.BackgroundRGB.R, "oldest stays one step above the bar")
2222 }
2323 }
@@ -18,6 +18,6 @@ func TestNeutralHistoryGradient(t *testing.T) {
18 require.Less(t, style.BackgroundRGB.R, previous)18 require.Less(t, style.BackgroundRGB.R, previous)
19 previous = style.BackgroundRGB.R19 previous = style.BackgroundRGB.R
20 }20 }
21- require.Equal(t, styles.SurfaceBar.BackgroundRGB, styles.MRUStyle(count-1, count).BackgroundRGB)21+ require.Greater(t, styles.MRUStyle(count-1, count).BackgroundRGB.R, styles.SurfaceBar.BackgroundRGB.R, "oldest stays one step above the bar")
22 }22 }
23 }23 }
M internal/usecase/theme/ramp.go +21 -69
@@ -1,10 +1,6 @@
11 package theme
22
3-import (
4- "math"
5-
6- renderer "github.com/bnema/vev-vt"
7-)
3+import renderer "github.com/bnema/vev-vt"
84
95 const (
106 normalTextContrast = 4.5
@@ -32,11 +28,6 @@ type Ramp struct {
3228
3329 background renderer.RGB
3430 foreground renderer.RGB
35- accent renderer.RGB
36- // mruWeights lists, strongest first, the accent weights usable for the
37- // recent-session history; it is scanned once per ramp.
38- mruWeights [mruTopWeight - mruFloorWeight + 1]uint8
39- mruCount int
4031 rgb bool
4132 }
4233
@@ -47,24 +38,24 @@ func BuildRamp(t Theme, accent Accent) Ramp {
4738 return neutralRamp(t)
4839 }
4940
50- bar, _, ok := surfaceAtOrBelow(t, accent.RGB, 8)
41+ bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
5142 if !ok {
5243 return neutralRamp(t)
5344 }
54- inactive, inactiveWeight, ok := surfaceAtOrBelow(t, accent.RGB, 14)
45+ inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
5546 if !ok {
5647 return neutralRamp(t)
5748 }
58- recent, _, ok := surfaceAtOrBelow(t, accent.RGB, 22)
49+ recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
5950 if !ok {
6051 return neutralRamp(t)
6152 }
62- active, activeWeight, ok := surfaceAtOrBelow(t, accent.RGB, 100)
53+ active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
6354 if !ok {
6455 return neutralRamp(t)
6556 }
6657
67- ramp := Ramp{
58+ return Ramp{
6859 SurfaceBar: bar,
6960 SurfaceInactive: inactive,
7061 SurfaceRecent: recent,
@@ -74,16 +65,13 @@ func BuildRamp(t Theme, accent Accent) Ramp {
7465 BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
7566 background: t.Background,
7667 foreground: t.Foreground,
77- accent: accent.RGB,
7868 rgb: true,
7969 }
80- ramp.scanMRUWeights(t, activeWeight, inactiveWeight)
81- return ramp
8270 }
8371
8472 // surfaceAtOrBelow searches from the requested intensity to neutral. The
8573 // integer sequence is intentional: it makes contrast adaptation stable.
86-func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, int, bool) {
74+func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
8775 for weight := target; weight >= 0; weight-- {
8876 background := okLabLerp(t.Background, accent, float64(weight)/100)
8977 foreground, ok := primaryText(t, background)
@@ -93,9 +81,9 @@ func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style,
9381 if _, ok := secondaryText(foreground, background); !ok {
9482 continue
9583 }
96- return rgbSurface(foreground, background), weight, true
84+ return rgbSurface(foreground, background), true
9785 }
98- return renderer.Style{}, 0, false
86+ return renderer.Style{}, false
9987 }
10088
10189 func rgbSurface(foreground, background renderer.RGB) renderer.Style {
@@ -189,60 +177,24 @@ func neutralRamp(t Theme) Ramp {
189177 }
190178 }
191179
192-const (
193- // mruTopWeight is the newest history entry: a slightly faded active
194- // accent. mruFloorWeight keeps the oldest entry above the inactive and
195- // bar surfaces so the whole history stays visibly accent-tinted.
196- mruTopWeight = 85
197- mruFloorWeight = 30
198-)
199-
200-// scanMRUWeights records the readable history weights, strongest first. It
201-// stays strictly between the active and inactive surfaces; when the main
202-// range has no readable weight it uses the whole gap between them, so extreme
203-// themes still get an ordered fade that never sinks below inactive tabs.
204-func (r *Ramp) scanMRUWeights(t Theme, activeWeight, inactiveWeight int) {
205- r.mruCount = 0
206- r.collectMRUWeights(t, min(mruTopWeight, activeWeight-1), mruFloorWeight)
207- if r.mruCount == 0 {
208- r.collectMRUWeights(t, min(mruFloorWeight-1, activeWeight-1), inactiveWeight+1)
209- }
210-}
211-
212-func (r *Ramp) collectMRUWeights(t Theme, top, floor int) {
213- for weight := top; weight >= floor; weight-- {
214- background := okLabLerp(t.Background, r.accent, float64(weight)/100)
215- if _, ok := primaryText(t, background); ok {
216- r.mruWeights[r.mruCount] = uint8(weight)
217- r.mruCount++
218- }
219- }
180+// mruWeight is the share of the way from the bar to the active surface for a
181+// history entry. The newest entry always sits halfway; the remaining entries
182+// split that lower half into equal steps, so the oldest stays one step above
183+// the bar: 1 entry → 50; 2 → 50, 25; 3 → 50, 33, 17.
184+func mruWeight(index, count int) float64 {
185+ return 0.5 * (1 - float64(index)/float64(count))
220186 }
221187
222-// MRUStyle fades history entries from just below the active accent toward
223-// the bar, recomputed per count. Targets are spaced linearly in accent weight
224-// and snapped to the nearest readable weight, so the fade is monotonically
225-// non-increasing (strict when enough readable weights exist).
188+// MRUStyle places history entries by mruWeight between the bar and the active
189+// surface. The background is never moved for contrast; only the text picks the
190+// better of the theme foreground and background.
226191 func MRUStyle(ramp Ramp, index, count int) renderer.Style {
227- if !ramp.rgb || count <= 0 || ramp.mruCount == 0 {
192+ if !ramp.rgb || count <= 0 {
228193 return ramp.SurfaceRecent
229194 }
230195 index = max(0, min(index, count-1))
231-
232- weights := ramp.mruWeights[:ramp.mruCount]
233- top, bottom := float64(weights[0]), float64(weights[len(weights)-1])
234- target := top
235- if count > 1 {
236- target = top - (top-bottom)*float64(index)/float64(count-1)
237- }
238- weight := weights[0]
239- for _, candidate := range weights[1:] {
240- if math.Abs(float64(candidate)-target) < math.Abs(float64(weight)-target) {
241- weight = candidate
242- }
243- }
244-
245- background := okLabLerp(ramp.background, ramp.accent, float64(weight)/100)
196+ weight := mruWeight(index, count)
197+ background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
246198 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
247199 return rgbSurface(foreground, background)
248200 }
@@ -1,10 +1,6 @@
1 package theme1 package theme
2 2
3-import (3+import renderer "github.com/bnema/vev-vt"
4- "math"
5-
6- renderer "github.com/bnema/vev-vt"
7-)
8 4
9 const (5 const (
10 normalTextContrast = 4.56 normalTextContrast = 4.5
@@ -32,11 +28,6 @@ type Ramp struct {
32 28
33 background renderer.RGB29 background renderer.RGB
34 foreground renderer.RGB30 foreground renderer.RGB
35- accent renderer.RGB
36- // mruWeights lists, strongest first, the accent weights usable for the
37- // recent-session history; it is scanned once per ramp.
38- mruWeights [mruTopWeight - mruFloorWeight + 1]uint8
39- mruCount int
40 rgb bool31 rgb bool
41 }32 }
42 33
@@ -47,24 +38,24 @@ func BuildRamp(t Theme, accent Accent) Ramp {
47 return neutralRamp(t)38 return neutralRamp(t)
48 }39 }
49 40
50- bar, _, ok := surfaceAtOrBelow(t, accent.RGB, 8)41+ bar, ok := surfaceAtOrBelow(t, accent.RGB, 8)
51 if !ok {42 if !ok {
52 return neutralRamp(t)43 return neutralRamp(t)
53 }44 }
54- inactive, inactiveWeight, ok := surfaceAtOrBelow(t, accent.RGB, 14)45+ inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14)
55 if !ok {46 if !ok {
56 return neutralRamp(t)47 return neutralRamp(t)
57 }48 }
58- recent, _, ok := surfaceAtOrBelow(t, accent.RGB, 22)49+ recent, ok := surfaceAtOrBelow(t, accent.RGB, 22)
59 if !ok {50 if !ok {
60 return neutralRamp(t)51 return neutralRamp(t)
61 }52 }
62- active, activeWeight, ok := surfaceAtOrBelow(t, accent.RGB, 100)53+ active, ok := surfaceAtOrBelow(t, accent.RGB, 100)
63 if !ok {54 if !ok {
64 return neutralRamp(t)55 return neutralRamp(t)
65 }56 }
66 57
67- ramp := Ramp{58+ return Ramp{
68 SurfaceBar: bar,59 SurfaceBar: bar,
69 SurfaceInactive: inactive,60 SurfaceInactive: inactive,
70 SurfaceRecent: recent,61 SurfaceRecent: recent,
@@ -74,16 +65,13 @@ func BuildRamp(t Theme, accent Accent) Ramp {
74 BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),65 BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB),
75 background: t.Background,66 background: t.Background,
76 foreground: t.Foreground,67 foreground: t.Foreground,
77- accent: accent.RGB,
78 rgb: true,68 rgb: true,
79 }69 }
80- ramp.scanMRUWeights(t, activeWeight, inactiveWeight)
81- return ramp
82 }70 }
83 71
84 // surfaceAtOrBelow searches from the requested intensity to neutral. The72 // surfaceAtOrBelow searches from the requested intensity to neutral. The
85 // integer sequence is intentional: it makes contrast adaptation stable.73 // integer sequence is intentional: it makes contrast adaptation stable.
86-func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, int, bool) {74+func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) {
87 for weight := target; weight >= 0; weight-- {75 for weight := target; weight >= 0; weight-- {
88 background := okLabLerp(t.Background, accent, float64(weight)/100)76 background := okLabLerp(t.Background, accent, float64(weight)/100)
89 foreground, ok := primaryText(t, background)77 foreground, ok := primaryText(t, background)
@@ -93,9 +81,9 @@ func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style,
93 if _, ok := secondaryText(foreground, background); !ok {81 if _, ok := secondaryText(foreground, background); !ok {
94 continue82 continue
95 }83 }
96- return rgbSurface(foreground, background), weight, true84+ return rgbSurface(foreground, background), true
97 }85 }
98- return renderer.Style{}, 0, false86+ return renderer.Style{}, false
99 }87 }
100 88
101 func rgbSurface(foreground, background renderer.RGB) renderer.Style {89 func rgbSurface(foreground, background renderer.RGB) renderer.Style {
@@ -189,60 +177,24 @@ func neutralRamp(t Theme) Ramp {
189 }177 }
190 }178 }
191 179
192-const (180+// mruWeight is the share of the way from the bar to the active surface for a
193- // mruTopWeight is the newest history entry: a slightly faded active181+// history entry. The newest entry always sits halfway; the remaining entries
194- // accent. mruFloorWeight keeps the oldest entry above the inactive and182+// split that lower half into equal steps, so the oldest stays one step above
195- // bar surfaces so the whole history stays visibly accent-tinted.183+// the bar: 1 entry → 50; 2 → 50, 25; 3 → 50, 33, 17.
196- mruTopWeight = 85184+func mruWeight(index, count int) float64 {
197- mruFloorWeight = 30185+ return 0.5 * (1 - float64(index)/float64(count))
198-)
199-
200-// scanMRUWeights records the readable history weights, strongest first. It
201-// stays strictly between the active and inactive surfaces; when the main
202-// range has no readable weight it uses the whole gap between them, so extreme
203-// themes still get an ordered fade that never sinks below inactive tabs.
204-func (r *Ramp) scanMRUWeights(t Theme, activeWeight, inactiveWeight int) {
205- r.mruCount = 0
206- r.collectMRUWeights(t, min(mruTopWeight, activeWeight-1), mruFloorWeight)
207- if r.mruCount == 0 {
208- r.collectMRUWeights(t, min(mruFloorWeight-1, activeWeight-1), inactiveWeight+1)
209- }
210-}
211-
212-func (r *Ramp) collectMRUWeights(t Theme, top, floor int) {
213- for weight := top; weight >= floor; weight-- {
214- background := okLabLerp(t.Background, r.accent, float64(weight)/100)
215- if _, ok := primaryText(t, background); ok {
216- r.mruWeights[r.mruCount] = uint8(weight)
217- r.mruCount++
218- }
219- }
220 }186 }
221 187
222-// MRUStyle fades history entries from just below the active accent toward188+// MRUStyle places history entries by mruWeight between the bar and the active
223-// the bar, recomputed per count. Targets are spaced linearly in accent weight189+// surface. The background is never moved for contrast; only the text picks the
224-// and snapped to the nearest readable weight, so the fade is monotonically190+// better of the theme foreground and background.
225-// non-increasing (strict when enough readable weights exist).
226 func MRUStyle(ramp Ramp, index, count int) renderer.Style {191 func MRUStyle(ramp Ramp, index, count int) renderer.Style {
227- if !ramp.rgb || count <= 0 || ramp.mruCount == 0 {192+ if !ramp.rgb || count <= 0 {
228 return ramp.SurfaceRecent193 return ramp.SurfaceRecent
229 }194 }
230 index = max(0, min(index, count-1))195 index = max(0, min(index, count-1))
231-196+ weight := mruWeight(index, count)
232- weights := ramp.mruWeights[:ramp.mruCount]197+ background := okLabLerp(ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB, weight)
233- top, bottom := float64(weights[0]), float64(weights[len(weights)-1])
234- target := top
235- if count > 1 {
236- target = top - (top-bottom)*float64(index)/float64(count-1)
237- }
238- weight := weights[0]
239- for _, candidate := range weights[1:] {
240- if math.Abs(float64(candidate)-target) < math.Abs(float64(weight)-target) {
241- weight = candidate
242- }
243- }
244-
245- background := okLabLerp(ramp.background, ramp.accent, float64(weight)/100)
246 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)198 foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background)
247 return rgbSurface(foreground, background)199 return rgbSurface(foreground, background)
248 }200 }
M internal/usecase/theme/ramp_test.go +30 -27
@@ -121,52 +121,55 @@ func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
121121 }
122122 }
123123
124-func TestMRUStyleFadesFromNearActiveTowardBar(t *testing.T) {
124+func TestMRUWeight(t *testing.T) {
125125 tests := []struct {
126- name string
127- theme Theme
128- accent renderer.RGB
129- fallback bool
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+
141+func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
142+ tests := []struct {
143+ name string
144+ theme Theme
145+ accent renderer.RGB
130146 }{
131147 {name: "dark teal", theme: rampTheme(false), accent: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},
132148 {name: "dark blue", theme: rampTheme(false), accent: renderer.RGB{R: 0x3b, G: 0x82, B: 0xf6}},
133149 {name: "light teal", theme: rampTheme(true), accent: renderer.RGB{R: 0x2a, G: 0x7a, B: 0x7a}},
134- {name: "low contrast grey with blue fallback", 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}, fallback: true},
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}},
135151 }
136152 for _, tt := range tests {
137153 t.Run(tt.name, func(t *testing.T) {
138154 ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true})
139155 require.True(t, ramp.rgb)
140- require.NotZero(t, ramp.mruCount)
141- require.Equal(t, tt.fallback, int(ramp.mruWeights[0]) < mruFloorWeight, "fallback range use")
142- distance := func(style renderer.Style) float64 {
143- return okLabDistance(rgbToOKLab(style.BackgroundRGB), rgbToOKLab(tt.theme.Background))
144- }
156+ bar, active := ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB
145157 for count := 1; count <= 9; count++ {
146- newest := MRUStyle(ramp, 0, count)
147- require.NotEqual(t, ramp.SurfaceActive.BackgroundRGB, newest.BackgroundRGB, "newest differs from active")
148- if !tt.fallback {
149- require.GreaterOrEqual(t, distance(newest), 0.7*distance(ramp.SurfaceActive), "newest stays close to the active accent")
150- }
151- previous := distance(ramp.SurfaceActive)
152158 for index := range count {
159+ weight := mruWeight(index, count)
153160 style := MRUStyle(ramp, index, count)
154- require.LessOrEqual(t, distance(style), previous, "history fades with age")
155- require.Greater(t, distance(style), distance(ramp.SurfaceInactive), "oldest stays above inactive")
156- require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast)
157- previous = distance(style)
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)
158164 }
159165 }
166+ require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
160167 })
161168 }
162169 }
163170
164-func TestMRUStyleWithoutReadableWeightKeepsRecentSurface(t *testing.T) {
165- theme := rampTheme(false)
166- ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
167- ramp.scanMRUWeights(theme, 15, 14)
168-
169- require.Zero(t, ramp.mruCount)
171+func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
172+ ramp := neutralRamp(rampTheme(false))
170173 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
171174 }
172175
@@ -121,52 +121,55 @@ func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) {
121 }121 }
122 }122 }
123 123
124-func TestMRUStyleFadesFromNearActiveTowardBar(t *testing.T) {124+func TestMRUWeight(t *testing.T) {
125 tests := []struct {125 tests := []struct {
126- name string126+ count int
127- theme Theme127+ want []float64
128- accent renderer.RGB128+ }{
129- fallback bool129+ {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+
141+func TestMRUStylePlacesHistoryBetweenActiveAndBar(t *testing.T) {
142+ tests := []struct {
143+ name string
144+ theme Theme
145+ accent renderer.RGB
130 }{146 }{
131 {name: "dark teal", theme: rampTheme(false), accent: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},147 {name: "dark teal", theme: rampTheme(false), accent: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}},
132 {name: "dark blue", theme: rampTheme(false), accent: renderer.RGB{R: 0x3b, G: 0x82, B: 0xf6}},148 {name: "dark blue", theme: rampTheme(false), accent: renderer.RGB{R: 0x3b, G: 0x82, B: 0xf6}},
133 {name: "light teal", theme: rampTheme(true), accent: renderer.RGB{R: 0x2a, G: 0x7a, B: 0x7a}},149 {name: "light teal", theme: rampTheme(true), accent: renderer.RGB{R: 0x2a, G: 0x7a, B: 0x7a}},
134- {name: "low contrast grey with blue fallback", 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}, fallback: true},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}},
135 }151 }
136 for _, tt := range tests {152 for _, tt := range tests {
137 t.Run(tt.name, func(t *testing.T) {153 t.Run(tt.name, func(t *testing.T) {
138 ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true})154 ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true})
139 require.True(t, ramp.rgb)155 require.True(t, ramp.rgb)
140- require.NotZero(t, ramp.mruCount)156+ bar, active := ramp.SurfaceBar.BackgroundRGB, ramp.SurfaceActive.BackgroundRGB
141- require.Equal(t, tt.fallback, int(ramp.mruWeights[0]) < mruFloorWeight, "fallback range use")
142- distance := func(style renderer.Style) float64 {
143- return okLabDistance(rgbToOKLab(style.BackgroundRGB), rgbToOKLab(tt.theme.Background))
144- }
145 for count := 1; count <= 9; count++ {157 for count := 1; count <= 9; count++ {
146- newest := MRUStyle(ramp, 0, count)
147- require.NotEqual(t, ramp.SurfaceActive.BackgroundRGB, newest.BackgroundRGB, "newest differs from active")
148- if !tt.fallback {
149- require.GreaterOrEqual(t, distance(newest), 0.7*distance(ramp.SurfaceActive), "newest stays close to the active accent")
150- }
151- previous := distance(ramp.SurfaceActive)
152 for index := range count {158 for index := range count {
159+ weight := mruWeight(index, count)
153 style := MRUStyle(ramp, index, count)160 style := MRUStyle(ramp, index, count)
154- require.LessOrEqual(t, distance(style), previous, "history fades with age")161+ require.Equal(t, okLabLerp(bar, active, weight), style.BackgroundRGB, "count=%d index=%d", count, index)
155- require.Greater(t, distance(style), distance(ramp.SurfaceInactive), "oldest stays above inactive")162+ foreground, _ := primaryText(tt.theme, style.BackgroundRGB)
156- require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast)163+ require.Equal(t, foreground, style.ForegroundRGB)
157- previous = distance(style)
158 }164 }
159 }165 }
166+ require.Equal(t, okLabLerp(bar, active, 0.5), MRUStyle(ramp, 0, 1).BackgroundRGB, "the newest entry always sits halfway")
160 })167 })
161 }168 }
162 }169 }
163 170
164-func TestMRUStyleWithoutReadableWeightKeepsRecentSurface(t *testing.T) {171+func TestMRUStyleWithoutRGBKeepsRecentSurface(t *testing.T) {
165- theme := rampTheme(false)172+ ramp := neutralRamp(rampTheme(false))
166- ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true})
167- ramp.scanMRUWeights(theme, 15, 14)
168-
169- require.Zero(t, ramp.mruCount)
170 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))173 require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3))
171 }174 }
172 175
M internal/usecase/theme/resolved_styles.go +3 -3
@@ -164,11 +164,11 @@ func neutralStyles(t Theme) Styles {
164164 if usable(t) {
165165 styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t)
166166 styles.TabNameActive = styles.TabActive
167- // Keep the neutral history ordered visually, even without a palette
168- // accent: recent entries fade into the bar as their rank increases.
167+ // Without a palette accent the history uses the same mruWeight
168+ // placement as MRUStyle, between the active tab and the bar.
169169 for count := 1; count <= len(styles.mruStyles); count++ {
170170 for index := range count {
171- amount := float64(index+1) / float64(count)
171+ amount := 1 - mruWeight(index, count)
172172 background := Blend(t.Foreground, status.BackgroundRGB, amount)
173173 foreground, ok := primaryText(t, background)
174174 if !ok {
@@ -164,11 +164,11 @@ func neutralStyles(t Theme) Styles {
164 if usable(t) {164 if usable(t) {
165 styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t)165 styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t)
166 styles.TabNameActive = styles.TabActive166 styles.TabNameActive = styles.TabActive
167- // Keep the neutral history ordered visually, even without a palette167+ // Without a palette accent the history uses the same mruWeight
168- // accent: recent entries fade into the bar as their rank increases.168+ // placement as MRUStyle, between the active tab and the bar.
169 for count := 1; count <= len(styles.mruStyles); count++ {169 for count := 1; count <= len(styles.mruStyles); count++ {
170 for index := range count {170 for index := range count {
171- amount := float64(index+1) / float64(count)171+ amount := 1 - mruWeight(index, count)
172 background := Blend(t.Foreground, status.BackgroundRGB, amount)172 background := Blend(t.Foreground, status.BackgroundRGB, amount)
173 foreground, ok := primaryText(t, background)173 foreground, ok := primaryText(t, background)
174 if !ok {174 if !ok {

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