feat(theme): derive chrome accents from terminal palette
- commit
- ce901de93d0052421055060ccbea710896ea8f60
- author
- brice · · 2026-07-17 08:58 UTC
- parent
- efa8333
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +342 -16
M internal/usecase/theme/styles.go +3 -3
| @@ -92,12 +92,12 @@ func NewStyles(t Theme) Styles { | |||
| 92 | 92 | ||
| 93 | 93 | TabName: EmphasisStyle(statusBar, t), | |
| 94 | 94 | TabNameActive: EmphasisStyle(accent, t), | |
| 95 | - | TabTitle: MutedVariantStyle(statusBar, t), | |
| 96 | - | TabTitleActive: MutedVariantStyle(accent, t), | |
| 95 | + | TabTitle: PaletteMutedVariantStyle(statusBar, t), | |
| 96 | + | TabTitleActive: PaletteMutedVariantStyle(accent, t), | |
| 97 | 97 | ||
| 98 | 98 | PickerName: EmphasisStyle(renderer.DefaultStyle(), t), | |
| 99 | 99 | PickerSelectionName: EmphasisStyle(selection, t), | |
| 100 | - | PickerSelectionMuted: MutedVariantStyle(selection, t), | |
| 100 | + | PickerSelectionMuted: PaletteMutedVariantStyle(selection, t), | |
| 101 | 101 | PickerSeparator: MutedTextStyle(t), | |
| 102 | 102 | } | |
| 103 | 103 | } | |
| @@ -92,12 +92,12 @@ func NewStyles(t Theme) Styles { | |||||
| 92 | 92 | ||||
| 93 | TabName: EmphasisStyle(statusBar, t), | 93 | TabName: EmphasisStyle(statusBar, t), | ||
| 94 | TabNameActive: EmphasisStyle(accent, t), | 94 | TabNameActive: EmphasisStyle(accent, t), | ||
| 95 | - | TabTitle: MutedVariantStyle(statusBar, t), | 95 | + | TabTitle: PaletteMutedVariantStyle(statusBar, t), |
| 96 | - | TabTitleActive: MutedVariantStyle(accent, t), | 96 | + | TabTitleActive: PaletteMutedVariantStyle(accent, t), |
| 97 | 97 | ||||
| 98 | PickerName: EmphasisStyle(renderer.DefaultStyle(), t), | 98 | PickerName: EmphasisStyle(renderer.DefaultStyle(), t), | ||
| 99 | PickerSelectionName: EmphasisStyle(selection, t), | 99 | PickerSelectionName: EmphasisStyle(selection, t), | ||
| 100 | - | PickerSelectionMuted: MutedVariantStyle(selection, t), | 100 | + | PickerSelectionMuted: PaletteMutedVariantStyle(selection, t), |
| 101 | PickerSeparator: MutedTextStyle(t), | 101 | PickerSeparator: MutedTextStyle(t), | ||
| 102 | } | 102 | } | ||
| 103 | } | 103 | } | ||
M internal/usecase/theme/styles_test.go +224 -0
| @@ -241,6 +241,230 @@ func TestThemeIsComparable(t *testing.T) { | |||
| 241 | 241 | require.True(t, themes[BuiltinDark]) | |
| 242 | 242 | } | |
| 243 | 243 | ||
| 244 | + | func TestPaletteAccentBackground(t *testing.T) { | |
| 245 | + | base := Theme{ | |
| 246 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 247 | + | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 248 | + | HasFG: true, | |
| 249 | + | HasBG: true, | |
| 250 | + | Known: true, | |
| 251 | + | TrueColor: true, | |
| 252 | + | UsePalette: true, | |
| 253 | + | } | |
| 254 | + | neutral := Blend(base.Background, base.Foreground, accentBlend) | |
| 255 | + | ||
| 256 | + | tests := []struct { | |
| 257 | + | name string | |
| 258 | + | palette [16]renderer.RGB | |
| 259 | + | known uint16 | |
| 260 | + | usePalette bool | |
| 261 | + | want renderer.RGB | |
| 262 | + | wantContrastMin bool | |
| 263 | + | }{ | |
| 264 | + | { | |
| 265 | + | name: "slot 4 supplies a contrasting accent background", | |
| 266 | + | palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}}, | |
| 267 | + | known: 1<<4 | 1<<12, | |
| 268 | + | usePalette: true, | |
| 269 | + | want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend), | |
| 270 | + | wantContrastMin: true, | |
| 271 | + | }, | |
| 272 | + | { | |
| 273 | + | name: "slot 4 failure escalates to slot 12", | |
| 274 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}}, | |
| 275 | + | known: 1<<4 | 1<<12, | |
| 276 | + | usePalette: true, | |
| 277 | + | want: Blend(base.Background, renderer.RGB{}, accentBlend), | |
| 278 | + | wantContrastMin: true, | |
| 279 | + | }, | |
| 280 | + | { | |
| 281 | + | name: "both palette blues failing uses exact neutral blend", | |
| 282 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |
| 283 | + | known: 1<<4 | 1<<12, | |
| 284 | + | usePalette: true, | |
| 285 | + | want: neutral, | |
| 286 | + | }, | |
| 287 | + | { | |
| 288 | + | name: "missing palette blues uses exact neutral blend", | |
| 289 | + | usePalette: true, | |
| 290 | + | want: neutral, | |
| 291 | + | }, | |
| 292 | + | { | |
| 293 | + | name: "palette disabled preserves neutral accent", | |
| 294 | + | palette: [16]renderer.RGB{4: {B: 255}}, | |
| 295 | + | known: 1 << 4, | |
| 296 | + | usePalette: false, | |
| 297 | + | want: neutral, | |
| 298 | + | }, | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | for _, tt := range tests { | |
| 302 | + | t.Run(tt.name, func(t *testing.T) { | |
| 303 | + | theme := base | |
| 304 | + | theme.Palette = tt.palette | |
| 305 | + | theme.PaletteKnown = tt.known | |
| 306 | + | theme.UsePalette = tt.usePalette | |
| 307 | + | ||
| 308 | + | got := AccentStyle(theme) | |
| 309 | + | require.True(t, got.HasBackgroundRGB) | |
| 310 | + | require.Equal(t, tt.want, got.BackgroundRGB) | |
| 311 | + | if tt.wantContrastMin { | |
| 312 | + | require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin) | |
| 313 | + | } | |
| 314 | + | }) | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func TestPaletteTitleHueDerivation(t *testing.T) { | |
| 319 | + | base := Theme{ | |
| 320 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |
| 321 | + | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |
| 322 | + | HasFG: true, | |
| 323 | + | HasBG: true, | |
| 324 | + | Known: true, | |
| 325 | + | TrueColor: true, | |
| 326 | + | UsePalette: true, | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | tests := []struct { | |
| 330 | + | name string | |
| 331 | + | theme Theme | |
| 332 | + | palette [16]renderer.RGB | |
| 333 | + | known uint16 | |
| 334 | + | }{ | |
| 335 | + | { | |
| 336 | + | name: "slot 4 is used when it contrasts for each title", | |
| 337 | + | theme: Theme{ | |
| 338 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 339 | + | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |
| 340 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |
| 341 | + | }, | |
| 342 | + | palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}}, | |
| 343 | + | ||
| 344 | + | known: 1<<4 | 1<<12, | |
| 345 | + | }, | |
| 346 | + | { | |
| 347 | + | name: "each title escalates independently from slot 4 to slot 12", | |
| 348 | + | palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}}, | |
| 349 | + | known: 1<<4 | 1<<12, | |
| 350 | + | }, | |
| 351 | + | { | |
| 352 | + | name: "both title hues failing retains neutral muted styles", | |
| 353 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |
| 354 | + | known: 1<<4 | 1<<12, | |
| 355 | + | }, | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | for _, tt := range tests { | |
| 359 | + | t.Run(tt.name, func(t *testing.T) { | |
| 360 | + | theme := base | |
| 361 | + | if tt.theme.Known { | |
| 362 | + | theme = tt.theme | |
| 363 | + | } | |
| 364 | + | theme.Palette = tt.palette | |
| 365 | + | theme.PaletteKnown = tt.known | |
| 366 | + | styles := NewStyles(theme) | |
| 367 | + | status := StatusBarStyle(theme) | |
| 368 | + | accent := AccentStyle(theme) | |
| 369 | + | ||
| 370 | + | if tt.name == "both title hues failing retains neutral muted styles" { | |
| 371 | + | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme))) | |
| 372 | + | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |
| 373 | + | return | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | require.True(t, styles.TabTitle.HasForegroundRGB) | |
| 377 | + | require.True(t, styles.TabTitleActive.HasForegroundRGB) | |
| 378 | + | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin) | |
| 379 | + | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin) | |
| 380 | + | ||
| 381 | + | if tt.name == "slot 4 is used when it contrasts for each title" { | |
| 382 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB) | |
| 383 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |
| 384 | + | } | |
| 385 | + | if tt.name == "each title escalates independently from slot 4 to slot 12" { | |
| 386 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB) | |
| 387 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |
| 388 | + | } | |
| 389 | + | }) | |
| 390 | + | } | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | func TestPaletteIndexedForegroundFallback(t *testing.T) { | |
| 394 | + | base := Theme{UsePalette: true, SchemeKnown: true, Known: true} | |
| 395 | + | ||
| 396 | + | tests := []struct { | |
| 397 | + | name string | |
| 398 | + | theme Theme | |
| 399 | + | want int | |
| 400 | + | ok bool | |
| 401 | + | }{ | |
| 402 | + | {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true}, | |
| 403 | + | {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true}, | |
| 404 | + | {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}}, | |
| 405 | + | {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}}, | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | for _, tt := range tests { | |
| 409 | + | t.Run(tt.name, func(t *testing.T) { | |
| 410 | + | styles := NewStyles(tt.theme) | |
| 411 | + | baselineTheme := tt.theme | |
| 412 | + | baselineTheme.UsePalette = false | |
| 413 | + | baseline := NewStyles(baselineTheme) | |
| 414 | + | for name, style := range map[string]renderer.Style{ | |
| 415 | + | "title": styles.TabTitle, | |
| 416 | + | "active title": styles.TabTitleActive, | |
| 417 | + | "border": styles.Border, | |
| 418 | + | "muted text": styles.PaletteDesc, | |
| 419 | + | } { | |
| 420 | + | require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name) | |
| 421 | + | if tt.ok { | |
| 422 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 423 | + | require.Equal(t, tt.want, style.Foreground, name) | |
| 424 | + | } else { | |
| 425 | + | want := map[string]renderer.Style{ | |
| 426 | + | "title": baseline.TabTitle, "active title": baseline.TabTitleActive, | |
| 427 | + | "border": baseline.Border, "muted text": baseline.PaletteDesc, | |
| 428 | + | }[name] | |
| 429 | + | require.Equal(t, want, style, name) | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | }) | |
| 433 | + | } | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) { | |
| 437 | + | theme := Theme{ | |
| 438 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |
| 439 | + | Background: renderer.RGB{R: 30, G: 30, B: 30}, | |
| 440 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |
| 441 | + | UsePalette: true, SchemeKnown: true, | |
| 442 | + | } | |
| 443 | + | styles := NewStyles(theme) | |
| 444 | + | for name, style := range map[string]renderer.Style{ | |
| 445 | + | "title": styles.TabTitle, "active title": styles.TabTitleActive, | |
| 446 | + | "border": styles.Border, "muted text": styles.PaletteDesc, | |
| 447 | + | } { | |
| 448 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |
| 449 | + | require.Equal(t, ansiBrightBlue, style.Foreground, name) | |
| 450 | + | require.Equal(t, -1, style.Background, "%s must not use an indexed background", name) | |
| 451 | + | } | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) { | |
| 455 | + | palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}} | |
| 456 | + | 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} | |
| 457 | + | withDisabledPalette := usableTheme | |
| 458 | + | withDisabledPalette.Palette = palette | |
| 459 | + | withDisabledPalette.PaletteKnown = 1<<4 | 1<<12 | |
| 460 | + | withDisabledPalette.UsePalette = false | |
| 461 | + | ||
| 462 | + | require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette)) | |
| 463 | + | ||
| 464 | + | forced := Theme{Known: true, SchemeKnown: true, UsePalette: false} | |
| 465 | + | require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced)) | |
| 466 | + | } | |
| 467 | + | ||
| 244 | 468 | func TestNewStylesComposesSemanticStyles(t *testing.T) { | |
| 245 | 469 | theme := Theme{ | |
| 246 | 470 | Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8}, | |
| @@ -241,6 +241,230 @@ func TestThemeIsComparable(t *testing.T) { | |||||
| 241 | require.True(t, themes[BuiltinDark]) | 241 | require.True(t, themes[BuiltinDark]) | ||
| 242 | } | 242 | } | ||
| 243 | 243 | ||||
| 244 | + | func TestPaletteAccentBackground(t *testing.T) { | |||
| 245 | + | base := Theme{ | |||
| 246 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |||
| 247 | + | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |||
| 248 | + | HasFG: true, | |||
| 249 | + | HasBG: true, | |||
| 250 | + | Known: true, | |||
| 251 | + | TrueColor: true, | |||
| 252 | + | UsePalette: true, | |||
| 253 | + | } | |||
| 254 | + | neutral := Blend(base.Background, base.Foreground, accentBlend) | |||
| 255 | + | ||||
| 256 | + | tests := []struct { | |||
| 257 | + | name string | |||
| 258 | + | palette [16]renderer.RGB | |||
| 259 | + | known uint16 | |||
| 260 | + | usePalette bool | |||
| 261 | + | want renderer.RGB | |||
| 262 | + | wantContrastMin bool | |||
| 263 | + | }{ | |||
| 264 | + | { | |||
| 265 | + | name: "slot 4 supplies a contrasting accent background", | |||
| 266 | + | palette: [16]renderer.RGB{4: {B: 255}, 12: {R: 200, G: 200, B: 200}}, | |||
| 267 | + | known: 1<<4 | 1<<12, | |||
| 268 | + | usePalette: true, | |||
| 269 | + | want: Blend(base.Background, renderer.RGB{B: 255}, accentBlend), | |||
| 270 | + | wantContrastMin: true, | |||
| 271 | + | }, | |||
| 272 | + | { | |||
| 273 | + | name: "slot 4 failure escalates to slot 12", | |||
| 274 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {}}, | |||
| 275 | + | known: 1<<4 | 1<<12, | |||
| 276 | + | usePalette: true, | |||
| 277 | + | want: Blend(base.Background, renderer.RGB{}, accentBlend), | |||
| 278 | + | wantContrastMin: true, | |||
| 279 | + | }, | |||
| 280 | + | { | |||
| 281 | + | name: "both palette blues failing uses exact neutral blend", | |||
| 282 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |||
| 283 | + | known: 1<<4 | 1<<12, | |||
| 284 | + | usePalette: true, | |||
| 285 | + | want: neutral, | |||
| 286 | + | }, | |||
| 287 | + | { | |||
| 288 | + | name: "missing palette blues uses exact neutral blend", | |||
| 289 | + | usePalette: true, | |||
| 290 | + | want: neutral, | |||
| 291 | + | }, | |||
| 292 | + | { | |||
| 293 | + | name: "palette disabled preserves neutral accent", | |||
| 294 | + | palette: [16]renderer.RGB{4: {B: 255}}, | |||
| 295 | + | known: 1 << 4, | |||
| 296 | + | usePalette: false, | |||
| 297 | + | want: neutral, | |||
| 298 | + | }, | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | for _, tt := range tests { | |||
| 302 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 303 | + | theme := base | |||
| 304 | + | theme.Palette = tt.palette | |||
| 305 | + | theme.PaletteKnown = tt.known | |||
| 306 | + | theme.UsePalette = tt.usePalette | |||
| 307 | + | ||||
| 308 | + | got := AccentStyle(theme) | |||
| 309 | + | require.True(t, got.HasBackgroundRGB) | |||
| 310 | + | require.Equal(t, tt.want, got.BackgroundRGB) | |||
| 311 | + | if tt.wantContrastMin { | |||
| 312 | + | require.GreaterOrEqual(t, ContrastRatio(theme.Foreground, got.BackgroundRGB), accentContrastMin) | |||
| 313 | + | } | |||
| 314 | + | }) | |||
| 315 | + | } | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | func TestPaletteTitleHueDerivation(t *testing.T) { | |||
| 319 | + | base := Theme{ | |||
| 320 | + | Foreground: renderer.RGB{R: 200, G: 200, B: 200}, | |||
| 321 | + | Background: renderer.RGB{R: 100, G: 100, B: 100}, | |||
| 322 | + | HasFG: true, | |||
| 323 | + | HasBG: true, | |||
| 324 | + | Known: true, | |||
| 325 | + | TrueColor: true, | |||
| 326 | + | UsePalette: true, | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | tests := []struct { | |||
| 330 | + | name string | |||
| 331 | + | theme Theme | |||
| 332 | + | palette [16]renderer.RGB | |||
| 333 | + | known uint16 | |||
| 334 | + | }{ | |||
| 335 | + | { | |||
| 336 | + | name: "slot 4 is used when it contrasts for each title", | |||
| 337 | + | theme: Theme{ | |||
| 338 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |||
| 339 | + | Background: renderer.RGB{R: 50, G: 50, B: 50}, | |||
| 340 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, UsePalette: true, | |||
| 341 | + | }, | |||
| 342 | + | palette: [16]renderer.RGB{4: {}, 12: {R: 200, G: 200, B: 200}}, | |||
| 343 | + | ||||
| 344 | + | known: 1<<4 | 1<<12, | |||
| 345 | + | }, | |||
| 346 | + | { | |||
| 347 | + | name: "each title escalates independently from slot 4 to slot 12", | |||
| 348 | + | palette: [16]renderer.RGB{4: {}, 12: {R: 255, G: 255, B: 255}}, | |||
| 349 | + | known: 1<<4 | 1<<12, | |||
| 350 | + | }, | |||
| 351 | + | { | |||
| 352 | + | name: "both title hues failing retains neutral muted styles", | |||
| 353 | + | palette: [16]renderer.RGB{4: {R: 200, G: 200, B: 200}, 12: {R: 200, G: 200, B: 200}}, | |||
| 354 | + | known: 1<<4 | 1<<12, | |||
| 355 | + | }, | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | for _, tt := range tests { | |||
| 359 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 360 | + | theme := base | |||
| 361 | + | if tt.theme.Known { | |||
| 362 | + | theme = tt.theme | |||
| 363 | + | } | |||
| 364 | + | theme.Palette = tt.palette | |||
| 365 | + | theme.PaletteKnown = tt.known | |||
| 366 | + | styles := NewStyles(theme) | |||
| 367 | + | status := StatusBarStyle(theme) | |||
| 368 | + | accent := AccentStyle(theme) | |||
| 369 | + | ||||
| 370 | + | if tt.name == "both title hues failing retains neutral muted styles" { | |||
| 371 | + | require.True(t, styles.TabTitle.Equal(MutedVariantStyle(status, theme))) | |||
| 372 | + | require.True(t, styles.TabTitleActive.Equal(MutedVariantStyle(accent, theme))) | |||
| 373 | + | return | |||
| 374 | + | } | |||
| 375 | + | ||||
| 376 | + | require.True(t, styles.TabTitle.HasForegroundRGB) | |||
| 377 | + | require.True(t, styles.TabTitleActive.HasForegroundRGB) | |||
| 378 | + | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitle.ForegroundRGB, status.BackgroundRGB), accentContrastMin) | |||
| 379 | + | require.GreaterOrEqual(t, ContrastRatio(styles.TabTitleActive.ForegroundRGB, accent.BackgroundRGB), accentContrastMin) | |||
| 380 | + | ||||
| 381 | + | if tt.name == "slot 4 is used when it contrasts for each title" { | |||
| 382 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitle.ForegroundRGB) | |||
| 383 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |||
| 384 | + | } | |||
| 385 | + | if tt.name == "each title escalates independently from slot 4 to slot 12" { | |||
| 386 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[12], titleHueBlend), styles.TabTitle.ForegroundRGB) | |||
| 387 | + | require.Equal(t, Blend(theme.Foreground, theme.Palette[4], titleHueBlend), styles.TabTitleActive.ForegroundRGB) | |||
| 388 | + | } | |||
| 389 | + | }) | |||
| 390 | + | } | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | func TestPaletteIndexedForegroundFallback(t *testing.T) { | |||
| 394 | + | base := Theme{UsePalette: true, SchemeKnown: true, Known: true} | |||
| 395 | + | ||||
| 396 | + | tests := []struct { | |||
| 397 | + | name string | |||
| 398 | + | theme Theme | |||
| 399 | + | want int | |||
| 400 | + | ok bool | |||
| 401 | + | }{ | |||
| 402 | + | {name: "dark scheme uses bright blue", theme: base, want: 12, ok: true}, | |||
| 403 | + | {name: "light scheme uses blue", theme: func() Theme { t := base; t.Light = true; return t }(), want: 4, ok: true}, | |||
| 404 | + | {name: "unknown scheme remains neutral", theme: Theme{UsePalette: true, Known: true}}, | |||
| 405 | + | {name: "known RGB blue that cannot be evaluated remains neutral", theme: Theme{UsePalette: true, SchemeKnown: true, PaletteKnown: 1 << 4}}, | |||
| 406 | + | } | |||
| 407 | + | ||||
| 408 | + | for _, tt := range tests { | |||
| 409 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 410 | + | styles := NewStyles(tt.theme) | |||
| 411 | + | baselineTheme := tt.theme | |||
| 412 | + | baselineTheme.UsePalette = false | |||
| 413 | + | baseline := NewStyles(baselineTheme) | |||
| 414 | + | for name, style := range map[string]renderer.Style{ | |||
| 415 | + | "title": styles.TabTitle, | |||
| 416 | + | "active title": styles.TabTitleActive, | |||
| 417 | + | "border": styles.Border, | |||
| 418 | + | "muted text": styles.PaletteDesc, | |||
| 419 | + | } { | |||
| 420 | + | require.False(t, style.HasBackgroundRGB, "%s must never receive an indexed background", name) | |||
| 421 | + | if tt.ok { | |||
| 422 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 423 | + | require.Equal(t, tt.want, style.Foreground, name) | |||
| 424 | + | } else { | |||
| 425 | + | want := map[string]renderer.Style{ | |||
| 426 | + | "title": baseline.TabTitle, "active title": baseline.TabTitleActive, | |||
| 427 | + | "border": baseline.Border, "muted text": baseline.PaletteDesc, | |||
| 428 | + | }[name] | |||
| 429 | + | require.Equal(t, want, style, name) | |||
| 430 | + | } | |||
| 431 | + | } | |||
| 432 | + | }) | |||
| 433 | + | } | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | func TestPaletteMissingRGBUsesOnlyIndexedTitleForeground(t *testing.T) { | |||
| 437 | + | theme := Theme{ | |||
| 438 | + | Foreground: renderer.RGB{R: 240, G: 240, B: 240}, | |||
| 439 | + | Background: renderer.RGB{R: 30, G: 30, B: 30}, | |||
| 440 | + | HasFG: true, HasBG: true, Known: true, TrueColor: true, | |||
| 441 | + | UsePalette: true, SchemeKnown: true, | |||
| 442 | + | } | |||
| 443 | + | styles := NewStyles(theme) | |||
| 444 | + | for name, style := range map[string]renderer.Style{ | |||
| 445 | + | "title": styles.TabTitle, "active title": styles.TabTitleActive, | |||
| 446 | + | "border": styles.Border, "muted text": styles.PaletteDesc, | |||
| 447 | + | } { | |||
| 448 | + | require.False(t, style.HasForegroundRGB, "%s must use an indexed foreground", name) | |||
| 449 | + | require.Equal(t, ansiBrightBlue, style.Foreground, name) | |||
| 450 | + | require.Equal(t, -1, style.Background, "%s must not use an indexed background", name) | |||
| 451 | + | } | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | func TestPaletteDisabledAndForcedModesPreserveExistingStyles(t *testing.T) { | |||
| 455 | + | palette := [16]renderer.RGB{4: {B: 255}, 12: {R: 255, G: 255, B: 255}} | |||
| 456 | + | 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} | |||
| 457 | + | withDisabledPalette := usableTheme | |||
| 458 | + | withDisabledPalette.Palette = palette | |||
| 459 | + | withDisabledPalette.PaletteKnown = 1<<4 | 1<<12 | |||
| 460 | + | withDisabledPalette.UsePalette = false | |||
| 461 | + | ||||
| 462 | + | require.Equal(t, NewStyles(usableTheme), NewStyles(withDisabledPalette)) | |||
| 463 | + | ||||
| 464 | + | forced := Theme{Known: true, SchemeKnown: true, UsePalette: false} | |||
| 465 | + | require.Equal(t, NewStyles(Theme{Known: true}), NewStyles(forced)) | |||
| 466 | + | } | |||
| 467 | + | ||||
| 244 | func TestNewStylesComposesSemanticStyles(t *testing.T) { | 468 | func TestNewStylesComposesSemanticStyles(t *testing.T) { | ||
| 245 | theme := Theme{ | 469 | theme := Theme{ | ||
| 246 | Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8}, | 470 | Foreground: renderer.RGB{R: 0xc8, G: 0xc8, B: 0xc8}, | ||
M internal/usecase/theme/theme.go +115 -13
| @@ -357,6 +357,13 @@ func StatusBarStyle(t Theme) renderer.Style { | |||
| 357 | 357 | return style | |
| 358 | 358 | } | |
| 359 | 359 | ||
| 360 | + | const ( | |
| 361 | + | ansiBlue = 4 | |
| 362 | + | ansiBrightBlue = 12 | |
| 363 | + | accentBlend = 0.25 | |
| 364 | + | titleHueBlend = 0.2 | |
| 365 | + | ) | |
| 366 | + | ||
| 360 | 367 | func AccentStyle(t Theme) renderer.Style { | |
| 361 | 368 | if !usable(t) { | |
| 362 | 369 | return inverseStyle() | |
| @@ -365,28 +372,69 @@ func AccentStyle(t Theme) renderer.Style { | |||
| 365 | 372 | style.HasForegroundRGB = true | |
| 366 | 373 | style.ForegroundRGB = t.Foreground | |
| 367 | 374 | style.HasBackgroundRGB = true | |
| 368 | - | style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25) | |
| 375 | + | style.BackgroundRGB = accentBackground(t) | |
| 369 | 376 | return style | |
| 370 | 377 | } | |
| 371 | 378 | ||
| 379 | + | // accentBackground uses a terminal's blue palette only when the resulting | |
| 380 | + | // foreground/background pair remains legible. A neutral blend is always the | |
| 381 | + | // fallback, including when palette inheritance is disabled. | |
| 382 | + | func accentBackground(t Theme) renderer.RGB { | |
| 383 | + | neutral := Blend(t.Background, t.Foreground, accentBlend) | |
| 384 | + | if !t.UsePalette { | |
| 385 | + | return neutral | |
| 386 | + | } | |
| 387 | + | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |
| 388 | + | blue, ok := t.PaletteColor(slot) | |
| 389 | + | if !ok { | |
| 390 | + | continue | |
| 391 | + | } | |
| 392 | + | candidate := Blend(t.Background, blue, accentBlend) | |
| 393 | + | if ContrastRatio(t.Foreground, candidate) >= accentContrastMin { | |
| 394 | + | return candidate | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | return neutral | |
| 398 | + | } | |
| 399 | + | ||
| 372 | 400 | func BorderStyle(t Theme) renderer.Style { | |
| 373 | - | if !usable(t) { | |
| 374 | - | return renderer.DefaultStyle() | |
| 401 | + | neutral := renderer.DefaultStyle() | |
| 402 | + | if usable(t) { | |
| 403 | + | neutral.HasForegroundRGB = true | |
| 404 | + | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |
| 375 | 405 | } | |
| 376 | - | style := renderer.DefaultStyle() | |
| 377 | - | style.HasForegroundRGB = true | |
| 378 | - | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |
| 379 | - | return style | |
| 406 | + | if !t.UsePalette { | |
| 407 | + | return neutral | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | base := renderer.DefaultStyle() | |
| 411 | + | if usable(t) { | |
| 412 | + | base.HasForegroundRGB = true | |
| 413 | + | base.ForegroundRGB = t.Foreground | |
| 414 | + | base.HasBackgroundRGB = true | |
| 415 | + | base.BackgroundRGB = t.Background | |
| 416 | + | } | |
| 417 | + | return paletteForegroundStyle(base, neutral, t) | |
| 380 | 418 | } | |
| 381 | 419 | ||
| 382 | 420 | func MutedTextStyle(t Theme) renderer.Style { | |
| 383 | - | if !usable(t) { | |
| 384 | - | return renderer.DefaultStyle() | |
| 421 | + | neutral := renderer.DefaultStyle() | |
| 422 | + | if usable(t) { | |
| 423 | + | neutral.HasForegroundRGB = true | |
| 424 | + | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 385 | 425 | } | |
| 386 | - | style := renderer.DefaultStyle() | |
| 387 | - | style.HasForegroundRGB = true | |
| 388 | - | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |
| 389 | - | return style | |
| 426 | + | if !t.UsePalette { | |
| 427 | + | return neutral | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | base := renderer.DefaultStyle() | |
| 431 | + | if usable(t) { | |
| 432 | + | base.HasForegroundRGB = true | |
| 433 | + | base.ForegroundRGB = t.Foreground | |
| 434 | + | base.HasBackgroundRGB = true | |
| 435 | + | base.BackgroundRGB = t.Background | |
| 436 | + | } | |
| 437 | + | return paletteForegroundStyle(base, neutral, t) | |
| 390 | 438 | } | |
| 391 | 439 | ||
| 392 | 440 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | |
| @@ -425,6 +473,60 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 425 | 473 | return out | |
| 426 | 474 | } | |
| 427 | 475 | ||
| 476 | + | // PaletteMutedVariantStyle derives a title foreground from a terminal blue | |
| 477 | + | // palette entry. Its contrast is evaluated against the title's actual base | |
| 478 | + | // background, rather than the terminal background, because status and active | |
| 479 | + | // title backgrounds differ. | |
| 480 | + | func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |
| 481 | + | return paletteForegroundStyle(base, MutedVariantStyle(base, t), t) | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | // paletteForegroundStyle uses palette blues for a foreground only. It never | |
| 485 | + | // changes a background, so palette inheritance cannot introduce indexed | |
| 486 | + | // backgrounds. If terminal RGB data is unavailable, a known color scheme can | |
| 487 | + | // still select a best-effort ANSI foreground. A reported blue that fails | |
| 488 | + | // contrast deliberately suppresses that indexed fallback. | |
| 489 | + | func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style { | |
| 490 | + | if !t.UsePalette { | |
| 491 | + | return neutral | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | knownBlue := paletteBlueKnown(t) | |
| 495 | + | if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB { | |
| 496 | + | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |
| 497 | + | blue, ok := t.PaletteColor(slot) | |
| 498 | + | if !ok { | |
| 499 | + | continue | |
| 500 | + | } | |
| 501 | + | candidate := Blend(base.ForegroundRGB, blue, titleHueBlend) | |
| 502 | + | if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin { | |
| 503 | + | out := base | |
| 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 | + | ||
| 428 | 530 | const defaultDimmingPercent = 35 | |
| 429 | 531 | ||
| 430 | 532 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | |
| @@ -357,6 +357,13 @@ func StatusBarStyle(t Theme) renderer.Style { | |||||
| 357 | return style | 357 | return style | ||
| 358 | } | 358 | } | ||
| 359 | 359 | ||||
| 360 | + | const ( | |||
| 361 | + | ansiBlue = 4 | |||
| 362 | + | ansiBrightBlue = 12 | |||
| 363 | + | accentBlend = 0.25 | |||
| 364 | + | titleHueBlend = 0.2 | |||
| 365 | + | ) | |||
| 366 | + | ||||
| 360 | func AccentStyle(t Theme) renderer.Style { | 367 | func AccentStyle(t Theme) renderer.Style { | ||
| 361 | if !usable(t) { | 368 | if !usable(t) { | ||
| 362 | return inverseStyle() | 369 | return inverseStyle() | ||
| @@ -365,28 +372,69 @@ func AccentStyle(t Theme) renderer.Style { | |||||
| 365 | style.HasForegroundRGB = true | 372 | style.HasForegroundRGB = true | ||
| 366 | style.ForegroundRGB = t.Foreground | 373 | style.ForegroundRGB = t.Foreground | ||
| 367 | style.HasBackgroundRGB = true | 374 | style.HasBackgroundRGB = true | ||
| 368 | - | style.BackgroundRGB = Blend(t.Background, t.Foreground, 0.25) | 375 | + | style.BackgroundRGB = accentBackground(t) |
| 369 | return style | 376 | return style | ||
| 370 | } | 377 | } | ||
| 371 | 378 | ||||
| 379 | + | // accentBackground uses a terminal's blue palette only when the resulting | |||
| 380 | + | // foreground/background pair remains legible. A neutral blend is always the | |||
| 381 | + | // fallback, including when palette inheritance is disabled. | |||
| 382 | + | func accentBackground(t Theme) renderer.RGB { | |||
| 383 | + | neutral := Blend(t.Background, t.Foreground, accentBlend) | |||
| 384 | + | if !t.UsePalette { | |||
| 385 | + | return neutral | |||
| 386 | + | } | |||
| 387 | + | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |||
| 388 | + | blue, ok := t.PaletteColor(slot) | |||
| 389 | + | if !ok { | |||
| 390 | + | continue | |||
| 391 | + | } | |||
| 392 | + | candidate := Blend(t.Background, blue, accentBlend) | |||
| 393 | + | if ContrastRatio(t.Foreground, candidate) >= accentContrastMin { | |||
| 394 | + | return candidate | |||
| 395 | + | } | |||
| 396 | + | } | |||
| 397 | + | return neutral | |||
| 398 | + | } | |||
| 399 | + | ||||
| 372 | func BorderStyle(t Theme) renderer.Style { | 400 | func BorderStyle(t Theme) renderer.Style { | ||
| 373 | - | if !usable(t) { | 401 | + | neutral := renderer.DefaultStyle() |
| 374 | - | return renderer.DefaultStyle() | 402 | + | if usable(t) { |
| 403 | + | neutral.HasForegroundRGB = true | |||
| 404 | + | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | |||
| 375 | } | 405 | } | ||
| 376 | - | style := renderer.DefaultStyle() | 406 | + | if !t.UsePalette { |
| 377 | - | style.HasForegroundRGB = true | 407 | + | return neutral |
| 378 | - | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.40) | 408 | + | } |
| 379 | - | return style | 409 | + | |
| 410 | + | base := renderer.DefaultStyle() | |||
| 411 | + | if usable(t) { | |||
| 412 | + | base.HasForegroundRGB = true | |||
| 413 | + | base.ForegroundRGB = t.Foreground | |||
| 414 | + | base.HasBackgroundRGB = true | |||
| 415 | + | base.BackgroundRGB = t.Background | |||
| 416 | + | } | |||
| 417 | + | return paletteForegroundStyle(base, neutral, t) | |||
| 380 | } | 418 | } | ||
| 381 | 419 | ||||
| 382 | func MutedTextStyle(t Theme) renderer.Style { | 420 | func MutedTextStyle(t Theme) renderer.Style { | ||
| 383 | - | if !usable(t) { | 421 | + | neutral := renderer.DefaultStyle() |
| 384 | - | return renderer.DefaultStyle() | 422 | + | if usable(t) { |
| 423 | + | neutral.HasForegroundRGB = true | |||
| 424 | + | neutral.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | |||
| 385 | } | 425 | } | ||
| 386 | - | style := renderer.DefaultStyle() | 426 | + | if !t.UsePalette { |
| 387 | - | style.HasForegroundRGB = true | 427 | + | return neutral |
| 388 | - | style.ForegroundRGB = Blend(t.Foreground, t.Background, 0.45) | 428 | + | } |
| 389 | - | return style | 429 | + | |
| 430 | + | base := renderer.DefaultStyle() | |||
| 431 | + | if usable(t) { | |||
| 432 | + | base.HasForegroundRGB = true | |||
| 433 | + | base.ForegroundRGB = t.Foreground | |||
| 434 | + | base.HasBackgroundRGB = true | |||
| 435 | + | base.BackgroundRGB = t.Background | |||
| 436 | + | } | |||
| 437 | + | return paletteForegroundStyle(base, neutral, t) | |||
| 390 | } | 438 | } | ||
| 391 | 439 | ||||
| 392 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | 440 | // mutedVariantBlend is the fraction MutedVariantStyle blends a style's own | ||
| @@ -425,6 +473,60 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |||||
| 425 | return out | 473 | return out | ||
| 426 | } | 474 | } | ||
| 427 | 475 | ||||
| 476 | + | // PaletteMutedVariantStyle derives a title foreground from a terminal blue | |||
| 477 | + | // palette entry. Its contrast is evaluated against the title's actual base | |||
| 478 | + | // background, rather than the terminal background, because status and active | |||
| 479 | + | // title backgrounds differ. | |||
| 480 | + | func PaletteMutedVariantStyle(base renderer.Style, t Theme) renderer.Style { | |||
| 481 | + | return paletteForegroundStyle(base, MutedVariantStyle(base, t), t) | |||
| 482 | + | } | |||
| 483 | + | ||||
| 484 | + | // paletteForegroundStyle uses palette blues for a foreground only. It never | |||
| 485 | + | // changes a background, so palette inheritance cannot introduce indexed | |||
| 486 | + | // backgrounds. If terminal RGB data is unavailable, a known color scheme can | |||
| 487 | + | // still select a best-effort ANSI foreground. A reported blue that fails | |||
| 488 | + | // contrast deliberately suppresses that indexed fallback. | |||
| 489 | + | func paletteForegroundStyle(base, neutral renderer.Style, t Theme) renderer.Style { | |||
| 490 | + | if !t.UsePalette { | |||
| 491 | + | return neutral | |||
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | knownBlue := paletteBlueKnown(t) | |||
| 495 | + | if usable(t) && base.HasForegroundRGB && base.HasBackgroundRGB { | |||
| 496 | + | for _, slot := range []int{ansiBlue, ansiBrightBlue} { | |||
| 497 | + | blue, ok := t.PaletteColor(slot) | |||
| 498 | + | if !ok { | |||
| 499 | + | continue | |||
| 500 | + | } | |||
| 501 | + | candidate := Blend(base.ForegroundRGB, blue, titleHueBlend) | |||
| 502 | + | if ContrastRatio(candidate, base.BackgroundRGB) >= accentContrastMin { | |||
| 503 | + | out := base | |||
| 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 | + | ||||
| 428 | const defaultDimmingPercent = 35 | 530 | const defaultDimmingPercent = 35 | ||
| 429 | 531 | ||||
| 430 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | 532 | // Dimmer transforms resolved terminal colors for subdued UI states. Construct | ||