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 Unverified
B5690EEEBB952194The 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) { | |||
| 18 | 18 | require.Less(t, style.BackgroundRGB.R, previous) | |
| 19 | 19 | 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 | } | |
| @@ -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.R | 19 | 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 @@ | |||
| 1 | 1 | package theme | |
| 2 | 2 | ||
| 3 | - | import ( | |
| 4 | - | "math" | |
| 5 | - | ||
| 6 | - | renderer "github.com/bnema/vev-vt" | |
| 7 | - | ) | |
| 3 | + | import renderer "github.com/bnema/vev-vt" | |
| 8 | 4 | ||
| 9 | 5 | const ( | |
| 10 | 6 | normalTextContrast = 4.5 | |
| @@ -32,11 +28,6 @@ type Ramp struct { | |||
| 32 | 28 | ||
| 33 | 29 | background renderer.RGB | |
| 34 | 30 | 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 | 31 | rgb bool | |
| 41 | 32 | } | |
| 42 | 33 | ||
| @@ -47,24 +38,24 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||
| 47 | 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 | 42 | if !ok { | |
| 52 | 43 | return neutralRamp(t) | |
| 53 | 44 | } | |
| 54 | - | inactive, inactiveWeight, ok := surfaceAtOrBelow(t, accent.RGB, 14) | |
| 45 | + | inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14) | |
| 55 | 46 | if !ok { | |
| 56 | 47 | return neutralRamp(t) | |
| 57 | 48 | } | |
| 58 | - | recent, _, ok := surfaceAtOrBelow(t, accent.RGB, 22) | |
| 49 | + | recent, ok := surfaceAtOrBelow(t, accent.RGB, 22) | |
| 59 | 50 | if !ok { | |
| 60 | 51 | return neutralRamp(t) | |
| 61 | 52 | } | |
| 62 | - | active, activeWeight, ok := surfaceAtOrBelow(t, accent.RGB, 100) | |
| 53 | + | active, ok := surfaceAtOrBelow(t, accent.RGB, 100) | |
| 63 | 54 | if !ok { | |
| 64 | 55 | return neutralRamp(t) | |
| 65 | 56 | } | |
| 66 | 57 | ||
| 67 | - | ramp := Ramp{ | |
| 58 | + | return Ramp{ | |
| 68 | 59 | SurfaceBar: bar, | |
| 69 | 60 | SurfaceInactive: inactive, | |
| 70 | 61 | SurfaceRecent: recent, | |
| @@ -74,16 +65,13 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||
| 74 | 65 | BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 75 | 66 | background: t.Background, | |
| 76 | 67 | foreground: t.Foreground, | |
| 77 | - | accent: accent.RGB, | |
| 78 | 68 | rgb: true, | |
| 79 | 69 | } | |
| 80 | - | ramp.scanMRUWeights(t, activeWeight, inactiveWeight) | |
| 81 | - | return ramp | |
| 82 | 70 | } | |
| 83 | 71 | ||
| 84 | 72 | // surfaceAtOrBelow searches from the requested intensity to neutral. The | |
| 85 | 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 | 75 | for weight := target; weight >= 0; weight-- { | |
| 88 | 76 | background := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 89 | 77 | foreground, ok := primaryText(t, background) | |
| @@ -93,9 +81,9 @@ func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, | |||
| 93 | 81 | if _, ok := secondaryText(foreground, background); !ok { | |
| 94 | 82 | continue | |
| 95 | 83 | } | |
| 96 | - | return rgbSurface(foreground, background), weight, true | |
| 84 | + | return rgbSurface(foreground, background), true | |
| 97 | 85 | } | |
| 98 | - | return renderer.Style{}, 0, false | |
| 86 | + | return renderer.Style{}, false | |
| 99 | 87 | } | |
| 100 | 88 | ||
| 101 | 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 ( | |
| 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)) | |
| 220 | 186 | } | |
| 221 | 187 | ||
| 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. | |
| 226 | 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 | 193 | return ramp.SurfaceRecent | |
| 229 | 194 | } | |
| 230 | 195 | 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) | |
| 246 | 198 | foreground, _ := primaryText(Theme{Foreground: ramp.foreground, Background: ramp.background}, background) | |
| 247 | 199 | return rgbSurface(foreground, background) | |
| 248 | 200 | } | |
| @@ -1,10 +1,6 @@ | |||||
| 1 | package theme | 1 | 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.5 | 6 | normalTextContrast = 4.5 | ||
| @@ -32,11 +28,6 @@ type Ramp struct { | |||||
| 32 | 28 | ||||
| 33 | background renderer.RGB | 29 | background renderer.RGB | ||
| 34 | foreground renderer.RGB | 30 | 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 bool | 31 | 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. The | 72 | // 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 | continue | 82 | continue | ||
| 95 | } | 83 | } | ||
| 96 | - | return rgbSurface(foreground, background), weight, true | 84 | + | return rgbSurface(foreground, background), true |
| 97 | } | 85 | } | ||
| 98 | - | return renderer.Style{}, 0, false | 86 | + | 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 active | 181 | + | // history entry. The newest entry always sits halfway; the remaining entries |
| 194 | - | // accent. mruFloorWeight keeps the oldest entry above the inactive and | 182 | + | // 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 = 85 | 184 | + | func mruWeight(index, count int) float64 { |
| 197 | - | mruFloorWeight = 30 | 185 | + | 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 toward | 188 | + | // MRUStyle places history entries by mruWeight between the bar and the active |
| 223 | - | // the bar, recomputed per count. Targets are spaced linearly in accent weight | 189 | + | // 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 monotonically | 190 | + | // 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.SurfaceRecent | 193 | 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) { | |||
| 121 | 121 | } | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | - | func TestMRUStyleFadesFromNearActiveTowardBar(t *testing.T) { | |
| 124 | + | func TestMRUWeight(t *testing.T) { | |
| 125 | 125 | 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 | |
| 130 | 146 | }{ | |
| 131 | 147 | {name: "dark teal", theme: rampTheme(false), accent: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}}, | |
| 132 | 148 | {name: "dark blue", theme: rampTheme(false), accent: renderer.RGB{R: 0x3b, G: 0x82, B: 0xf6}}, | |
| 133 | 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 | 152 | for _, tt := range tests { | |
| 137 | 153 | t.Run(tt.name, func(t *testing.T) { | |
| 138 | 154 | ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true}) | |
| 139 | 155 | 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 | |
| 145 | 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 | 158 | for index := range count { | |
| 159 | + | weight := mruWeight(index, count) | |
| 153 | 160 | 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) | |
| 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) { | |
| 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)) | |
| 170 | 173 | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3)) | |
| 171 | 174 | } | |
| 172 | 175 | ||
| @@ -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 string | 126 | + | count int |
| 127 | - | theme Theme | 127 | + | want []float64 |
| 128 | - | accent renderer.RGB | 128 | + | }{ |
| 129 | - | fallback bool | 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 | |||
| 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 { | |||
| 164 | 164 | if usable(t) { | |
| 165 | 165 | styles.TabActive = EmphasisStyle(rgbSurface(t.Background, t.Foreground), t) | |
| 166 | 166 | 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. | |
| 169 | 169 | for count := 1; count <= len(styles.mruStyles); count++ { | |
| 170 | 170 | for index := range count { | |
| 171 | - | amount := float64(index+1) / float64(count) | |
| 171 | + | amount := 1 - mruWeight(index, count) | |
| 172 | 172 | background := Blend(t.Foreground, status.BackgroundRGB, amount) | |
| 173 | 173 | foreground, ok := primaryText(t, background) | |
| 174 | 174 | 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.TabActive | 166 | styles.TabNameActive = styles.TabActive | ||
| 167 | - | // Keep the neutral history ordered visually, even without a palette | 167 | + | // 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 { | ||