fix(theme): fade MRU history from near-active accent toward bar (#308)
* fix(theme): fade MRU history from near-active accent toward bar * fix(theme): precompute MRU weights and snap fade to linear targets * fix(theme): keep MRU fallback fade above inactive surfaces * refactor(theme): skip rescanning the main MRU range in the fallback * test(theme): drop removed TrueColor field from MRU fallback case
- commit
- 13bce977280c556d1d8a88fe6d41b3b4aa40c251
- author
- bnema · · 2026-10-03 17:51 UTC
- committer
- GitHub · · 2026-10-03 17:51 UTC
- parent
- 10d29b7
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
3 files · +116 -30
M internal/usecase/daemon/status_test.go +1 -1
| @@ -1411,7 +1411,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||
| 1411 | 1411 | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles) | |
| 1412 | 1412 | only := strings.Index(rowText(single), "only") | |
| 1413 | 1413 | require.GreaterOrEqual(t, only, 0) | |
| 1414 | - | require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface") | |
| 1414 | + | require.True(t, single[only].Style.Equal(themeui.MRUStyle(resolved.Ramp, 0, 1)), "a singleton MRU uses the newest history surface") | |
| 1415 | 1415 | }) | |
| 1416 | 1416 | ||
| 1417 | 1417 | t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) { | |
| @@ -1411,7 +1411,7 @@ func TestStatusBarsUseCompleteSemanticSurfaces(t *testing.T) { | |||||
| 1411 | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles) | 1411 | drawStatusBarState(single, barState{status: statusSnapshot{session: "current"}, mru: []recentRouteDisplay{{name: "only"}}}, styles) | ||
| 1412 | only := strings.Index(rowText(single), "only") | 1412 | only := strings.Index(rowText(single), "only") | ||
| 1413 | require.GreaterOrEqual(t, only, 0) | 1413 | require.GreaterOrEqual(t, only, 0) | ||
| 1414 | - | require.True(t, single[only].Style.Equal(styles.SurfaceRecent), "a singleton MRU must retain the 22 percent recent surface") | 1414 | + | require.True(t, single[only].Style.Equal(themeui.MRUStyle(resolved.Ramp, 0, 1)), "a singleton MRU uses the newest history surface") |
| 1415 | }) | 1415 | }) | ||
| 1416 | 1416 | ||||
| 1417 | t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) { | 1417 | t.Run("attention pulse preserves its active or inactive base surface", func(t *testing.T) { | ||
M internal/usecase/theme/ramp.go +71 -25
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package theme | |
| 2 | 2 | ||
| 3 | - | import renderer "github.com/bnema/vev-vt" | |
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | ||
| 6 | + | renderer "github.com/bnema/vev-vt" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | const ( | |
| 6 | 10 | normalTextContrast = 4.5 | |
| @@ -27,7 +31,12 @@ type Ramp struct { | |||
| 27 | 31 | BorderWarn renderer.Style | |
| 28 | 32 | ||
| 29 | 33 | background renderer.RGB | |
| 34 | + | foreground renderer.RGB | |
| 30 | 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 | |
| 31 | 40 | rgb bool | |
| 32 | 41 | } | |
| 33 | 42 | ||
| @@ -38,24 +47,24 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||
| 38 | 47 | return neutralRamp(t) | |
| 39 | 48 | } | |
| 40 | 49 | ||
| 41 | - | bar, ok := surfaceAtOrBelow(t, accent.RGB, 8) | |
| 50 | + | bar, _, ok := surfaceAtOrBelow(t, accent.RGB, 8) | |
| 42 | 51 | if !ok { | |
| 43 | 52 | return neutralRamp(t) | |
| 44 | 53 | } | |
| 45 | - | inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14) | |
| 54 | + | inactive, inactiveWeight, ok := surfaceAtOrBelow(t, accent.RGB, 14) | |
| 46 | 55 | if !ok { | |
| 47 | 56 | return neutralRamp(t) | |
| 48 | 57 | } | |
| 49 | - | recent, ok := surfaceAtOrBelow(t, accent.RGB, 22) | |
| 58 | + | recent, _, ok := surfaceAtOrBelow(t, accent.RGB, 22) | |
| 50 | 59 | if !ok { | |
| 51 | 60 | return neutralRamp(t) | |
| 52 | 61 | } | |
| 53 | - | active, ok := surfaceAtOrBelow(t, accent.RGB, 100) | |
| 62 | + | active, activeWeight, ok := surfaceAtOrBelow(t, accent.RGB, 100) | |
| 54 | 63 | if !ok { | |
| 55 | 64 | return neutralRamp(t) | |
| 56 | 65 | } | |
| 57 | 66 | ||
| 58 | - | return Ramp{ | |
| 67 | + | ramp := Ramp{ | |
| 59 | 68 | SurfaceBar: bar, | |
| 60 | 69 | SurfaceInactive: inactive, | |
| 61 | 70 | SurfaceRecent: recent, | |
| @@ -64,14 +73,17 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||
| 64 | 73 | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 65 | 74 | BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 66 | 75 | background: t.Background, | |
| 76 | + | foreground: t.Foreground, | |
| 67 | 77 | accent: accent.RGB, | |
| 68 | 78 | rgb: true, | |
| 69 | 79 | } | |
| 80 | + | ramp.scanMRUWeights(t, activeWeight, inactiveWeight) | |
| 81 | + | return ramp | |
| 70 | 82 | } | |
| 71 | 83 | ||
| 72 | 84 | // surfaceAtOrBelow searches from the requested intensity to neutral. The | |
| 73 | 85 | // integer sequence is intentional: it makes contrast adaptation stable. | |
| 74 | - | func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) { | |
| 86 | + | func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, int, bool) { | |
| 75 | 87 | for weight := target; weight >= 0; weight-- { | |
| 76 | 88 | background := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 77 | 89 | foreground, ok := primaryText(t, background) | |
| @@ -81,9 +93,9 @@ func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, | |||
| 81 | 93 | if _, ok := secondaryText(foreground, background); !ok { | |
| 82 | 94 | continue | |
| 83 | 95 | } | |
| 84 | - | return rgbSurface(foreground, background), true | |
| 96 | + | return rgbSurface(foreground, background), weight, true | |
| 85 | 97 | } | |
| 86 | - | return renderer.Style{}, false | |
| 98 | + | return renderer.Style{}, 0, false | |
| 87 | 99 | } | |
| 88 | 100 | ||
| 89 | 101 | func rgbSurface(foreground, background renderer.RGB) renderer.Style { | |
| @@ -177,26 +189,60 @@ func neutralRamp(t Theme) Ramp { | |||
| 177 | 189 | } | |
| 178 | 190 | } | |
| 179 | 191 | ||
| 180 | - | // MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for | |
| 181 | - | // the oldest entry. A singleton is therefore the full recent surface. | |
| 182 | - | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |
| 183 | - | if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB { | |
| 184 | - | return ramp.SurfaceRecent | |
| 185 | - | } | |
| 186 | - | if index < 0 { | |
| 187 | - | index = 0 | |
| 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) | |
| 188 | 209 | } | |
| 189 | - | if index >= count { | |
| 190 | - | index = count - 1 | |
| 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 | + | } | |
| 191 | 219 | } | |
| 192 | - | if !ramp.rgb { | |
| 220 | + | } | |
| 221 | + | ||
| 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). | |
| 226 | + | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |
| 227 | + | if !ramp.rgb || count <= 0 || ramp.mruCount == 0 { | |
| 193 | 228 | return ramp.SurfaceRecent | |
| 194 | 229 | } | |
| 195 | - | weight := 22.0 - 11.0*float64(index)/float64(count-1) | |
| 196 | - | background := okLabLerp(ramp.background, ramp.accent, weight/100) | |
| 197 | - | foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background) | |
| 198 | - | if !ok { | |
| 199 | - | return ramp.SurfaceRecent | |
| 230 | + | 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) | |
| 200 | 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) | |
| 201 | 247 | return rgbSurface(foreground, background) | |
| 202 | 248 | } | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package theme | 1 | package theme | ||
| 2 | 2 | ||||
| 3 | - | import renderer "github.com/bnema/vev-vt" | 3 | + | import ( |
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | renderer "github.com/bnema/vev-vt" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | const ( | 9 | const ( | ||
| 6 | normalTextContrast = 4.5 | 10 | normalTextContrast = 4.5 | ||
| @@ -27,7 +31,12 @@ type Ramp struct { | |||||
| 27 | BorderWarn renderer.Style | 31 | BorderWarn renderer.Style | ||
| 28 | 32 | ||||
| 29 | background renderer.RGB | 33 | background renderer.RGB | ||
| 34 | + | foreground renderer.RGB | |||
| 30 | accent 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 | |||
| 31 | rgb bool | 40 | rgb bool | ||
| 32 | } | 41 | } | ||
| 33 | 42 | ||||
| @@ -38,24 +47,24 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||||
| 38 | return neutralRamp(t) | 47 | return neutralRamp(t) | ||
| 39 | } | 48 | } | ||
| 40 | 49 | ||||
| 41 | - | bar, ok := surfaceAtOrBelow(t, accent.RGB, 8) | 50 | + | bar, _, ok := surfaceAtOrBelow(t, accent.RGB, 8) |
| 42 | if !ok { | 51 | if !ok { | ||
| 43 | return neutralRamp(t) | 52 | return neutralRamp(t) | ||
| 44 | } | 53 | } | ||
| 45 | - | inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14) | 54 | + | inactive, inactiveWeight, ok := surfaceAtOrBelow(t, accent.RGB, 14) |
| 46 | if !ok { | 55 | if !ok { | ||
| 47 | return neutralRamp(t) | 56 | return neutralRamp(t) | ||
| 48 | } | 57 | } | ||
| 49 | - | recent, ok := surfaceAtOrBelow(t, accent.RGB, 22) | 58 | + | recent, _, ok := surfaceAtOrBelow(t, accent.RGB, 22) |
| 50 | if !ok { | 59 | if !ok { | ||
| 51 | return neutralRamp(t) | 60 | return neutralRamp(t) | ||
| 52 | } | 61 | } | ||
| 53 | - | active, ok := surfaceAtOrBelow(t, accent.RGB, 100) | 62 | + | active, activeWeight, ok := surfaceAtOrBelow(t, accent.RGB, 100) |
| 54 | if !ok { | 63 | if !ok { | ||
| 55 | return neutralRamp(t) | 64 | return neutralRamp(t) | ||
| 56 | } | 65 | } | ||
| 57 | 66 | ||||
| 58 | - | return Ramp{ | 67 | + | ramp := Ramp{ |
| 59 | SurfaceBar: bar, | 68 | SurfaceBar: bar, | ||
| 60 | SurfaceInactive: inactive, | 69 | SurfaceInactive: inactive, | ||
| 61 | SurfaceRecent: recent, | 70 | SurfaceRecent: recent, | ||
| @@ -64,14 +73,17 @@ func BuildRamp(t Theme, accent Accent) Ramp { | |||||
| 64 | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | 73 | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | ||
| 65 | BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB), | 74 | BorderWarn: warnBorder(t, accent.RGB, bar.BackgroundRGB), | ||
| 66 | background: t.Background, | 75 | background: t.Background, | ||
| 76 | + | foreground: t.Foreground, | |||
| 67 | accent: accent.RGB, | 77 | accent: accent.RGB, | ||
| 68 | rgb: true, | 78 | rgb: true, | ||
| 69 | } | 79 | } | ||
| 80 | + | ramp.scanMRUWeights(t, activeWeight, inactiveWeight) | |||
| 81 | + | return ramp | |||
| 70 | } | 82 | } | ||
| 71 | 83 | ||||
| 72 | // surfaceAtOrBelow searches from the requested intensity to neutral. The | 84 | // surfaceAtOrBelow searches from the requested intensity to neutral. The | ||
| 73 | // integer sequence is intentional: it makes contrast adaptation stable. | 85 | // integer sequence is intentional: it makes contrast adaptation stable. | ||
| 74 | - | func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) { | 86 | + | func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, int, bool) { |
| 75 | for weight := target; weight >= 0; weight-- { | 87 | for weight := target; weight >= 0; weight-- { | ||
| 76 | background := okLabLerp(t.Background, accent, float64(weight)/100) | 88 | background := okLabLerp(t.Background, accent, float64(weight)/100) | ||
| 77 | foreground, ok := primaryText(t, background) | 89 | foreground, ok := primaryText(t, background) | ||
| @@ -81,9 +93,9 @@ func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, | |||||
| 81 | if _, ok := secondaryText(foreground, background); !ok { | 93 | if _, ok := secondaryText(foreground, background); !ok { | ||
| 82 | continue | 94 | continue | ||
| 83 | } | 95 | } | ||
| 84 | - | return rgbSurface(foreground, background), true | 96 | + | return rgbSurface(foreground, background), weight, true |
| 85 | } | 97 | } | ||
| 86 | - | return renderer.Style{}, false | 98 | + | return renderer.Style{}, 0, false |
| 87 | } | 99 | } | ||
| 88 | 100 | ||||
| 89 | func rgbSurface(foreground, background renderer.RGB) renderer.Style { | 101 | func rgbSurface(foreground, background renderer.RGB) renderer.Style { | ||
| @@ -177,26 +189,60 @@ func neutralRamp(t Theme) Ramp { | |||||
| 177 | } | 189 | } | ||
| 178 | } | 190 | } | ||
| 179 | 191 | ||||
| 180 | - | // MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for | 192 | + | const ( |
| 181 | - | // the oldest entry. A singleton is therefore the full recent surface. | 193 | + | // mruTopWeight is the newest history entry: a slightly faded active |
| 182 | - | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | 194 | + | // accent. mruFloorWeight keeps the oldest entry above the inactive and |
| 183 | - | if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB { | 195 | + | // bar surfaces so the whole history stays visibly accent-tinted. |
| 184 | - | return ramp.SurfaceRecent | 196 | + | mruTopWeight = 85 |
| 185 | - | } | 197 | + | mruFloorWeight = 30 |
| 186 | - | if index < 0 { | 198 | + | ) |
| 187 | - | index = 0 | 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) | |||
| 188 | } | 209 | } | ||
| 189 | - | if index >= count { | 210 | + | } |
| 190 | - | index = count - 1 | 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 | + | } | |||
| 191 | } | 219 | } | ||
| 192 | - | if !ramp.rgb { | 220 | + | } |
| 221 | + | ||||
| 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). | |||
| 226 | + | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |||
| 227 | + | if !ramp.rgb || count <= 0 || ramp.mruCount == 0 { | |||
| 193 | return ramp.SurfaceRecent | 228 | return ramp.SurfaceRecent | ||
| 194 | } | 229 | } | ||
| 195 | - | weight := 22.0 - 11.0*float64(index)/float64(count-1) | 230 | + | index = max(0, min(index, count-1)) |
| 196 | - | background := okLabLerp(ramp.background, ramp.accent, weight/100) | 231 | + | |
| 197 | - | foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background) | 232 | + | weights := ramp.mruWeights[:ramp.mruCount] |
| 198 | - | if !ok { | 233 | + | top, bottom := float64(weights[0]), float64(weights[len(weights)-1]) |
| 199 | - | return ramp.SurfaceRecent | 234 | + | target := top |
| 235 | + | if count > 1 { | |||
| 236 | + | target = top - (top-bottom)*float64(index)/float64(count-1) | |||
| 200 | } | 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) | |||
| 201 | return rgbSurface(foreground, background) | 247 | return rgbSurface(foreground, background) | ||
| 202 | } | 248 | } | ||
M internal/usecase/theme/ramp_test.go +44 -4
| @@ -121,13 +121,53 @@ func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) { | |||
| 121 | 121 | } | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | - | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | |
| 124 | + | func TestMRUStyleFadesFromNearActiveTowardBar(t *testing.T) { | |
| 125 | + | tests := []struct { | |
| 126 | + | name string | |
| 127 | + | theme Theme | |
| 128 | + | accent renderer.RGB | |
| 129 | + | fallback bool | |
| 130 | + | }{ | |
| 131 | + | {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}}, | |
| 133 | + | {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}, | |
| 135 | + | } | |
| 136 | + | for _, tt := range tests { | |
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |
| 138 | + | ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true}) | |
| 139 | + | 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 | + | } | |
| 145 | + | 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 { | |
| 153 | + | 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) | |
| 158 | + | } | |
| 159 | + | } | |
| 160 | + | }) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func TestMRUStyleWithoutReadableWeightKeepsRecentSurface(t *testing.T) { | |
| 125 | 165 | theme := rampTheme(false) | |
| 126 | 166 | 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)) | |
| 167 | + | ramp.scanMRUWeights(theme, 15, 14) | |
| 168 | + | ||
| 169 | + | require.Zero(t, ramp.mruCount) | |
| 128 | 170 | 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 | 171 | } | |
| 132 | 172 | ||
| 133 | 173 | func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |
| @@ -121,13 +121,53 @@ func TestBuildRampWarnBorderIsAmberAndDistinctAcrossAccentHues(t *testing.T) { | |||||
| 121 | } | 121 | } | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | - | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | 124 | + | func TestMRUStyleFadesFromNearActiveTowardBar(t *testing.T) { |
| 125 | + | tests := []struct { | |||
| 126 | + | name string | |||
| 127 | + | theme Theme | |||
| 128 | + | accent renderer.RGB | |||
| 129 | + | fallback bool | |||
| 130 | + | }{ | |||
| 131 | + | {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}}, | |||
| 133 | + | {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}, | |||
| 135 | + | } | |||
| 136 | + | for _, tt := range tests { | |||
| 137 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 138 | + | ramp := BuildRamp(tt.theme, Accent{RGB: tt.accent, Known: true}) | |||
| 139 | + | 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 | + | } | |||
| 145 | + | 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 { | |||
| 153 | + | 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) | |||
| 158 | + | } | |||
| 159 | + | } | |||
| 160 | + | }) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestMRUStyleWithoutReadableWeightKeepsRecentSurface(t *testing.T) { | |||
| 125 | theme := rampTheme(false) | 165 | theme := rampTheme(false) | ||
| 126 | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | 166 | 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)) | 167 | + | ramp.scanMRUWeights(theme, 15, 14) |
| 168 | + | ||||
| 169 | + | require.Zero(t, ramp.mruCount) | |||
| 128 | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3)) | 170 | 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 | } | 171 | } | ||
| 132 | 172 | ||||
| 133 | func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | 173 | func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | ||