feat(theme): derive complete accent camaieu
- commit
- b85f9421e1eadb5dbdcfbc831657ec3d278d3781
- author
- brice · · 2026-07-18 07:54 UTC
- parent
- 4c09134
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +530 -493
M internal/usecase/theme/benchmark_test.go +24 -1
| @@ -3,10 +3,14 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | - | var benchmarkStylesSink Styles | |
| 10 | + | var ( | |
| 11 | + | benchmarkStylesSink Styles | |
| 12 | + | benchmarkResolvedSink ResolvedTheme | |
| 13 | + | ) | |
| 10 | 14 | ||
| 11 | 15 | func benchmarkPaletteTheme() Theme { | |
| 12 | 16 | return Theme{ | |
| @@ -46,3 +50,22 @@ func BenchmarkNewStyles(b *testing.B) { | |||
| 46 | 50 | benchmarkStylesSink = NewStyles(theme) | |
| 47 | 51 | } | |
| 48 | 52 | } | |
| 53 | + | ||
| 54 | + | func TestResolveAllocations(t *testing.T) { | |
| 55 | + | theme := benchmarkPaletteTheme() | |
| 56 | + | allocs := testing.AllocsPerRun(1000, func() { | |
| 57 | + | benchmarkResolvedSink = Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 58 | + | }) | |
| 59 | + | if allocs != 0 { | |
| 60 | + | t.Fatalf("Resolve allocations/op = %v, want 0", allocs) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func BenchmarkResolve(b *testing.B) { | |
| 65 | + | theme := benchmarkPaletteTheme() | |
| 66 | + | policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto} | |
| 67 | + | b.ReportAllocs() | |
| 68 | + | for b.Loop() { | |
| 69 | + | benchmarkResolvedSink = Resolve(theme, policy) | |
| 70 | + | } | |
| 71 | + | } | |
| @@ -3,10 +3,14 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "testing" | 4 | "testing" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | "github.com/bnema/vev/pkg/renderer" | 7 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | - | var benchmarkStylesSink Styles | 10 | + | var ( |
| 11 | + | benchmarkStylesSink Styles | |||
| 12 | + | benchmarkResolvedSink ResolvedTheme | |||
| 13 | + | ) | |||
| 10 | 14 | ||||
| 11 | func benchmarkPaletteTheme() Theme { | 15 | func benchmarkPaletteTheme() Theme { | ||
| 12 | return Theme{ | 16 | return Theme{ | ||
| @@ -46,3 +50,22 @@ func BenchmarkNewStyles(b *testing.B) { | |||||
| 46 | benchmarkStylesSink = NewStyles(theme) | 50 | benchmarkStylesSink = NewStyles(theme) | ||
| 47 | } | 51 | } | ||
| 48 | } | 52 | } | ||
| 53 | + | ||||
| 54 | + | func TestResolveAllocations(t *testing.T) { | |||
| 55 | + | theme := benchmarkPaletteTheme() | |||
| 56 | + | allocs := testing.AllocsPerRun(1000, func() { | |||
| 57 | + | benchmarkResolvedSink = Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |||
| 58 | + | }) | |||
| 59 | + | if allocs != 0 { | |||
| 60 | + | t.Fatalf("Resolve allocations/op = %v, want 0", allocs) | |||
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func BenchmarkResolve(b *testing.B) { | |||
| 65 | + | theme := benchmarkPaletteTheme() | |||
| 66 | + | policy := domain.ThemeAccent{Mode: domain.ThemeAccentAuto} | |||
| 67 | + | b.ReportAllocs() | |||
| 68 | + | for b.Loop() { | |||
| 69 | + | benchmarkResolvedSink = Resolve(theme, policy) | |||
| 70 | + | } | |||
| 71 | + | } | |||
A internal/usecase/theme/ramp.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |
| 4 | + | ||
| 5 | + | const ( | |
| 6 | + | normalTextContrast = 4.5 | |
| 7 | + | borderContrast = 3.0 | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // Ramp is the contrast-safe set of semantic terminal chrome colors. | |
| 11 | + | type Ramp struct { | |
| 12 | + | SurfaceBar renderer.Style | |
| 13 | + | SurfaceInactive renderer.Style | |
| 14 | + | SurfaceRecent renderer.Style | |
| 15 | + | SurfaceActive renderer.Style | |
| 16 | + | BorderMuted renderer.Style | |
| 17 | + | BorderActive renderer.Style | |
| 18 | + | ||
| 19 | + | background renderer.RGB | |
| 20 | + | accent renderer.RGB | |
| 21 | + | rgb bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // BuildRamp derives every RGB surface from the terminal background and one | |
| 25 | + | // resolved accent. It deliberately never uses indexed colors for backgrounds. | |
| 26 | + | func BuildRamp(t Theme, accent Accent) Ramp { | |
| 27 | + | if !accent.Known || accent.IndexedOnly || !usable(t) { | |
| 28 | + | return neutralRamp(t) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | bar, ok := surfaceAtOrBelow(t, accent.RGB, 8) | |
| 32 | + | if !ok { | |
| 33 | + | return neutralRamp(t) | |
| 34 | + | } | |
| 35 | + | inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14) | |
| 36 | + | if !ok { | |
| 37 | + | return neutralRamp(t) | |
| 38 | + | } | |
| 39 | + | recent, ok := surfaceAtOrBelow(t, accent.RGB, 22) | |
| 40 | + | if !ok { | |
| 41 | + | return neutralRamp(t) | |
| 42 | + | } | |
| 43 | + | active, ok := surfaceAtOrBelow(t, accent.RGB, 100) | |
| 44 | + | if !ok { | |
| 45 | + | return neutralRamp(t) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | return Ramp{ | |
| 49 | + | SurfaceBar: bar, | |
| 50 | + | SurfaceInactive: inactive, | |
| 51 | + | SurfaceRecent: recent, | |
| 52 | + | SurfaceActive: active, | |
| 53 | + | BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 54 | + | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | |
| 55 | + | background: t.Background, | |
| 56 | + | accent: accent.RGB, | |
| 57 | + | rgb: true, | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // surfaceAtOrBelow searches from the requested intensity to neutral. The | |
| 62 | + | // integer sequence is intentional: it makes contrast adaptation stable. | |
| 63 | + | func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) { | |
| 64 | + | for weight := target; weight >= 0; weight-- { | |
| 65 | + | background := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 66 | + | foreground, ok := primaryText(t, background) | |
| 67 | + | if !ok { | |
| 68 | + | continue | |
| 69 | + | } | |
| 70 | + | if _, ok := secondaryText(foreground, background); !ok { | |
| 71 | + | continue | |
| 72 | + | } | |
| 73 | + | return rgbSurface(foreground, background), true | |
| 74 | + | } | |
| 75 | + | return renderer.Style{}, false | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func rgbSurface(foreground, background renderer.RGB) renderer.Style { | |
| 79 | + | style := renderer.DefaultStyle() | |
| 80 | + | style.HasForegroundRGB = true | |
| 81 | + | style.ForegroundRGB = foreground | |
| 82 | + | style.HasBackgroundRGB = true | |
| 83 | + | style.BackgroundRGB = background | |
| 84 | + | return style | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // primaryText chooses only the reported default foreground or background. | |
| 88 | + | func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) { | |
| 89 | + | best := t.Foreground | |
| 90 | + | bestContrast := ContrastRatio(t.Foreground, surface) | |
| 91 | + | backgroundContrast := ContrastRatio(t.Background, surface) | |
| 92 | + | if backgroundContrast > bestContrast { | |
| 93 | + | best, bestContrast = t.Background, backgroundContrast | |
| 94 | + | } | |
| 95 | + | return best, bestContrast >= normalTextContrast | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // secondaryText mutes toward the actual surface while retaining normal-text | |
| 99 | + | // contrast. Searching down from 40 selects the greatest permitted mute. | |
| 100 | + | func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) { | |
| 101 | + | for weight := 40; weight >= 0; weight-- { | |
| 102 | + | candidate := okLabLerp(primary, surface, float64(weight)/100) | |
| 103 | + | if ContrastRatio(candidate, surface) >= normalTextContrast { | |
| 104 | + | return candidate, true | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return renderer.RGB{}, false | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // mutedBorder starts at 60 percent and only moves toward the accent, choosing | |
| 111 | + | // the lowest weight that separates from its adjacent surface. | |
| 112 | + | func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |
| 113 | + | for weight := 60; weight <= 100; weight++ { | |
| 114 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 115 | + | if ContrastRatio(candidate, adjacent) >= borderContrast { | |
| 116 | + | return foregroundStyle(candidate) | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | return neutralBorderStyle(t) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // activeBorder starts at the accent endpoint and reduces only when needed. | |
| 123 | + | func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |
| 124 | + | for weight := 100; weight >= 0; weight-- { | |
| 125 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |
| 126 | + | if ContrastRatio(candidate, adjacent) >= borderContrast { | |
| 127 | + | return foregroundStyle(candidate) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | return neutralBorderStyle(t) | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func foregroundStyle(color renderer.RGB) renderer.Style { | |
| 134 | + | style := renderer.DefaultStyle() | |
| 135 | + | style.HasForegroundRGB = true | |
| 136 | + | style.ForegroundRGB = color | |
| 137 | + | return style | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func neutralRamp(t Theme) Ramp { | |
| 141 | + | styles := neutralStyles(t) | |
| 142 | + | return Ramp{ | |
| 143 | + | SurfaceBar: styles.SurfaceBar, | |
| 144 | + | SurfaceInactive: styles.SurfaceInactive, | |
| 145 | + | SurfaceRecent: styles.SurfaceRecent, | |
| 146 | + | SurfaceActive: styles.SurfaceActive, | |
| 147 | + | BorderMuted: styles.BorderMuted, | |
| 148 | + | BorderActive: styles.BorderActive, | |
| 149 | + | } | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | // MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for | |
| 153 | + | // the oldest entry. A singleton is therefore the full recent surface. | |
| 154 | + | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |
| 155 | + | if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB { | |
| 156 | + | return ramp.SurfaceRecent | |
| 157 | + | } | |
| 158 | + | if index < 0 { | |
| 159 | + | index = 0 | |
| 160 | + | } | |
| 161 | + | if index >= count { | |
| 162 | + | index = count - 1 | |
| 163 | + | } | |
| 164 | + | if !ramp.rgb { | |
| 165 | + | return ramp.SurfaceRecent | |
| 166 | + | } | |
| 167 | + | weight := 22.0 - 11.0*float64(index)/float64(count-1) | |
| 168 | + | background := okLabLerp(ramp.background, ramp.accent, weight/100) | |
| 169 | + | foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background) | |
| 170 | + | if !ok { | |
| 171 | + | return ramp.SurfaceRecent | |
| 172 | + | } | |
| 173 | + | return rgbSurface(foreground, background) | |
| 174 | + | } | |
| @@ -0,0 +1,174 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/pkg/renderer" | |||
| 4 | + | ||||
| 5 | + | const ( | |||
| 6 | + | normalTextContrast = 4.5 | |||
| 7 | + | borderContrast = 3.0 | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // Ramp is the contrast-safe set of semantic terminal chrome colors. | |||
| 11 | + | type Ramp struct { | |||
| 12 | + | SurfaceBar renderer.Style | |||
| 13 | + | SurfaceInactive renderer.Style | |||
| 14 | + | SurfaceRecent renderer.Style | |||
| 15 | + | SurfaceActive renderer.Style | |||
| 16 | + | BorderMuted renderer.Style | |||
| 17 | + | BorderActive renderer.Style | |||
| 18 | + | ||||
| 19 | + | background renderer.RGB | |||
| 20 | + | accent renderer.RGB | |||
| 21 | + | rgb bool | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // BuildRamp derives every RGB surface from the terminal background and one | |||
| 25 | + | // resolved accent. It deliberately never uses indexed colors for backgrounds. | |||
| 26 | + | func BuildRamp(t Theme, accent Accent) Ramp { | |||
| 27 | + | if !accent.Known || accent.IndexedOnly || !usable(t) { | |||
| 28 | + | return neutralRamp(t) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | bar, ok := surfaceAtOrBelow(t, accent.RGB, 8) | |||
| 32 | + | if !ok { | |||
| 33 | + | return neutralRamp(t) | |||
| 34 | + | } | |||
| 35 | + | inactive, ok := surfaceAtOrBelow(t, accent.RGB, 14) | |||
| 36 | + | if !ok { | |||
| 37 | + | return neutralRamp(t) | |||
| 38 | + | } | |||
| 39 | + | recent, ok := surfaceAtOrBelow(t, accent.RGB, 22) | |||
| 40 | + | if !ok { | |||
| 41 | + | return neutralRamp(t) | |||
| 42 | + | } | |||
| 43 | + | active, ok := surfaceAtOrBelow(t, accent.RGB, 100) | |||
| 44 | + | if !ok { | |||
| 45 | + | return neutralRamp(t) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | return Ramp{ | |||
| 49 | + | SurfaceBar: bar, | |||
| 50 | + | SurfaceInactive: inactive, | |||
| 51 | + | SurfaceRecent: recent, | |||
| 52 | + | SurfaceActive: active, | |||
| 53 | + | BorderMuted: mutedBorder(t, accent.RGB, bar.BackgroundRGB), | |||
| 54 | + | BorderActive: activeBorder(t, accent.RGB, bar.BackgroundRGB), | |||
| 55 | + | background: t.Background, | |||
| 56 | + | accent: accent.RGB, | |||
| 57 | + | rgb: true, | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // surfaceAtOrBelow searches from the requested intensity to neutral. The | |||
| 62 | + | // integer sequence is intentional: it makes contrast adaptation stable. | |||
| 63 | + | func surfaceAtOrBelow(t Theme, accent renderer.RGB, target int) (renderer.Style, bool) { | |||
| 64 | + | for weight := target; weight >= 0; weight-- { | |||
| 65 | + | background := okLabLerp(t.Background, accent, float64(weight)/100) | |||
| 66 | + | foreground, ok := primaryText(t, background) | |||
| 67 | + | if !ok { | |||
| 68 | + | continue | |||
| 69 | + | } | |||
| 70 | + | if _, ok := secondaryText(foreground, background); !ok { | |||
| 71 | + | continue | |||
| 72 | + | } | |||
| 73 | + | return rgbSurface(foreground, background), true | |||
| 74 | + | } | |||
| 75 | + | return renderer.Style{}, false | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func rgbSurface(foreground, background renderer.RGB) renderer.Style { | |||
| 79 | + | style := renderer.DefaultStyle() | |||
| 80 | + | style.HasForegroundRGB = true | |||
| 81 | + | style.ForegroundRGB = foreground | |||
| 82 | + | style.HasBackgroundRGB = true | |||
| 83 | + | style.BackgroundRGB = background | |||
| 84 | + | return style | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // primaryText chooses only the reported default foreground or background. | |||
| 88 | + | func primaryText(t Theme, surface renderer.RGB) (renderer.RGB, bool) { | |||
| 89 | + | best := t.Foreground | |||
| 90 | + | bestContrast := ContrastRatio(t.Foreground, surface) | |||
| 91 | + | backgroundContrast := ContrastRatio(t.Background, surface) | |||
| 92 | + | if backgroundContrast > bestContrast { | |||
| 93 | + | best, bestContrast = t.Background, backgroundContrast | |||
| 94 | + | } | |||
| 95 | + | return best, bestContrast >= normalTextContrast | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // secondaryText mutes toward the actual surface while retaining normal-text | |||
| 99 | + | // contrast. Searching down from 40 selects the greatest permitted mute. | |||
| 100 | + | func secondaryText(primary, surface renderer.RGB) (renderer.RGB, bool) { | |||
| 101 | + | for weight := 40; weight >= 0; weight-- { | |||
| 102 | + | candidate := okLabLerp(primary, surface, float64(weight)/100) | |||
| 103 | + | if ContrastRatio(candidate, surface) >= normalTextContrast { | |||
| 104 | + | return candidate, true | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | return renderer.RGB{}, false | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // mutedBorder starts at 60 percent and only moves toward the accent, choosing | |||
| 111 | + | // the lowest weight that separates from its adjacent surface. | |||
| 112 | + | func mutedBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||
| 113 | + | for weight := 60; weight <= 100; weight++ { | |||
| 114 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |||
| 115 | + | if ContrastRatio(candidate, adjacent) >= borderContrast { | |||
| 116 | + | return foregroundStyle(candidate) | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | return neutralBorderStyle(t) | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // activeBorder starts at the accent endpoint and reduces only when needed. | |||
| 123 | + | func activeBorder(t Theme, accent, adjacent renderer.RGB) renderer.Style { | |||
| 124 | + | for weight := 100; weight >= 0; weight-- { | |||
| 125 | + | candidate := okLabLerp(t.Background, accent, float64(weight)/100) | |||
| 126 | + | if ContrastRatio(candidate, adjacent) >= borderContrast { | |||
| 127 | + | return foregroundStyle(candidate) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | return neutralBorderStyle(t) | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func foregroundStyle(color renderer.RGB) renderer.Style { | |||
| 134 | + | style := renderer.DefaultStyle() | |||
| 135 | + | style.HasForegroundRGB = true | |||
| 136 | + | style.ForegroundRGB = color | |||
| 137 | + | return style | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func neutralRamp(t Theme) Ramp { | |||
| 141 | + | styles := neutralStyles(t) | |||
| 142 | + | return Ramp{ | |||
| 143 | + | SurfaceBar: styles.SurfaceBar, | |||
| 144 | + | SurfaceInactive: styles.SurfaceInactive, | |||
| 145 | + | SurfaceRecent: styles.SurfaceRecent, | |||
| 146 | + | SurfaceActive: styles.SurfaceActive, | |||
| 147 | + | BorderMuted: styles.BorderMuted, | |||
| 148 | + | BorderActive: styles.BorderActive, | |||
| 149 | + | } | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // MRUStyle interpolates the newest entry from SurfaceRecent (22%) to 11% for | |||
| 153 | + | // the oldest entry. A singleton is therefore the full recent surface. | |||
| 154 | + | func MRUStyle(ramp Ramp, index, count int) renderer.Style { | |||
| 155 | + | if count <= 1 || !ramp.SurfaceRecent.HasBackgroundRGB || !ramp.SurfaceBar.HasBackgroundRGB { | |||
| 156 | + | return ramp.SurfaceRecent | |||
| 157 | + | } | |||
| 158 | + | if index < 0 { | |||
| 159 | + | index = 0 | |||
| 160 | + | } | |||
| 161 | + | if index >= count { | |||
| 162 | + | index = count - 1 | |||
| 163 | + | } | |||
| 164 | + | if !ramp.rgb { | |||
| 165 | + | return ramp.SurfaceRecent | |||
| 166 | + | } | |||
| 167 | + | weight := 22.0 - 11.0*float64(index)/float64(count-1) | |||
| 168 | + | background := okLabLerp(ramp.background, ramp.accent, weight/100) | |||
| 169 | + | foreground, ok := primaryText(Theme{Foreground: ramp.SurfaceRecent.ForegroundRGB, Background: ramp.background}, background) | |||
| 170 | + | if !ok { | |||
| 171 | + | return ramp.SurfaceRecent | |||
| 172 | + | } | |||
| 173 | + | return rgbSurface(foreground, background) | |||
| 174 | + | } | |||
A internal/usecase/theme/ramp_test.go +134 -0
| @@ -0,0 +1,134 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestBuildRampUsesExactSemanticWeights(t *testing.T) { | |
| 12 | + | theme := rampTheme(false) | |
| 13 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true} | |
| 14 | + | ramp := BuildRamp(theme, accent) | |
| 15 | + | ||
| 16 | + | for name, got := range map[string]renderer.Style{ | |
| 17 | + | "bar": ramp.SurfaceBar, | |
| 18 | + | "inactive": ramp.SurfaceInactive, | |
| 19 | + | "recent": ramp.SurfaceRecent, | |
| 20 | + | } { | |
| 21 | + | require.True(t, got.HasBackgroundRGB, name) | |
| 22 | + | } | |
| 23 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB) | |
| 24 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB) | |
| 25 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB) | |
| 26 | + | require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB) | |
| 27 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB) | |
| 28 | + | require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB) | |
| 29 | + | require.False(t, ramp.BorderMuted.HasBackgroundRGB) | |
| 30 | + | require.False(t, ramp.BorderActive.HasBackgroundRGB) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) { | |
| 34 | + | for _, light := range []bool{false, true} { | |
| 35 | + | t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) { | |
| 36 | + | theme := rampTheme(light) | |
| 37 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | |
| 38 | + | ramp := BuildRamp(theme, accent) | |
| 39 | + | for name, style := range map[string]renderer.Style{ | |
| 40 | + | "bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive, | |
| 41 | + | "recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive, | |
| 42 | + | } { | |
| 43 | + | require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name) | |
| 44 | + | } | |
| 45 | + | for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} { | |
| 46 | + | require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name) | |
| 47 | + | } | |
| 48 | + | }) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) { | |
| 53 | + | theme := Theme{ | |
| 54 | + | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | |
| 55 | + | Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59}, | |
| 56 | + | HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true, | |
| 57 | + | } | |
| 58 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | |
| 59 | + | ramp := BuildRamp(theme, accent) | |
| 60 | + | ||
| 61 | + | wantWeight := -1 | |
| 62 | + | for weight := 100; weight >= 0; weight-- { | |
| 63 | + | surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100) | |
| 64 | + | primary, ok := primaryText(theme, surface) | |
| 65 | + | if !ok { | |
| 66 | + | continue | |
| 67 | + | } | |
| 68 | + | if _, ok := secondaryText(primary, surface); ok { | |
| 69 | + | wantWeight = weight | |
| 70 | + | break | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | require.NotEqual(t, 100, wantWeight) | |
| 74 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | |
| 78 | + | theme := rampTheme(false) | |
| 79 | + | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | |
| 80 | + | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1)) | |
| 81 | + | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3)) | |
| 82 | + | require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB) | |
| 83 | + | require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |
| 87 | + | theme := rampTheme(false) | |
| 88 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 89 | + | theme.Palette[10] = theme.Palette[2] | |
| 90 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |
| 91 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |
| 92 | + | ||
| 93 | + | require.True(t, resolved.Accent.Known) | |
| 94 | + | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | |
| 95 | + | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | |
| 96 | + | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | |
| 97 | + | require.True(t, resolved.Styles.TabActive.Bold) | |
| 98 | + | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | |
| 99 | + | require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase) | |
| 100 | + | require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB) | |
| 101 | + | require.True(t, resolved.Styles.PickerSelection.Bold) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) { | |
| 105 | + | theme := rampTheme(false) | |
| 106 | + | theme.TrueColor = false | |
| 107 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 108 | + | theme.PaletteKnown = 1 << 2 | |
| 109 | + | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 110 | + | require.True(t, indexed.Accent.IndexedOnly) | |
| 111 | + | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | |
| 112 | + | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | |
| 113 | + | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | |
| 114 | + | ||
| 115 | + | theme.TrueColor = true | |
| 116 | + | theme.UsePalette = false | |
| 117 | + | off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 118 | + | require.False(t, off.Accent.Known) | |
| 119 | + | require.Equal(t, neutralStyles(theme), off.Styles) | |
| 120 | + | ||
| 121 | + | insufficient := rampTheme(false) | |
| 122 | + | insufficient.Foreground = insufficient.Background | |
| 123 | + | insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |
| 124 | + | insufficient.PaletteKnown = 1 << 2 | |
| 125 | + | neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |
| 126 | + | require.Equal(t, neutralStyles(insufficient), neutral.Styles) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func rampTheme(light bool) Theme { | |
| 130 | + | if light { | |
| 131 | + | return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 132 | + | } | |
| 133 | + | return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |
| 134 | + | } | |
| @@ -0,0 +1,134 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestBuildRampUsesExactSemanticWeights(t *testing.T) { | |||
| 12 | + | theme := rampTheme(false) | |||
| 13 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Slot: 2, Known: true} | |||
| 14 | + | ramp := BuildRamp(theme, accent) | |||
| 15 | + | ||||
| 16 | + | for name, got := range map[string]renderer.Style{ | |||
| 17 | + | "bar": ramp.SurfaceBar, | |||
| 18 | + | "inactive": ramp.SurfaceInactive, | |||
| 19 | + | "recent": ramp.SurfaceRecent, | |||
| 20 | + | } { | |||
| 21 | + | require.True(t, got.HasBackgroundRGB, name) | |||
| 22 | + | } | |||
| 23 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.08), ramp.SurfaceBar.BackgroundRGB) | |||
| 24 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.14), ramp.SurfaceInactive.BackgroundRGB) | |||
| 25 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.22), ramp.SurfaceRecent.BackgroundRGB) | |||
| 26 | + | require.Equal(t, accent.RGB, ramp.SurfaceActive.BackgroundRGB) | |||
| 27 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, 0.60), ramp.BorderMuted.ForegroundRGB) | |||
| 28 | + | require.Equal(t, accent.RGB, ramp.BorderActive.ForegroundRGB) | |||
| 29 | + | require.False(t, ramp.BorderMuted.HasBackgroundRGB) | |||
| 30 | + | require.False(t, ramp.BorderActive.HasBackgroundRGB) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestBuildRampAdaptsTextAndBordersForDarkAndLightThemes(t *testing.T) { | |||
| 34 | + | for _, light := range []bool{false, true} { | |||
| 35 | + | t.Run(map[bool]string{false: "dark", true: "light"}[light], func(t *testing.T) { | |||
| 36 | + | theme := rampTheme(light) | |||
| 37 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | |||
| 38 | + | ramp := BuildRamp(theme, accent) | |||
| 39 | + | for name, style := range map[string]renderer.Style{ | |||
| 40 | + | "bar": ramp.SurfaceBar, "inactive": ramp.SurfaceInactive, | |||
| 41 | + | "recent": ramp.SurfaceRecent, "active": ramp.SurfaceActive, | |||
| 42 | + | } { | |||
| 43 | + | require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, style.BackgroundRGB), normalTextContrast, name) | |||
| 44 | + | } | |||
| 45 | + | for name, style := range map[string]renderer.Style{"muted": ramp.BorderMuted, "active": ramp.BorderActive} { | |||
| 46 | + | require.GreaterOrEqual(t, ContrastRatio(style.ForegroundRGB, ramp.SurfaceBar.BackgroundRGB), borderContrast, name) | |||
| 47 | + | } | |||
| 48 | + | }) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestBuildRampReducesActiveToHighestSafeWeight(t *testing.T) { | |||
| 53 | + | theme := Theme{ | |||
| 54 | + | Foreground: renderer.RGB{R: 0xe0, G: 0xe0, B: 0xe0}, | |||
| 55 | + | Background: renderer.RGB{R: 0x59, G: 0x59, B: 0x59}, | |||
| 56 | + | HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true, | |||
| 57 | + | } | |||
| 58 | + | accent := Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true} | |||
| 59 | + | ramp := BuildRamp(theme, accent) | |||
| 60 | + | ||||
| 61 | + | wantWeight := -1 | |||
| 62 | + | for weight := 100; weight >= 0; weight-- { | |||
| 63 | + | surface := okLabLerp(theme.Background, accent.RGB, float64(weight)/100) | |||
| 64 | + | primary, ok := primaryText(theme, surface) | |||
| 65 | + | if !ok { | |||
| 66 | + | continue | |||
| 67 | + | } | |||
| 68 | + | if _, ok := secondaryText(primary, surface); ok { | |||
| 69 | + | wantWeight = weight | |||
| 70 | + | break | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | require.NotEqual(t, 100, wantWeight) | |||
| 74 | + | require.Equal(t, okLabLerp(theme.Background, accent.RGB, float64(wantWeight)/100), ramp.SurfaceActive.BackgroundRGB) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestMRUStyleFadesFromRecentToElevenPercent(t *testing.T) { | |||
| 78 | + | theme := rampTheme(false) | |||
| 79 | + | ramp := BuildRamp(theme, Accent{RGB: renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, Known: true}) | |||
| 80 | + | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 1)) | |||
| 81 | + | require.Equal(t, ramp.SurfaceRecent, MRUStyle(ramp, 0, 3)) | |||
| 82 | + | require.Equal(t, okLabLerp(theme.Background, renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5}, 0.11), MRUStyle(ramp, 2, 3).BackgroundRGB) | |||
| 83 | + | require.NotEqual(t, ramp.SurfaceBar.BackgroundRGB, MRUStyle(ramp, 2, 3).BackgroundRGB) | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestResolveBuildsCompleteStylesFromOneAccent(t *testing.T) { | |||
| 87 | + | theme := rampTheme(false) | |||
| 88 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 89 | + | theme.Palette[10] = theme.Palette[2] | |||
| 90 | + | theme.PaletteKnown = 1<<2 | 1<<10 | |||
| 91 | + | resolved := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}) | |||
| 92 | + | ||||
| 93 | + | require.True(t, resolved.Accent.Known) | |||
| 94 | + | require.Equal(t, resolved.Ramp.SurfaceBar, resolved.Styles.SurfaceBar) | |||
| 95 | + | require.Equal(t, resolved.Ramp.SurfaceActive, resolved.Styles.SurfaceActive) | |||
| 96 | + | require.Equal(t, resolved.Ramp.BorderActive, resolved.Styles.BorderActive) | |||
| 97 | + | require.True(t, resolved.Styles.TabActive.Bold) | |||
| 98 | + | require.Equal(t, resolved.Styles.SurfaceRecent, resolved.Styles.MRURecent) | |||
| 99 | + | require.Equal(t, resolved.Styles.SurfaceBar, resolved.Styles.PickerBase) | |||
| 100 | + | require.Equal(t, resolved.Styles.SurfaceActive.BackgroundRGB, resolved.Styles.PickerSelection.BackgroundRGB) | |||
| 101 | + | require.True(t, resolved.Styles.PickerSelection.Bold) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func TestResolveIndexedAndPaletteOffNeverUseAccentBackground(t *testing.T) { | |||
| 105 | + | theme := rampTheme(false) | |||
| 106 | + | theme.TrueColor = false | |||
| 107 | + | theme.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 108 | + | theme.PaletteKnown = 1 << 2 | |||
| 109 | + | indexed := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |||
| 110 | + | require.True(t, indexed.Accent.IndexedOnly) | |||
| 111 | + | require.False(t, indexed.Styles.SurfaceActive.HasBackgroundRGB) | |||
| 112 | + | require.Equal(t, 2, indexed.Styles.BorderActive.Foreground) | |||
| 113 | + | require.False(t, indexed.Styles.BorderActive.HasBackgroundRGB) | |||
| 114 | + | ||||
| 115 | + | theme.TrueColor = true | |||
| 116 | + | theme.UsePalette = false | |||
| 117 | + | off := Resolve(theme, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |||
| 118 | + | require.False(t, off.Accent.Known) | |||
| 119 | + | require.Equal(t, neutralStyles(theme), off.Styles) | |||
| 120 | + | ||||
| 121 | + | insufficient := rampTheme(false) | |||
| 122 | + | insufficient.Foreground = insufficient.Background | |||
| 123 | + | insufficient.Palette[2] = renderer.RGB{R: 0x7d, G: 0xb5, B: 0xb5} | |||
| 124 | + | insufficient.PaletteKnown = 1 << 2 | |||
| 125 | + | neutral := Resolve(insufficient, domain.ThemeAccent{Mode: domain.ThemeAccentSlot, Slot: 2}) | |||
| 126 | + | require.Equal(t, neutralStyles(insufficient), neutral.Styles) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func rampTheme(light bool) Theme { | |||
| 130 | + | if light { | |||
| 131 | + | return Theme{Foreground: renderer.RGB{R: 0x20, G: 0x20, B: 0x20}, Background: renderer.RGB{R: 0xf8, G: 0xf8, B: 0xf8}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |||
| 132 | + | } | |||
| 133 | + | return Theme{Foreground: renderer.RGB{R: 0xd8, G: 0xdc, B: 0xe8}, Background: renderer.RGB{R: 0x08, G: 0x09, B: 0x0a}, HasFG: true, HasBG: true, TrueColor: true, Known: true, UsePalette: true} | |||
| 134 | + | } | |||
A internal/usecase/theme/resolved_styles.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/domain" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | const neutralAccentBlend = 0.25 | |
| 9 | + | ||
| 10 | + | // ResolvedTheme is the immutable, complete chrome style snapshot produced from | |
| 11 | + | // a terminal theme and typed accent policy. | |
| 12 | + | type ResolvedTheme struct { | |
| 13 | + | Theme Theme | |
| 14 | + | Accent Accent | |
| 15 | + | Ramp Ramp | |
| 16 | + | Styles Styles | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Resolve is the single entry point for terminal-owned accent inference and | |
| 20 | + | // semantic chrome construction. | |
| 21 | + | func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme { | |
| 22 | + | accent := ResolveAccent(t, policy) | |
| 23 | + | ramp := BuildRamp(t, accent) | |
| 24 | + | styles := stylesFromRamp(t, accent, ramp) | |
| 25 | + | return ResolvedTheme{Theme: t, Accent: accent, Ramp: ramp, Styles: styles} | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |
| 29 | + | if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb { | |
| 30 | + | styles := neutralStyles(t) | |
| 31 | + | if accent.Known && accent.IndexedOnly { | |
| 32 | + | styles = indexedStyles(styles, accent.Slot) | |
| 33 | + | } | |
| 34 | + | return styles | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | inactiveTitle := secondarySurface(ramp.SurfaceInactive) | |
| 38 | + | activeTitle := secondarySurface(ramp.SurfaceActive) | |
| 39 | + | barDescription := secondarySurface(ramp.SurfaceBar) | |
| 40 | + | styles := Styles{ | |
| 41 | + | SurfaceBar: ramp.SurfaceBar, | |
| 42 | + | SurfaceInactive: ramp.SurfaceInactive, | |
| 43 | + | SurfaceRecent: ramp.SurfaceRecent, | |
| 44 | + | SurfaceActive: ramp.SurfaceActive, | |
| 45 | + | BorderMuted: ramp.BorderMuted, | |
| 46 | + | BorderActive: ramp.BorderActive, | |
| 47 | + | ||
| 48 | + | TabInactive: ramp.SurfaceInactive, | |
| 49 | + | TabInactiveTitle: inactiveTitle, | |
| 50 | + | TabActive: EmphasisStyle(ramp.SurfaceActive, t), | |
| 51 | + | TabActiveTitle: activeTitle, | |
| 52 | + | MRURecent: ramp.SurfaceRecent, | |
| 53 | + | ||
| 54 | + | PickerBase: ramp.SurfaceBar, | |
| 55 | + | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 56 | + | PickerDescription: barDescription, | |
| 57 | + | PickerSeparator: ramp.BorderMuted, | |
| 58 | + | PromptBase: ramp.SurfaceBar, | |
| 59 | + | CopyStatus: ramp.SurfaceBar, | |
| 60 | + | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | |
| 61 | + | } | |
| 62 | + | return withLegacyAliases(styles) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func secondarySurface(base renderer.Style) renderer.Style { | |
| 66 | + | if !base.HasForegroundRGB || !base.HasBackgroundRGB { | |
| 67 | + | return base | |
| 68 | + | } | |
| 69 | + | foreground, ok := secondaryText(base.ForegroundRGB, base.BackgroundRGB) | |
| 70 | + | if !ok { | |
| 71 | + | return base | |
| 72 | + | } | |
| 73 | + | base.ForegroundRGB = foreground | |
| 74 | + | return base | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func indexedStyles(styles Styles, slot uint8) Styles { | |
| 78 | + | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | |
| 79 | + | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | |
| 80 | + | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | |
| 81 | + | styles.Border = styles.BorderMuted | |
| 82 | + | return styles | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | |
| 86 | + | style.HasForegroundRGB = false | |
| 87 | + | style.Foreground = int(slot) | |
| 88 | + | style.HasBackgroundRGB = false | |
| 89 | + | style.Background = -1 | |
| 90 | + | return style | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // neutralStyles deliberately preserves the pre-accent neutral hierarchy. It | |
| 94 | + | // is used for palette-off, forced themes, incomplete defaults, and RGB-less | |
| 95 | + | // indexed fallbacks; it never performs a palette slot lookup. | |
| 96 | + | func neutralStyles(t Theme) Styles { | |
| 97 | + | status := renderer.DefaultStyle() | |
| 98 | + | accent := inverseStyle() | |
| 99 | + | border := renderer.DefaultStyle() | |
| 100 | + | muted := renderer.DefaultStyle() | |
| 101 | + | if usable(t) { | |
| 102 | + | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | |
| 103 | + | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | |
| 104 | + | border = neutralBorderStyle(t) | |
| 105 | + | muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |
| 106 | + | } | |
| 107 | + | styles := Styles{ | |
| 108 | + | SurfaceBar: status, | |
| 109 | + | SurfaceInactive: status, | |
| 110 | + | SurfaceRecent: status, | |
| 111 | + | SurfaceActive: accent, | |
| 112 | + | BorderMuted: border, | |
| 113 | + | BorderActive: border, | |
| 114 | + | ||
| 115 | + | TabInactive: EmphasisStyle(status, t), | |
| 116 | + | TabInactiveTitle: MutedVariantStyle(status, t), | |
| 117 | + | TabActive: EmphasisStyle(accent, t), | |
| 118 | + | TabActiveTitle: MutedVariantStyle(accent, t), | |
| 119 | + | MRURecent: status, | |
| 120 | + | ||
| 121 | + | PickerBase: status, | |
| 122 | + | PickerSelection: EmphasisStyle(accent, t), | |
| 123 | + | PickerDescription: muted, | |
| 124 | + | PickerSeparator: muted, | |
| 125 | + | PromptBase: status, | |
| 126 | + | CopyStatus: accent, | |
| 127 | + | SearchSelection: accent, | |
| 128 | + | } | |
| 129 | + | return withLegacyAliases(styles) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func neutralBorderStyle(t Theme) renderer.Style { | |
| 133 | + | if !usable(t) { | |
| 134 | + | return renderer.DefaultStyle() | |
| 135 | + | } | |
| 136 | + | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func withLegacyAliases(styles Styles) Styles { | |
| 140 | + | styles.StatusBar = styles.SurfaceBar | |
| 141 | + | styles.Accent = styles.SurfaceActive | |
| 142 | + | styles.Border = styles.BorderMuted | |
| 143 | + | styles.Selection = styles.SurfaceActive | |
| 144 | + | styles.PaletteDesc = styles.PickerDescription | |
| 145 | + | styles.TabName = styles.TabInactive | |
| 146 | + | styles.TabNameActive = styles.TabActive | |
| 147 | + | styles.TabTitle = styles.TabInactiveTitle | |
| 148 | + | styles.TabTitleActive = styles.TabActiveTitle | |
| 149 | + | styles.PickerName = styles.PickerBase | |
| 150 | + | styles.PickerSelectionName = styles.PickerSelection | |
| 151 | + | styles.PickerSelectionMuted = secondarySurface(styles.PickerSelection) | |
| 152 | + | return styles | |
| 153 | + | } | |
| @@ -0,0 +1,153 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | const neutralAccentBlend = 0.25 | |||
| 9 | + | ||||
| 10 | + | // ResolvedTheme is the immutable, complete chrome style snapshot produced from | |||
| 11 | + | // a terminal theme and typed accent policy. | |||
| 12 | + | type ResolvedTheme struct { | |||
| 13 | + | Theme Theme | |||
| 14 | + | Accent Accent | |||
| 15 | + | Ramp Ramp | |||
| 16 | + | Styles Styles | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | // Resolve is the single entry point for terminal-owned accent inference and | |||
| 20 | + | // semantic chrome construction. | |||
| 21 | + | func Resolve(t Theme, policy domain.ThemeAccent) ResolvedTheme { | |||
| 22 | + | accent := ResolveAccent(t, policy) | |||
| 23 | + | ramp := BuildRamp(t, accent) | |||
| 24 | + | styles := stylesFromRamp(t, accent, ramp) | |||
| 25 | + | return ResolvedTheme{Theme: t, Accent: accent, Ramp: ramp, Styles: styles} | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func stylesFromRamp(t Theme, accent Accent, ramp Ramp) Styles { | |||
| 29 | + | if !accent.Known || accent.IndexedOnly || !usable(t) || !ramp.rgb { | |||
| 30 | + | styles := neutralStyles(t) | |||
| 31 | + | if accent.Known && accent.IndexedOnly { | |||
| 32 | + | styles = indexedStyles(styles, accent.Slot) | |||
| 33 | + | } | |||
| 34 | + | return styles | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | inactiveTitle := secondarySurface(ramp.SurfaceInactive) | |||
| 38 | + | activeTitle := secondarySurface(ramp.SurfaceActive) | |||
| 39 | + | barDescription := secondarySurface(ramp.SurfaceBar) | |||
| 40 | + | styles := Styles{ | |||
| 41 | + | SurfaceBar: ramp.SurfaceBar, | |||
| 42 | + | SurfaceInactive: ramp.SurfaceInactive, | |||
| 43 | + | SurfaceRecent: ramp.SurfaceRecent, | |||
| 44 | + | SurfaceActive: ramp.SurfaceActive, | |||
| 45 | + | BorderMuted: ramp.BorderMuted, | |||
| 46 | + | BorderActive: ramp.BorderActive, | |||
| 47 | + | ||||
| 48 | + | TabInactive: ramp.SurfaceInactive, | |||
| 49 | + | TabInactiveTitle: inactiveTitle, | |||
| 50 | + | TabActive: EmphasisStyle(ramp.SurfaceActive, t), | |||
| 51 | + | TabActiveTitle: activeTitle, | |||
| 52 | + | MRURecent: ramp.SurfaceRecent, | |||
| 53 | + | ||||
| 54 | + | PickerBase: ramp.SurfaceBar, | |||
| 55 | + | PickerSelection: EmphasisStyle(ramp.SurfaceActive, t), | |||
| 56 | + | PickerDescription: barDescription, | |||
| 57 | + | PickerSeparator: ramp.BorderMuted, | |||
| 58 | + | PromptBase: ramp.SurfaceBar, | |||
| 59 | + | CopyStatus: ramp.SurfaceBar, | |||
| 60 | + | SearchSelection: EmphasisStyle(ramp.SurfaceActive, t), | |||
| 61 | + | } | |||
| 62 | + | return withLegacyAliases(styles) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func secondarySurface(base renderer.Style) renderer.Style { | |||
| 66 | + | if !base.HasForegroundRGB || !base.HasBackgroundRGB { | |||
| 67 | + | return base | |||
| 68 | + | } | |||
| 69 | + | foreground, ok := secondaryText(base.ForegroundRGB, base.BackgroundRGB) | |||
| 70 | + | if !ok { | |||
| 71 | + | return base | |||
| 72 | + | } | |||
| 73 | + | base.ForegroundRGB = foreground | |||
| 74 | + | return base | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func indexedStyles(styles Styles, slot uint8) Styles { | |||
| 78 | + | styles.BorderMuted = indexedForeground(styles.BorderMuted, slot) | |||
| 79 | + | styles.BorderActive = indexedForeground(styles.BorderActive, slot) | |||
| 80 | + | styles.PickerSeparator = indexedForeground(styles.PickerSeparator, slot) | |||
| 81 | + | styles.Border = styles.BorderMuted | |||
| 82 | + | return styles | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | func indexedForeground(style renderer.Style, slot uint8) renderer.Style { | |||
| 86 | + | style.HasForegroundRGB = false | |||
| 87 | + | style.Foreground = int(slot) | |||
| 88 | + | style.HasBackgroundRGB = false | |||
| 89 | + | style.Background = -1 | |||
| 90 | + | return style | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // neutralStyles deliberately preserves the pre-accent neutral hierarchy. It | |||
| 94 | + | // is used for palette-off, forced themes, incomplete defaults, and RGB-less | |||
| 95 | + | // indexed fallbacks; it never performs a palette slot lookup. | |||
| 96 | + | func neutralStyles(t Theme) Styles { | |||
| 97 | + | status := renderer.DefaultStyle() | |||
| 98 | + | accent := inverseStyle() | |||
| 99 | + | border := renderer.DefaultStyle() | |||
| 100 | + | muted := renderer.DefaultStyle() | |||
| 101 | + | if usable(t) { | |||
| 102 | + | status = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, 0.12)) | |||
| 103 | + | accent = rgbSurface(t.Foreground, Blend(t.Background, t.Foreground, neutralAccentBlend)) | |||
| 104 | + | border = neutralBorderStyle(t) | |||
| 105 | + | muted = foregroundStyle(Blend(t.Foreground, t.Background, 0.45)) | |||
| 106 | + | } | |||
| 107 | + | styles := Styles{ | |||
| 108 | + | SurfaceBar: status, | |||
| 109 | + | SurfaceInactive: status, | |||
| 110 | + | SurfaceRecent: status, | |||
| 111 | + | SurfaceActive: accent, | |||
| 112 | + | BorderMuted: border, | |||
| 113 | + | BorderActive: border, | |||
| 114 | + | ||||
| 115 | + | TabInactive: EmphasisStyle(status, t), | |||
| 116 | + | TabInactiveTitle: MutedVariantStyle(status, t), | |||
| 117 | + | TabActive: EmphasisStyle(accent, t), | |||
| 118 | + | TabActiveTitle: MutedVariantStyle(accent, t), | |||
| 119 | + | MRURecent: status, | |||
| 120 | + | ||||
| 121 | + | PickerBase: status, | |||
| 122 | + | PickerSelection: EmphasisStyle(accent, t), | |||
| 123 | + | PickerDescription: muted, | |||
| 124 | + | PickerSeparator: muted, | |||
| 125 | + | PromptBase: status, | |||
| 126 | + | CopyStatus: accent, | |||
| 127 | + | SearchSelection: accent, | |||
| 128 | + | } | |||
| 129 | + | return withLegacyAliases(styles) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func neutralBorderStyle(t Theme) renderer.Style { | |||
| 133 | + | if !usable(t) { | |||
| 134 | + | return renderer.DefaultStyle() | |||
| 135 | + | } | |||
| 136 | + | return foregroundStyle(Blend(t.Foreground, t.Background, 0.40)) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func withLegacyAliases(styles Styles) Styles { | |||
| 140 | + | styles.StatusBar = styles.SurfaceBar | |||
| 141 | + | styles.Accent = styles.SurfaceActive | |||
| 142 | + | styles.Border = styles.BorderMuted | |||
| 143 | + | styles.Selection = styles.SurfaceActive | |||
| 144 | + | styles.PaletteDesc = styles.PickerDescription | |||
| 145 | + | styles.TabName = styles.TabInactive | |||
| 146 | + | styles.TabNameActive = styles.TabActive | |||
| 147 | + | styles.TabTitle = styles.TabInactiveTitle | |||
| 148 | + | styles.TabTitleActive = styles.TabActiveTitle | |||
| 149 | + | styles.PickerName = styles.PickerBase | |||
| 150 | + | styles.PickerSelectionName = styles.PickerSelection | |||
| 151 | + | styles.PickerSelectionMuted = secondarySurface(styles.PickerSelection) | |||
| 152 | + | return styles | |||
| 153 | + | } | |||
M internal/usecase/theme/styles.go +35 -34
| @@ -3,6 +3,7 @@ package theme | |||
| 3 | 3 | import ( | |
| 4 | 4 | "math" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 6 | 7 | "github.com/bnema/vev/pkg/renderer" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| @@ -58,47 +59,47 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |||
| 58 | 59 | return base | |
| 59 | 60 | } | |
| 60 | 61 | ||
| 61 | - | // Styles is the resolved set of terminal chrome styles for a client theme. | |
| 62 | + | // Styles is the complete semantic set of terminal chrome styles. The legacy | |
| 63 | + | // aliases remain until daemon renderers consume the semantic fields directly. | |
| 62 | 64 | type Styles struct { | |
| 63 | - | StatusBar renderer.Style | |
| 64 | - | Accent renderer.Style | |
| 65 | - | Border renderer.Style | |
| 66 | - | Selection renderer.Style | |
| 67 | - | CopyStatus renderer.Style | |
| 68 | - | PaletteDesc renderer.Style | |
| 65 | + | SurfaceBar renderer.Style | |
| 66 | + | SurfaceInactive renderer.Style | |
| 67 | + | SurfaceRecent renderer.Style | |
| 68 | + | SurfaceActive renderer.Style | |
| 69 | + | BorderMuted renderer.Style | |
| 70 | + | BorderActive renderer.Style | |
| 69 | 71 | ||
| 70 | - | TabName renderer.Style | |
| 71 | - | TabNameActive renderer.Style | |
| 72 | - | TabTitle renderer.Style | |
| 73 | - | TabTitleActive renderer.Style | |
| 72 | + | TabInactive renderer.Style | |
| 73 | + | TabInactiveTitle renderer.Style | |
| 74 | + | TabActive renderer.Style | |
| 75 | + | TabActiveTitle renderer.Style | |
| 76 | + | MRURecent renderer.Style | |
| 74 | 77 | ||
| 78 | + | PickerBase renderer.Style | |
| 79 | + | PickerSelection renderer.Style | |
| 80 | + | PickerDescription renderer.Style | |
| 81 | + | PickerSeparator renderer.Style | |
| 82 | + | PromptBase renderer.Style | |
| 83 | + | CopyStatus renderer.Style | |
| 84 | + | SearchSelection renderer.Style | |
| 85 | + | ||
| 86 | + | // Compatibility aliases for pre-cache render paths. | |
| 87 | + | StatusBar renderer.Style | |
| 88 | + | Accent renderer.Style | |
| 89 | + | Border renderer.Style | |
| 90 | + | Selection renderer.Style | |
| 91 | + | PaletteDesc renderer.Style | |
| 92 | + | TabName renderer.Style | |
| 93 | + | TabNameActive renderer.Style | |
| 94 | + | TabTitle renderer.Style | |
| 95 | + | TabTitleActive renderer.Style | |
| 75 | 96 | PickerName renderer.Style | |
| 76 | 97 | PickerSelectionName renderer.Style | |
| 77 | 98 | PickerSelectionMuted renderer.Style | |
| 78 | - | PickerSeparator renderer.Style | |
| 79 | 99 | } | |
| 80 | 100 | ||
| 81 | - | // NewStyles composes the terminal chrome styles from the semantic theme styles. | |
| 101 | + | // NewStyles applies the default automatic policy. New callers that own a | |
| 102 | + | // policy should use Resolve and retain its complete immutable result. | |
| 82 | 103 | func NewStyles(t Theme) Styles { | |
| 83 | - | statusBar := StatusBarStyle(t) | |
| 84 | - | accent := AccentStyle(t) | |
| 85 | - | selection := SelectionStyle(t) | |
| 86 | - | return Styles{ | |
| 87 | - | StatusBar: statusBar, | |
| 88 | - | Accent: accent, | |
| 89 | - | Border: BorderStyle(t), | |
| 90 | - | Selection: selection, | |
| 91 | - | CopyStatus: selection, | |
| 92 | - | PaletteDesc: MutedTextStyle(t), | |
| 93 | - | ||
| 94 | - | TabName: EmphasisStyle(statusBar, t), | |
| 95 | - | TabNameActive: EmphasisStyle(accent, t), | |
| 96 | - | TabTitle: PaletteMutedVariantStyle(statusBar, t), | |
| 97 | - | TabTitleActive: PaletteMutedVariantStyle(accent, t), | |
| 98 | - | ||
| 99 | - | PickerName: EmphasisStyle(renderer.DefaultStyle(), t), | |
| 100 | - | PickerSelectionName: EmphasisStyle(selection, t), | |
| 101 | - | PickerSelectionMuted: PaletteMutedVariantStyle(selection, t), | |
| 102 | - | PickerSeparator: MutedTextStyle(t), | |
| 103 | - | } | |
| 104 | + | return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles | |
| 104 | 105 | } | |
| @@ -3,6 +3,7 @@ package theme | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "math" | 4 | "math" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | "github.com/bnema/vev/pkg/renderer" | 7 | "github.com/bnema/vev/pkg/renderer" | ||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| @@ -58,47 +59,47 @@ func MRUFade(base renderer.Style, t Theme, i, count int) renderer.Style { | |||||
| 58 | return base | 59 | return base | ||
| 59 | } | 60 | } | ||
| 60 | 61 | ||||
| 61 | - | // Styles is the resolved set of terminal chrome styles for a client theme. | 62 | + | // Styles is the complete semantic set of terminal chrome styles. The legacy |
| 63 | + | // aliases remain until daemon renderers consume the semantic fields directly. | |||
| 62 | type Styles struct { | 64 | type Styles struct { | ||
| 63 | - | StatusBar renderer.Style | 65 | + | SurfaceBar renderer.Style |
| 64 | - | Accent renderer.Style | 66 | + | SurfaceInactive renderer.Style |
| 65 | - | Border renderer.Style | 67 | + | SurfaceRecent renderer.Style |
| 66 | - | Selection renderer.Style | 68 | + | SurfaceActive renderer.Style |
| 67 | - | CopyStatus renderer.Style | 69 | + | BorderMuted renderer.Style |
| 68 | - | PaletteDesc renderer.Style | 70 | + | BorderActive renderer.Style |
| 69 | 71 | ||||
| 70 | - | TabName renderer.Style | 72 | + | TabInactive renderer.Style |
| 71 | - | TabNameActive renderer.Style | 73 | + | TabInactiveTitle renderer.Style |
| 72 | - | TabTitle renderer.Style | 74 | + | TabActive renderer.Style |
| 73 | - | TabTitleActive renderer.Style | 75 | + | TabActiveTitle renderer.Style |
| 76 | + | MRURecent renderer.Style | |||
| 74 | 77 | ||||
| 78 | + | PickerBase renderer.Style | |||
| 79 | + | PickerSelection renderer.Style | |||
| 80 | + | PickerDescription renderer.Style | |||
| 81 | + | PickerSeparator renderer.Style | |||
| 82 | + | PromptBase renderer.Style | |||
| 83 | + | CopyStatus renderer.Style | |||
| 84 | + | SearchSelection renderer.Style | |||
| 85 | + | ||||
| 86 | + | // Compatibility aliases for pre-cache render paths. | |||
| 87 | + | StatusBar renderer.Style | |||
| 88 | + | Accent renderer.Style | |||
| 89 | + | Border renderer.Style | |||
| 90 | + | Selection renderer.Style | |||
| 91 | + | PaletteDesc renderer.Style | |||
| 92 | + | TabName renderer.Style | |||
| 93 | + | TabNameActive renderer.Style | |||
| 94 | + | TabTitle renderer.Style | |||
| 95 | + | TabTitleActive renderer.Style | |||
| 75 | PickerName renderer.Style | 96 | PickerName renderer.Style | ||
| 76 | PickerSelectionName renderer.Style | 97 | PickerSelectionName renderer.Style | ||
| 77 | PickerSelectionMuted renderer.Style | 98 | PickerSelectionMuted renderer.Style | ||
| 78 | - | PickerSeparator renderer.Style | |||
| 79 | } | 99 | } | ||
| 80 | 100 | ||||
| 81 | - | // NewStyles composes the terminal chrome styles from the semantic theme styles. | 101 | + | // NewStyles applies the default automatic policy. New callers that own a |
| 102 | + | // policy should use Resolve and retain its complete immutable result. | |||
| 82 | func NewStyles(t Theme) Styles { | 103 | func NewStyles(t Theme) Styles { | ||
| 83 | - | statusBar := StatusBarStyle(t) | 104 | + | return Resolve(t, domain.ThemeAccent{Mode: domain.ThemeAccentAuto}).Styles |
| 84 | - | accent := AccentStyle(t) | |||
| 85 | - | selection := SelectionStyle(t) | |||
| 86 | - | return Styles{ | |||
| 87 | - | StatusBar: statusBar, | |||
| 88 | - | Accent: accent, | |||
| 89 | - | Border: BorderStyle(t), | |||
| 90 | - | Selection: selection, | |||
| 91 | - | CopyStatus: selection, | |||
| 92 | - | PaletteDesc: MutedTextStyle(t), | |||
| 93 | - | ||||
| 94 | - | TabName: EmphasisStyle(statusBar, t), | |||
| 95 | - | TabNameActive: EmphasisStyle(accent, t), | |||
| 96 | - | TabTitle: PaletteMutedVariantStyle(statusBar, t), | |||
| 97 | - | TabTitleActive: PaletteMutedVariantStyle(accent, t), | |||
| 98 | - | ||||
| 99 | - | PickerName: EmphasisStyle(renderer.DefaultStyle(), t), | |||
| 100 | - | PickerSelectionName: EmphasisStyle(selection, t), | |||
| 101 | - | PickerSelectionMuted: PaletteMutedVariantStyle(selection, t), | |||
| 102 | - | PickerSeparator: MutedTextStyle(t), | |||
| 103 | - | } | |||
| 104 | } | 105 | } | ||
M internal/usecase/theme/styles_test.go +0 -323
| @@ -254,326 +254,3 @@ func TestThemeIsComparable(t *testing.T) { | |||
| 254 | 254 | themes := map[Theme]bool{BuiltinDark: true} | |
| 255 | 255 | require.True(t, themes[BuiltinDark]) | |
| 256 | 256 | } | |
| 257 | - | ||
| 258 | - | func TestPaletteAccentBackground(t *testing.T) { | |
| 259 | - | base := Theme{ | |
| 260 | - | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 261 | - | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 262 | - | HasFG: true, | |
| 263 | - | HasBG: true, | |
| 264 | - | Known: true, | |
| 265 | - | TrueColor: true, | |
| 266 | - | UsePalette: true, | |
| 267 | - | } | |
| 268 | - | neutral := Blend(base.Background, base.Foreground, accentBlend) | |
| 269 | - | ||
| 270 | - | tests := []struct { | |
| 271 | - | name string | |
| 272 | - | palette [16]renderer.RGB | |
| 273 | - | known uint16 | |
| 274 | - | usePalette bool | |
| 275 | - | want renderer.RGB | |
| 276 | - | wantContrastMin bool | |
| 277 | - | }{ | |
| 278 | - | { | |
| 279 | - | name: "slot 4 supplies a contrasting accent background", | |
| 280 | - | palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}}, | |
| 281 | - | known: 1<<4 | 1<<12, | |
| 282 | - | usePalette: true, | |
| 283 | - | want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend), | |
| 284 | - | wantContrastMin: true, | |
| 285 | - | }, | |
| 286 | - | { | |
| 287 | - | name: "slot 4 failure escalates to slot 12", | |
| 288 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}}, | |
| 289 | - | known: 1<<4 | 1<<12, | |
| 290 | - | usePalette: true, | |
| 291 | - | want: Blend(base.Background, renderer.RGB{}, accentBlend), | |
| 292 | - | wantContrastMin: true, | |
| 293 | - | }, | |
| 294 | - | { | |
| 295 | - | name: "both palette blues failing uses exact neutral blend", | |
| 296 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |
| 297 | - | known: 1<<4 | 1<<12, | |
| 298 | - | usePalette: true, | |
| 299 | - | want: neutral, | |
| 300 | - | }, | |
| 301 | - | { | |
| 302 | - | name: "missing palette blues uses exact neutral blend", | |
| 303 | - | usePalette: true, | |
| 304 | - | want: neutral, | |
| 305 | - | }, | |
| 306 | - | { | |
| 307 | - | name: "palette disabled preserves neutral accent", | |
| 308 | - | palette: [16]renderer.RGB{4: {B: 255}}, | |
| 309 | - | known: 1 << 4, | |
| 310 | - | usePalette: false, | |
| 311 | - | want: neutral, | |
| 312 | - | }, | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | for _, tt := range tests { | |
| 316 | - | t.Run(tt.name, func(t *testing.T) { | |
| 317 | - | theme := base | |
| 318 | - | theme.Palette = tt.palette | |
| 319 | - | theme.PaletteKnown = tt.known | |
| 320 | - | theme.UsePalette = tt.usePalette | |
| 321 | - | ||
| 322 | - | got := AccentStyle(theme) | |
| 323 | - | require.True(t, got.HasBackgroundRGB) | |
| 324 | - | require.Equal(t, tt.want, got.BackgroundRGB) | |
| 325 | - | if tt.wantContrastMin { | |
| 326 | - | require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin) | |
| 327 | - | } | |
| 328 | - | }) | |
| 329 | - | } | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | func TestPaletteTitleHueDerivation(t *testing.T) { | |
| 333 | - | base := Theme{ | |
| 334 | - | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 335 | - | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 336 | - | HasFG: true, | |
| 337 | - | HasBG: true, | |
| 338 | - | Known: true, | |
| 339 | - | TrueColor: true, | |
| 340 | - | UsePalette: true, | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | tests := []struct { | |
| 344 | - | name string | |
| 345 | - | theme Theme | |
| 346 | - | palette [16]renderer.RGB | |
| 347 | - | known uint16 | |
| 348 | - | }{ | |
| 349 | - | { | |
| 350 | - | name: "slot 4 is used when it contrasts for each title", | |
| 351 | - | theme: Theme{ | |
| 352 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 353 | - | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |
| 354 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 355 | - | }, | |
| 356 | - | palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}}, | |
| 357 | - | ||
| 358 | - | known: 1<<4 | 1<<12, | |
| 359 | - | }, | |
| 360 | - | { | |
| 361 | - | name: "each title escalates independently from slot 4 to slot 12", | |
| 362 | - | palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}}, | |
| 363 | - | known: 1<<4 | 1<<12, | |
| 364 | - | }, | |
| 365 | - | { | |
| 366 | - | name: "both title hues failing retains neutral muted styles", | |
| 367 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |
| 368 | - | known: 1<<4 | 1<<12, | |
| 369 | - | }, | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | for _, tt := range tests { | |
| 373 | - | t.Run(tt.name, func(t *testing.T) { | |
| 374 | - | theme := base | |
| 375 | - | if tt.theme.Known { | |
| 376 | - | theme = tt.theme | |
| 377 | - | } | |
| 378 | - | theme.Palette = tt.palette | |
| 379 | - | theme.PaletteKnown = tt.known | |
| 380 | - | styles := NewStyles(theme) | |
| 381 | - | status := StatusBarStyle(theme) | |
| 382 | - | accent := AccentStyle(theme) | |
| 383 | - | ||
| 384 | - | if tt.name == "both title hues failing retains neutral muted styles" { | |
| 385 | - | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme))) | |
| 386 | - | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |
| 387 | - | return | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | require.True(t, styles.TabTitle.HasForegroundRGB) | |
| 391 | - | require.True(t, styles.TabTitleActive.HasForegroundRGB) | |
| 392 | - | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin) | |
| 393 | - | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin) | |
| 394 | - | ||
| 395 | - | if tt.name == "slot 4 is used when it contrasts for each title" { | |
| 396 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB) | |
| 397 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |
| 398 | - | } | |
| 399 | - | if tt.name == "each title escalates independently from slot 4 to slot 12" { | |
| 400 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB) | |
| 401 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |
| 402 | - | } | |
| 403 | - | }) | |
| 404 | - | } | |
| 405 | - | } | |
| 406 | - | ||
| 407 | - | func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) { | |
| 408 | - | base := Theme{ | |
| 409 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 410 | - | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |
| 411 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 412 | - | } | |
| 413 | - | ||
| 414 | - | tests := []struct { | |
| 415 | - | name string | |
| 416 | - | theme Theme | |
| 417 | - | wantForeground renderer.RGB | |
| 418 | - | wantIndexed int | |
| 419 | - | }{ | |
| 420 | - | { | |
| 421 | - | name: "RGB palette foreground", | |
| 422 | - | theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(), | |
| 423 | - | wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend), | |
| 424 | - | wantIndexed: -1, | |
| 425 | - | }, | |
| 426 | - | { | |
| 427 | - | name: "indexed foreground fallback", | |
| 428 | - | theme: func() Theme { t := base; t.SchemeKnown = true; return t }(), | |
| 429 | - | wantIndexed: ansiBrightBlue, | |
| 430 | - | }, | |
| 431 | - | } | |
| 432 | - | ||
| 433 | - | for _, tt := range tests { | |
| 434 | - | t.Run(tt.name, func(t *testing.T) { | |
| 435 | - | styles := NewStyles(tt.theme) | |
| 436 | - | ||
| 437 | - | for name, style := range map[string]renderer.Style{ | |
| 438 | - | "border": styles.Border, | |
| 439 | - | "muted text": styles.PaletteDesc, | |
| 440 | - | } { | |
| 441 | - | require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name) | |
| 442 | - | if tt.wantIndexed >= 0 { | |
| 443 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 444 | - | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |
| 445 | - | } else { | |
| 446 | - | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |
| 447 | - | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |
| 448 | - | } | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | status := StatusBarStyle(tt.theme) | |
| 452 | - | accent := AccentStyle(tt.theme) | |
| 453 | - | for name, style := range map[string]renderer.Style{ | |
| 454 | - | "title": styles.TabTitle, | |
| 455 | - | "active title": styles.TabTitleActive, | |
| 456 | - | } { | |
| 457 | - | wantBackground := status.BackgroundRGB | |
| 458 | - | if name == "active title" { | |
| 459 | - | wantBackground = accent.BackgroundRGB | |
| 460 | - | } | |
| 461 | - | require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name) | |
| 462 | - | require.Equal(t, wantBackground, style.BackgroundRGB, name) | |
| 463 | - | if tt.wantIndexed >= 0 { | |
| 464 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 465 | - | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |
| 466 | - | } else { | |
| 467 | - | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |
| 468 | - | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |
| 469 | - | } | |
| 470 | - | } | |
| 471 | - | }) | |
| 472 | - | } | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | func TestPaletteIndexedForegroundFallback(t *testing.T) { | |
| 476 | - | base := Theme{UsePalette: true, SchemeKnown: true, Known: true} | |
| 477 | - | ||
| 478 | - | tests := []struct { | |
| 479 | - | name string | |
| 480 | - | theme Theme | |
| 481 | - | want int | |
| 482 | - | ok bool | |
| 483 | - | }{ | |
| 484 | - | {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true}, | |
| 485 | - | {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true}, | |
| 486 | - | {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}}, | |
| 487 | - | {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}}, | |
| 488 | - | } | |
| 489 | - | ||
| 490 | - | for _, tt := range tests { | |
| 491 | - | t.Run(tt.name, func(t *testing.T) { | |
| 492 | - | styles := NewStyles(tt.theme) | |
| 493 | - | baselineTheme := tt.theme | |
| 494 | - | baselineTheme.UsePalette = false | |
| 495 | - | baseline := NewStyles(baselineTheme) | |
| 496 | - | for name, style := range map[string]renderer.Style{ | |
| 497 | - | "title": styles.TabTitle, | |
| 498 | - | "active title": styles.TabTitleActive, | |
| 499 | - | "border": styles.Border, | |
| 500 | - | "muted text": styles.PaletteDesc, | |
| 501 | - | } { | |
| 502 | - | require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name) | |
| 503 | - | if tt.ok { | |
| 504 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 505 | - | require.Equal(t, tt.want, style.Foreground, name) | |
| 506 | - | } else { | |
| 507 | - | want := map[string]renderer.Style{ | |
| 508 | - | "title": baseline.TabTitle, "active title": baseline.TabTitleActive, | |
| 509 | - | "border": baseline.Border, "muted text": baseline.PaletteDesc, | |
| 510 | - | }[name] | |
| 511 | - | require.Equal(t, want, style, name) | |
| 512 | - | } | |
| 513 | - | } | |
| 514 | - | }) | |
| 515 | - | } | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) { | |
| 519 | - | theme := Theme{ | |
| 520 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 521 | - | Background: renderer.RGB{R: 30, G: 30, B: 30}, | |
| 522 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 523 | - | UsePalette: true, SchemeKnown: true, | |
| 524 | - | } | |
| 525 | - | styles := NewStyles(theme) | |
| 526 | - | for name, style := range map[string]renderer.Style{ | |
| 527 | - | "title": styles.TabTitle, "active title": styles.TabTitleActive, | |
| 528 | - | "border": styles.Border, "muted text": styles.PaletteDesc, | |
| 529 | - | } { | |
| 530 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 531 | - | require.Equal(t, ansiBrightBlue, style.Foreground, name) | |
| 532 | - | require.Equal(t, -1, style.Background, "%s must not use an indexed background", name) | |
| 533 | - | } | |
| 534 | - | } | |
| 535 | - | ||
| 536 | - | func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) { | |
| 537 | - | palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}} | |
| 538 | - | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |
| 539 | - | withDisabledPalette := usableTheme | |
| 540 | - | withDisabledPalette.Palette = palette | |
| 541 | - | withDisabledPalette.PaletteKnown = 1<<4 | 1<<12 | |
| 542 | - | withDisabledPalette.UsePalette = false | |
| 543 | - | ||
| 544 | - | require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette)) | |
| 545 | - | ||
| 546 | - | forced := Theme{Known: true, SchemeKnown: true, UsePalette: false} | |
| 547 | - | require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced)) | |
| 548 | - | } | |
| 549 | - | ||
| 550 | - | func TestNewStylesComposesSemanticStyles(t *testing.T) { | |
| 551 | - | theme := Theme{ | |
| 552 | - | Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8}, | |
| 553 | - | Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e}, | |
| 554 | - | HasFG: true, | |
| 555 | - | HasBG: true, | |
| 556 | - | Known: true, | |
| 557 | - | TrueColor: true, | |
| 558 | - | } | |
| 559 | - | ||
| 560 | - | styles := NewStyles(theme) | |
| 561 | - | statusBar := StatusBarStyle(theme) | |
| 562 | - | accent := AccentStyle(theme) | |
| 563 | - | selection := SelectionStyle(theme) | |
| 564 | - | ||
| 565 | - | require.True(t, styles.StatusBar.Equal(statusBar)) | |
| 566 | - | require.True(t, styles.Accent.Equal(accent)) | |
| 567 | - | require.True(t, styles.Border.Equal(BorderStyle(theme))) | |
| 568 | - | require.True(t, styles.Selection.Equal(selection)) | |
| 569 | - | require.True(t, styles.CopyStatus.Equal(selection)) | |
| 570 | - | require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme))) | |
| 571 | - | require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme))) | |
| 572 | - | require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme))) | |
| 573 | - | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme))) | |
| 574 | - | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |
| 575 | - | require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme))) | |
| 576 | - | require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme))) | |
| 577 | - | require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme))) | |
| 578 | - | require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme))) | |
| 579 | - | } | |
| @@ -254,326 +254,3 @@ func TestThemeIsComparable(t *testing.T) { | |||||
| 254 | themes := map[Theme]bool{BuiltinDark: true} | 254 | themes := map[Theme]bool{BuiltinDark: true} | ||
| 255 | require.True(t, themes[BuiltinDark]) | 255 | require.True(t, themes[BuiltinDark]) | ||
| 256 | } | 256 | } | ||
| 257 | - | ||||
| 258 | - | func TestPaletteAccentBackground(t *testing.T) { | |||
| 259 | - | base := Theme{ | |||
| 260 | - | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |||
| 261 | - | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |||
| 262 | - | HasFG: true, | |||
| 263 | - | HasBG: true, | |||
| 264 | - | Known: true, | |||
| 265 | - | TrueColor: true, | |||
| 266 | - | UsePalette: true, | |||
| 267 | - | } | |||
| 268 | - | neutral := Blend(base.Background, base.Foreground, accentBlend) | |||
| 269 | - | ||||
| 270 | - | tests := []struct { | |||
| 271 | - | name string | |||
| 272 | - | palette [16]renderer.RGB | |||
| 273 | - | known uint16 | |||
| 274 | - | usePalette bool | |||
| 275 | - | want renderer.RGB | |||
| 276 | - | wantContrastMin bool | |||
| 277 | - | }{ | |||
| 278 | - | { | |||
| 279 | - | name: "slot 4 supplies a contrasting accent background", | |||
| 280 | - | palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}}, | |||
| 281 | - | known: 1<<4 | 1<<12, | |||
| 282 | - | usePalette: true, | |||
| 283 | - | want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend), | |||
| 284 | - | wantContrastMin: true, | |||
| 285 | - | }, | |||
| 286 | - | { | |||
| 287 | - | name: "slot 4 failure escalates to slot 12", | |||
| 288 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}}, | |||
| 289 | - | known: 1<<4 | 1<<12, | |||
| 290 | - | usePalette: true, | |||
| 291 | - | want: Blend(base.Background, renderer.RGB{}, accentBlend), | |||
| 292 | - | wantContrastMin: true, | |||
| 293 | - | }, | |||
| 294 | - | { | |||
| 295 | - | name: "both palette blues failing uses exact neutral blend", | |||
| 296 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |||
| 297 | - | known: 1<<4 | 1<<12, | |||
| 298 | - | usePalette: true, | |||
| 299 | - | want: neutral, | |||
| 300 | - | }, | |||
| 301 | - | { | |||
| 302 | - | name: "missing palette blues uses exact neutral blend", | |||
| 303 | - | usePalette: true, | |||
| 304 | - | want: neutral, | |||
| 305 | - | }, | |||
| 306 | - | { | |||
| 307 | - | name: "palette disabled preserves neutral accent", | |||
| 308 | - | palette: [16]renderer.RGB{4: {B: 255}}, | |||
| 309 | - | known: 1 << 4, | |||
| 310 | - | usePalette: false, | |||
| 311 | - | want: neutral, | |||
| 312 | - | }, | |||
| 313 | - | } | |||
| 314 | - | ||||
| 315 | - | for _, tt := range tests { | |||
| 316 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 317 | - | theme := base | |||
| 318 | - | theme.Palette = tt.palette | |||
| 319 | - | theme.PaletteKnown = tt.known | |||
| 320 | - | theme.UsePalette = tt.usePalette | |||
| 321 | - | ||||
| 322 | - | got := AccentStyle(theme) | |||
| 323 | - | require.True(t, got.HasBackgroundRGB) | |||
| 324 | - | require.Equal(t, tt.want, got.BackgroundRGB) | |||
| 325 | - | if tt.wantContrastMin { | |||
| 326 | - | require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin) | |||
| 327 | - | } | |||
| 328 | - | }) | |||
| 329 | - | } | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | func TestPaletteTitleHueDerivation(t *testing.T) { | |||
| 333 | - | base := Theme{ | |||
| 334 | - | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |||
| 335 | - | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |||
| 336 | - | HasFG: true, | |||
| 337 | - | HasBG: true, | |||
| 338 | - | Known: true, | |||
| 339 | - | TrueColor: true, | |||
| 340 | - | UsePalette: true, | |||
| 341 | - | } | |||
| 342 | - | ||||
| 343 | - | tests := []struct { | |||
| 344 | - | name string | |||
| 345 | - | theme Theme | |||
| 346 | - | palette [16]renderer.RGB | |||
| 347 | - | known uint16 | |||
| 348 | - | }{ | |||
| 349 | - | { | |||
| 350 | - | name: "slot 4 is used when it contrasts for each title", | |||
| 351 | - | theme: Theme{ | |||
| 352 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |||
| 353 | - | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |||
| 354 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 355 | - | }, | |||
| 356 | - | palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}}, | |||
| 357 | - | ||||
| 358 | - | known: 1<<4 | 1<<12, | |||
| 359 | - | }, | |||
| 360 | - | { | |||
| 361 | - | name: "each title escalates independently from slot 4 to slot 12", | |||
| 362 | - | palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}}, | |||
| 363 | - | known: 1<<4 | 1<<12, | |||
| 364 | - | }, | |||
| 365 | - | { | |||
| 366 | - | name: "both title hues failing retains neutral muted styles", | |||
| 367 | - | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |||
| 368 | - | known: 1<<4 | 1<<12, | |||
| 369 | - | }, | |||
| 370 | - | } | |||
| 371 | - | ||||
| 372 | - | for _, tt := range tests { | |||
| 373 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 374 | - | theme := base | |||
| 375 | - | if tt.theme.Known { | |||
| 376 | - | theme = tt.theme | |||
| 377 | - | } | |||
| 378 | - | theme.Palette = tt.palette | |||
| 379 | - | theme.PaletteKnown = tt.known | |||
| 380 | - | styles := NewStyles(theme) | |||
| 381 | - | status := StatusBarStyle(theme) | |||
| 382 | - | accent := AccentStyle(theme) | |||
| 383 | - | ||||
| 384 | - | if tt.name == "both title hues failing retains neutral muted styles" { | |||
| 385 | - | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme))) | |||
| 386 | - | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |||
| 387 | - | return | |||
| 388 | - | } | |||
| 389 | - | ||||
| 390 | - | require.True(t, styles.TabTitle.HasForegroundRGB) | |||
| 391 | - | require.True(t, styles.TabTitleActive.HasForegroundRGB) | |||
| 392 | - | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin) | |||
| 393 | - | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin) | |||
| 394 | - | ||||
| 395 | - | if tt.name == "slot 4 is used when it contrasts for each title" { | |||
| 396 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB) | |||
| 397 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |||
| 398 | - | } | |||
| 399 | - | if tt.name == "each title escalates independently from slot 4 to slot 12" { | |||
| 400 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB) | |||
| 401 | - | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |||
| 402 | - | } | |||
| 403 | - | }) | |||
| 404 | - | } | |||
| 405 | - | } | |||
| 406 | - | ||||
| 407 | - | func TestPaletteForegroundStylesKeepTheirRenderedBackgrounds(t *testing.T) { | |||
| 408 | - | base := Theme{ | |||
| 409 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |||
| 410 | - | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |||
| 411 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 412 | - | } | |||
| 413 | - | ||||
| 414 | - | tests := []struct { | |||
| 415 | - | name string | |||
| 416 | - | theme Theme | |||
| 417 | - | wantForeground renderer.RGB | |||
| 418 | - | wantIndexed int | |||
| 419 | - | }{ | |||
| 420 | - | { | |||
| 421 | - | name: "RGB palette foreground", | |||
| 422 | - | theme: func() Theme { t := base; t.PaletteKnown = 1 << ansiBlue; return t }(), | |||
| 423 | - | wantForeground: Blend(base.Foreground, renderer.RGB{}, titleHueBlend), | |||
| 424 | - | wantIndexed: -1, | |||
| 425 | - | }, | |||
| 426 | - | { | |||
| 427 | - | name: "indexed foreground fallback", | |||
| 428 | - | theme: func() Theme { t := base; t.SchemeKnown = true; return t }(), | |||
| 429 | - | wantIndexed: ansiBrightBlue, | |||
| 430 | - | }, | |||
| 431 | - | } | |||
| 432 | - | ||||
| 433 | - | for _, tt := range tests { | |||
| 434 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 435 | - | styles := NewStyles(tt.theme) | |||
| 436 | - | ||||
| 437 | - | for name, style := range map[string]renderer.Style{ | |||
| 438 | - | "border": styles.Border, | |||
| 439 | - | "muted text": styles.PaletteDesc, | |||
| 440 | - | } { | |||
| 441 | - | require.False(t, style.HasBackgroundRGB, "%s must remain foreground-only", name) | |||
| 442 | - | if tt.wantIndexed >= 0 { | |||
| 443 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 444 | - | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |||
| 445 | - | } else { | |||
| 446 | - | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |||
| 447 | - | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |||
| 448 | - | } | |||
| 449 | - | } | |||
| 450 | - | ||||
| 451 | - | status := StatusBarStyle(tt.theme) | |||
| 452 | - | accent := AccentStyle(tt.theme) | |||
| 453 | - | for name, style := range map[string]renderer.Style{ | |||
| 454 | - | "title": styles.TabTitle, | |||
| 455 | - | "active title": styles.TabTitleActive, | |||
| 456 | - | } { | |||
| 457 | - | wantBackground := status.BackgroundRGB | |||
| 458 | - | if name == "active title" { | |||
| 459 | - | wantBackground = accent.BackgroundRGB | |||
| 460 | - | } | |||
| 461 | - | require.True(t, style.HasBackgroundRGB, "%s must retain its title background", name) | |||
| 462 | - | require.Equal(t, wantBackground, style.BackgroundRGB, name) | |||
| 463 | - | if tt.wantIndexed >= 0 { | |||
| 464 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 465 | - | require.Equal(t, tt.wantIndexed, style.Foreground, name) | |||
| 466 | - | } else { | |||
| 467 | - | require.True(t, style.HasForegroundRGB, "%s must use the RGB palette foreground", name) | |||
| 468 | - | require.Equal(t, tt.wantForeground, style.ForegroundRGB, name) | |||
| 469 | - | } | |||
| 470 | - | } | |||
| 471 | - | }) | |||
| 472 | - | } | |||
| 473 | - | } | |||
| 474 | - | ||||
| 475 | - | func TestPaletteIndexedForegroundFallback(t *testing.T) { | |||
| 476 | - | base := Theme{UsePalette: true, SchemeKnown: true, Known: true} | |||
| 477 | - | ||||
| 478 | - | tests := []struct { | |||
| 479 | - | name string | |||
| 480 | - | theme Theme | |||
| 481 | - | want int | |||
| 482 | - | ok bool | |||
| 483 | - | }{ | |||
| 484 | - | {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true}, | |||
| 485 | - | {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true}, | |||
| 486 | - | {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}}, | |||
| 487 | - | {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}}, | |||
| 488 | - | } | |||
| 489 | - | ||||
| 490 | - | for _, tt := range tests { | |||
| 491 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 492 | - | styles := NewStyles(tt.theme) | |||
| 493 | - | baselineTheme := tt.theme | |||
| 494 | - | baselineTheme.UsePalette = false | |||
| 495 | - | baseline := NewStyles(baselineTheme) | |||
| 496 | - | for name, style := range map[string]renderer.Style{ | |||
| 497 | - | "title": styles.TabTitle, | |||
| 498 | - | "active title": styles.TabTitleActive, | |||
| 499 | - | "border": styles.Border, | |||
| 500 | - | "muted text": styles.PaletteDesc, | |||
| 501 | - | } { | |||
| 502 | - | require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name) | |||
| 503 | - | if tt.ok { | |||
| 504 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 505 | - | require.Equal(t, tt.want, style.Foreground, name) | |||
| 506 | - | } else { | |||
| 507 | - | want := map[string]renderer.Style{ | |||
| 508 | - | "title": baseline.TabTitle, "active title": baseline.TabTitleActive, | |||
| 509 | - | "border": baseline.Border, "muted text": baseline.PaletteDesc, | |||
| 510 | - | }[name] | |||
| 511 | - | require.Equal(t, want, style, name) | |||
| 512 | - | } | |||
| 513 | - | } | |||
| 514 | - | }) | |||
| 515 | - | } | |||
| 516 | - | } | |||
| 517 | - | ||||
| 518 | - | func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) { | |||
| 519 | - | theme := Theme{ | |||
| 520 | - | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |||
| 521 | - | Background: renderer.RGB{R: 30, G: 30, B: 30}, | |||
| 522 | - | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |||
| 523 | - | UsePalette: true, SchemeKnown: true, | |||
| 524 | - | } | |||
| 525 | - | styles := NewStyles(theme) | |||
| 526 | - | for name, style := range map[string]renderer.Style{ | |||
| 527 | - | "title": styles.TabTitle, "active title": styles.TabTitleActive, | |||
| 528 | - | "border": styles.Border, "muted text": styles.PaletteDesc, | |||
| 529 | - | } { | |||
| 530 | - | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 531 | - | require.Equal(t, ansiBrightBlue, style.Foreground, name) | |||
| 532 | - | require.Equal(t, -1, style.Background, "%s must not use an indexed background", name) | |||
| 533 | - | } | |||
| 534 | - | } | |||
| 535 | - | ||||
| 536 | - | func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) { | |||
| 537 | - | palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}} | |||
| 538 | - | usableTheme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 100, G: 100, B: 100}, HasFG: true, HasBG: true, Known: true, TrueColor: true} | |||
| 539 | - | withDisabledPalette := usableTheme | |||
| 540 | - | withDisabledPalette.Palette = palette | |||
| 541 | - | withDisabledPalette.PaletteKnown = 1<<4 | 1<<12 | |||
| 542 | - | withDisabledPalette.UsePalette = false | |||
| 543 | - | ||||
| 544 | - | require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette)) | |||
| 545 | - | ||||
| 546 | - | forced := Theme{Known: true, SchemeKnown: true, UsePalette: false} | |||
| 547 | - | require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced)) | |||
| 548 | - | } | |||
| 549 | - | ||||
| 550 | - | func TestNewStylesComposesSemanticStyles(t *testing.T) { | |||
| 551 | - | theme := Theme{ | |||
| 552 | - | Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8}, | |||
| 553 | - | Background: renderer.RGB{R: 0x0a, G: 0x14, B: 0x1e}, | |||
| 554 | - | HasFG: true, | |||
| 555 | - | HasBG: true, | |||
| 556 | - | Known: true, | |||
| 557 | - | TrueColor: true, | |||
| 558 | - | } | |||
| 559 | - | ||||
| 560 | - | styles := NewStyles(theme) | |||
| 561 | - | statusBar := StatusBarStyle(theme) | |||
| 562 | - | accent := AccentStyle(theme) | |||
| 563 | - | selection := SelectionStyle(theme) | |||
| 564 | - | ||||
| 565 | - | require.True(t, styles.StatusBar.Equal(statusBar)) | |||
| 566 | - | require.True(t, styles.Accent.Equal(accent)) | |||
| 567 | - | require.True(t, styles.Border.Equal(BorderStyle(theme))) | |||
| 568 | - | require.True(t, styles.Selection.Equal(selection)) | |||
| 569 | - | require.True(t, styles.CopyStatus.Equal(selection)) | |||
| 570 | - | require.True(t, styles.PaletteDesc.Equal(MutedTextStyle(theme))) | |||
| 571 | - | require.True(t, styles.TabName.Equal(EmphasisStyle(statusBar, theme))) | |||
| 572 | - | require.True(t, styles.TabNameActive.Equal(EmphasisStyle(accent, theme))) | |||
| 573 | - | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(statusBar, theme))) | |||
| 574 | - | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |||
| 575 | - | require.True(t, styles.PickerName.Equal(EmphasisStyle(renderer.DefaultStyle(), theme))) | |||
| 576 | - | require.True(t, styles.PickerSelectionName.Equal(EmphasisStyle(selection, theme))) | |||
| 577 | - | require.True(t, styles.PickerSelectionMuted.Equal(MutedVariantStyle(selection, theme))) | |||
| 578 | - | require.True(t, styles.PickerSeparator.Equal(MutedTextStyle(theme))) | |||
| 579 | - | } | |||
M internal/usecase/theme/theme.go +10 -135
| @@ -356,89 +356,22 @@ func StatusBarStyle(t Theme) renderer.Style { | |||
| 356 | 356 | return style | |
| 357 | 357 | } | |
| 358 | 358 | ||
| 359 | - | const ( | |
| 360 | - | ansiBlue = 4 | |
| 361 | - | ansiBrightBlue = 12 | |
| 362 | - | accentBlend = 0.25 | |
| 363 | - | titleHueBlend = 0.2 | |
| 364 | - | ) | |
| 365 | - | ||
| 359 | + | // The compatibility helpers below retain the neutral styles for isolated | |
| 360 | + | // callers. Accent-aware construction goes exclusively through Resolve. | |
| 366 | 361 | func AccentStyle(t Theme) renderer.Style { | |
| 367 | - | if !usable(t) { | |
| 368 | - | return inverseStyle() | |
| 369 | - | } | |
| 370 | - | style := renderer.DefaultStyle() | |
| 371 | - | style.HasForegroundRGB = true | |
| 372 | - | style.ForegroundRGB = t.Foreground | |
| 373 | - | style.HasBackgroundRGB = true | |
| 374 | - | style.BackgroundRGB = accentBackground(t) | |
| 375 | - | return style | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | // accentBackground uses a terminal's blue palette only when the resulting | |
| 379 | - | // foreground/background pair remains legible. A neutral blend is always the | |
| 380 | - | // fallback, including when palette inheritance is disabled. | |
| 381 | - | func accentBackground(t Theme) renderer.RGB { | |
| 382 | - | neutral := Blend(t.Background, t.Foreground, accentBlend) | |
| 383 | - | if !t.UsePalette { | |
| 384 | - | return neutral | |
| 385 | - | } | |
| 386 | - | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |
| 387 | - | blue, ok := t.PaletteColor(slot) | |
| 388 | - | if !ok { | |
| 389 | - | continue | |
| 390 | - | } | |
| 391 | - | candidate := Blend(t.Background, blue, accentBlend) | |
| 392 | - | if ContrastRatio(t.Foreground, candidate) >= accentContrastMin { | |
| 393 | - | return candidate | |
| 394 | - | } | |
| 395 | - | } | |
| 396 | - | return neutral | |
| 362 | + | return neutralStyles(t).SurfaceActive | |
| 397 | 363 | } | |
| 398 | 364 | ||
| 399 | 365 | func BorderStyle(t Theme) renderer.Style { | |
| 400 | - | neutral := renderer.DefaultStyle() | |
| 401 | - | if usable(t) { | |
| 402 | - | neutral.HasForegroundRGB = true | |
| 403 | - | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |
| 404 | - | } | |
| 405 | - | if !t.UsePalette { | |
| 406 | - | return neutral | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | base := renderer.DefaultStyle() | |
| 410 | - | if usable(t) { | |
| 411 | - | base.HasForegroundRGB = true | |
| 412 | - | base.ForegroundRGB = t.Foreground | |
| 413 | - | base.HasBackgroundRGB = true | |
| 414 | - | base.BackgroundRGB = t.Background | |
| 415 | - | } | |
| 416 | - | return paletteForegroundStyle(base, neutral, t) | |
| 366 | + | return neutralStyles(t).BorderMuted | |
| 417 | 367 | } | |
| 418 | 368 | ||
| 419 | 369 | func MutedTextStyle(t Theme) renderer.Style { | |
| 420 | - | neutral := renderer.DefaultStyle() | |
| 421 | - | if usable(t) { | |
| 422 | - | neutral.HasForegroundRGB = true | |
| 423 | - | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 424 | - | } | |
| 425 | - | if !t.UsePalette { | |
| 426 | - | return neutral | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | base := renderer.DefaultStyle() | |
| 430 | - | if usable(t) { | |
| 431 | - | base.HasForegroundRGB = true | |
| 432 | - | base.ForegroundRGB = t.Foreground | |
| 433 | - | base.HasBackgroundRGB = true | |
| 434 | - | base.BackgroundRGB = t.Background | |
| 435 | - | } | |
| 436 | - | return paletteForegroundStyle(base, neutral, t) | |
| 370 | + | return neutralStyles(t).PickerDescription | |
| 437 | 371 | } | |
| 438 | 372 | ||
| 439 | 373 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | |
| 440 | - | // foreground toward its own background, subtler than MutedTextStyle's 0.45 | |
| 441 | - | // (which blends the theme's global fg/bg instead of a style's own colors). | |
| 374 | + | // foreground toward its own background. | |
| 442 | 375 | const mutedVariantBlend = 0.4 | |
| 443 | 376 | ||
| 444 | 377 | // EmphasisStyle returns base with bold applied when the client theme supports | |
| @@ -452,81 +385,23 @@ func EmphasisStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 452 | 385 | return out | |
| 453 | 386 | } | |
| 454 | 387 | ||
| 455 | - | // MutedVariantStyle fades base's foreground toward its background when both | |
| 456 | - | // are RGB and the client theme supports styled output; otherwise base is | |
| 457 | - | // returned unchanged. When base has an RGB foreground but no RGB background, | |
| 458 | - | // it fades toward t.Background instead. | |
| 388 | + | // MutedVariantStyle fades base's foreground toward its own background. | |
| 459 | 389 | func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |
| 460 | 390 | if !usable(t) || !base.HasForegroundRGB { | |
| 461 | 391 | return base | |
| 462 | 392 | } | |
| 463 | - | bg := base.BackgroundRGB | |
| 393 | + | background := base.BackgroundRGB | |
| 464 | 394 | if !base.HasBackgroundRGB { | |
| 465 | 395 | if !t.HasBG { | |
| 466 | 396 | return base | |
| 467 | 397 | } | |
| 468 | - | bg = t.Background | |
| 398 | + | background = t.Background | |
| 469 | 399 | } | |
| 470 | 400 | out := base | |
| 471 | - | out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend) | |
| 401 | + | out.ForegroundRGB = Blend(base.ForegroundRGB, background, mutedVariantBlend) | |
| 472 | 402 | return out | |
| 473 | 403 | } | |
| 474 | 404 | ||
| 475 | - | // PaletteMutedVariantStyle derives a title foreground from a terminal blue | |
| 476 | - | // palette entry. Its contrast is evaluated against the title's actual base | |
| 477 | - | // background, rather than the terminal background, because status and active | |
| 478 | - | // title backgrounds differ. | |
| 479 | - | func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |
| 480 | - | return paletteForegroundStyle(base, MutedVariantStyle(base, t), t) | |
| 481 | - | } | |
| 482 | - | ||
| 483 | - | // paletteForegroundStyle uses palette blues for a foreground only. It never | |
| 484 | - | // changes a background, so palette inheritance cannot introduce indexed | |
| 485 | - | // backgrounds. If terminal RGB data is unavailable, a known color scheme can | |
| 486 | - | // still select a best-effort ANSI foreground. A reported blue that fails | |
| 487 | - | // contrast deliberately suppresses that indexed fallback. | |
| 488 | - | func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style { | |
| 489 | - | if !t.UsePalette { | |
| 490 | - | return neutral | |
| 491 | - | } | |
| 492 | - | ||
| 493 | - | knownBlue := paletteBlueKnown(t) | |
| 494 | - | if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB { | |
| 495 | - | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |
| 496 | - | blue, ok := t.PaletteColor(slot) | |
| 497 | - | if !ok { | |
| 498 | - | continue | |
| 499 | - | } | |
| 500 | - | candidate := Blend(base.ForegroundRGB, blue, titleHueBlend) | |
| 501 | - | if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin { | |
| 502 | - | out := neutral | |
| 503 | - | out.HasForegroundRGB = true | |
| 504 | - | out.ForegroundRGB = candidate | |
| 505 | - | return out | |
| 506 | - | } | |
| 507 | - | } | |
| 508 | - | } | |
| 509 | - | ||
| 510 | - | if !knownBlue && t.SchemeKnown { | |
| 511 | - | out := neutral | |
| 512 | - | out.HasForegroundRGB = false | |
| 513 | - | if t.Light { | |
| 514 | - | out.Foreground = ansiBlue | |
| 515 | - | } else { | |
| 516 | - | out.Foreground = ansiBrightBlue | |
| 517 | - | } | |
| 518 | - | return out | |
| 519 | - | } | |
| 520 | - | return neutral | |
| 521 | - | } | |
| 522 | - | ||
| 523 | - | func paletteBlueKnown(t Theme) bool { | |
| 524 | - | if !t.UsePalette { | |
| 525 | - | return false | |
| 526 | - | } | |
| 527 | - | return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0 | |
| 528 | - | } | |
| 529 | - | ||
| 530 | 405 | const defaultDimmingPercent = 35 | |
| 531 | 406 | ||
| 532 | 407 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | |
| @@ -356,89 +356,22 @@ func StatusBarStyle(t Theme) renderer.Style { | |||||
| 356 | return style | 356 | return style | ||
| 357 | } | 357 | } | ||
| 358 | 358 | ||||
| 359 | - | const ( | 359 | + | // The compatibility helpers below retain the neutral styles for isolated |
| 360 | - | ansiBlue = 4 | 360 | + | // callers. Accent-aware construction goes exclusively through Resolve. |
| 361 | - | ansiBrightBlue = 12 | |||
| 362 | - | accentBlend = 0.25 | |||
| 363 | - | titleHueBlend = 0.2 | |||
| 364 | - | ) | |||
| 365 | - | ||||
| 366 | func AccentStyle(t Theme) renderer.Style { | 361 | func AccentStyle(t Theme) renderer.Style { | ||
| 367 | - | if !usable(t) { | 362 | + | return neutralStyles(t).SurfaceActive |
| 368 | - | return inverseStyle() | |||
| 369 | - | } | |||
| 370 | - | style := renderer.DefaultStyle() | |||
| 371 | - | style.HasForegroundRGB = true | |||
| 372 | - | style.ForegroundRGB = t.Foreground | |||
| 373 | - | style.HasBackgroundRGB = true | |||
| 374 | - | style.BackgroundRGB = accentBackground(t) | |||
| 375 | - | return style | |||
| 376 | - | } | |||
| 377 | - | ||||
| 378 | - | // accentBackground uses a terminal's blue palette only when the resulting | |||
| 379 | - | // foreground/background pair remains legible. A neutral blend is always the | |||
| 380 | - | // fallback, including when palette inheritance is disabled. | |||
| 381 | - | func accentBackground(t Theme) renderer.RGB { | |||
| 382 | - | neutral := Blend(t.Background, t.Foreground, accentBlend) | |||
| 383 | - | if !t.UsePalette { | |||
| 384 | - | return neutral | |||
| 385 | - | } | |||
| 386 | - | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |||
| 387 | - | blue, ok := t.PaletteColor(slot) | |||
| 388 | - | if !ok { | |||
| 389 | - | continue | |||
| 390 | - | } | |||
| 391 | - | candidate := Blend(t.Background, blue, accentBlend) | |||
| 392 | - | if ContrastRatio(t.Foreground, candidate) >= accentContrastMin { | |||
| 393 | - | return candidate | |||
| 394 | - | } | |||
| 395 | - | } | |||
| 396 | - | return neutral | |||
| 397 | } | 363 | } | ||
| 398 | 364 | ||||
| 399 | func BorderStyle(t Theme) renderer.Style { | 365 | func BorderStyle(t Theme) renderer.Style { | ||
| 400 | - | neutral := renderer.DefaultStyle() | 366 | + | return neutralStyles(t).BorderMuted |
| 401 | - | if usable(t) { | |||
| 402 | - | neutral.HasForegroundRGB = true | |||
| 403 | - | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |||
| 404 | - | } | |||
| 405 | - | if !t.UsePalette { | |||
| 406 | - | return neutral | |||
| 407 | - | } | |||
| 408 | - | ||||
| 409 | - | base := renderer.DefaultStyle() | |||
| 410 | - | if usable(t) { | |||
| 411 | - | base.HasForegroundRGB = true | |||
| 412 | - | base.ForegroundRGB = t.Foreground | |||
| 413 | - | base.HasBackgroundRGB = true | |||
| 414 | - | base.BackgroundRGB = t.Background | |||
| 415 | - | } | |||
| 416 | - | return paletteForegroundStyle(base, neutral, t) | |||
| 417 | } | 367 | } | ||
| 418 | 368 | ||||
| 419 | func MutedTextStyle(t Theme) renderer.Style { | 369 | func MutedTextStyle(t Theme) renderer.Style { | ||
| 420 | - | neutral := renderer.DefaultStyle() | 370 | + | return neutralStyles(t).PickerDescription |
| 421 | - | if usable(t) { | |||
| 422 | - | neutral.HasForegroundRGB = true | |||
| 423 | - | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |||
| 424 | - | } | |||
| 425 | - | if !t.UsePalette { | |||
| 426 | - | return neutral | |||
| 427 | - | } | |||
| 428 | - | ||||
| 429 | - | base := renderer.DefaultStyle() | |||
| 430 | - | if usable(t) { | |||
| 431 | - | base.HasForegroundRGB = true | |||
| 432 | - | base.ForegroundRGB = t.Foreground | |||
| 433 | - | base.HasBackgroundRGB = true | |||
| 434 | - | base.BackgroundRGB = t.Background | |||
| 435 | - | } | |||
| 436 | - | return paletteForegroundStyle(base, neutral, t) | |||
| 437 | } | 371 | } | ||
| 438 | 372 | ||||
| 439 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | 373 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | ||
| 440 | - | // foreground toward its own background, subtler than MutedTextStyle's 0.45 | 374 | + | // foreground toward its own background. |
| 441 | - | // (which blends the theme's global fg/bg instead of a style's own colors). | |||
| 442 | const mutedVariantBlend = 0.4 | 375 | const mutedVariantBlend = 0.4 | ||
| 443 | 376 | ||||
| 444 | // EmphasisStyle returns base with bold applied when the client theme supports | 377 | // EmphasisStyle returns base with bold applied when the client theme supports | ||
| @@ -452,81 +385,23 @@ func EmphasisStyle(base renderer.Style, t Theme) renderer.Style { | |||||
| 452 | return out | 385 | return out | ||
| 453 | } | 386 | } | ||
| 454 | 387 | ||||
| 455 | - | // MutedVariantStyle fades base's foreground toward its background when both | 388 | + | // MutedVariantStyle fades base's foreground toward its own background. |
| 456 | - | // are RGB and the client theme supports styled output; otherwise base is | |||
| 457 | - | // returned unchanged. When base has an RGB foreground but no RGB background, | |||
| 458 | - | // it fades toward t.Background instead. | |||
| 459 | func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | 389 | func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | ||
| 460 | if !usable(t) || !base.HasForegroundRGB { | 390 | if !usable(t) || !base.HasForegroundRGB { | ||
| 461 | return base | 391 | return base | ||
| 462 | } | 392 | } | ||
| 463 | - | bg := base.BackgroundRGB | 393 | + | background := base.BackgroundRGB |
| 464 | if !base.HasBackgroundRGB { | 394 | if !base.HasBackgroundRGB { | ||
| 465 | if !t.HasBG { | 395 | if !t.HasBG { | ||
| 466 | return base | 396 | return base | ||
| 467 | } | 397 | } | ||
| 468 | - | bg = t.Background | 398 | + | background = t.Background |
| 469 | } | 399 | } | ||
| 470 | out := base | 400 | out := base | ||
| 471 | - | out.ForegroundRGB = Blend(base.ForegroundRGB, bg, mutedVariantBlend) | 401 | + | out.ForegroundRGB = Blend(base.ForegroundRGB, background, mutedVariantBlend) |
| 472 | return out | 402 | return out | ||
| 473 | } | 403 | } | ||
| 474 | 404 | ||||
| 475 | - | // PaletteMutedVariantStyle derives a title foreground from a terminal blue | |||
| 476 | - | // palette entry. Its contrast is evaluated against the title's actual base | |||
| 477 | - | // background, rather than the terminal background, because status and active | |||
| 478 | - | // title backgrounds differ. | |||
| 479 | - | func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 480 | - | return paletteForegroundStyle(base, MutedVariantStyle(base, t), t) | |||
| 481 | - | } | |||
| 482 | - | ||||
| 483 | - | // paletteForegroundStyle uses palette blues for a foreground only. It never | |||
| 484 | - | // changes a background, so palette inheritance cannot introduce indexed | |||
| 485 | - | // backgrounds. If terminal RGB data is unavailable, a known color scheme can | |||
| 486 | - | // still select a best-effort ANSI foreground. A reported blue that fails | |||
| 487 | - | // contrast deliberately suppresses that indexed fallback. | |||
| 488 | - | func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style { | |||
| 489 | - | if !t.UsePalette { | |||
| 490 | - | return neutral | |||
| 491 | - | } | |||
| 492 | - | ||||
| 493 | - | knownBlue := paletteBlueKnown(t) | |||
| 494 | - | if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB { | |||
| 495 | - | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |||
| 496 | - | blue, ok := t.PaletteColor(slot) | |||
| 497 | - | if !ok { | |||
| 498 | - | continue | |||
| 499 | - | } | |||
| 500 | - | candidate := Blend(base.ForegroundRGB, blue, titleHueBlend) | |||
| 501 | - | if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin { | |||
| 502 | - | out := neutral | |||
| 503 | - | out.HasForegroundRGB = true | |||
| 504 | - | out.ForegroundRGB = candidate | |||
| 505 | - | return out | |||
| 506 | - | } | |||
| 507 | - | } | |||
| 508 | - | } | |||
| 509 | - | ||||
| 510 | - | if !knownBlue && t.SchemeKnown { | |||
| 511 | - | out := neutral | |||
| 512 | - | out.HasForegroundRGB = false | |||
| 513 | - | if t.Light { | |||
| 514 | - | out.Foreground = ansiBlue | |||
| 515 | - | } else { | |||
| 516 | - | out.Foreground = ansiBrightBlue | |||
| 517 | - | } | |||
| 518 | - | return out | |||
| 519 | - | } | |||
| 520 | - | return neutral | |||
| 521 | - | } | |||
| 522 | - | ||||
| 523 | - | func paletteBlueKnown(t Theme) bool { | |||
| 524 | - | if !t.UsePalette { | |||
| 525 | - | return false | |||
| 526 | - | } | |||
| 527 | - | return t.PaletteKnown&(uint16(1)<<ansiBlue|uint16(1)<<ansiBrightBlue) != 0 | |||
| 528 | - | } | |||
| 529 | - | ||||
| 530 | const defaultDimmingPercent = 35 | 405 | const defaultDimmingPercent = 35 | ||
| 531 | 406 | ||||
| 532 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | 407 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | ||