| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
|
| 14 |
|
| 15 |
|
| 16 |
use crate::{Rgb, ThemeColors, ThemeMeta, list_themes_from_dirs, load_theme, wcag_contrast}; |
| 17 |
use serde::Serialize; |
| 18 |
use std::path::PathBuf; |
| 19 |
|
| 20 |
|
| 21 |
#[allow(unused_imports)] |
| 22 |
use crate::parse_meta; |
| 23 |
|
| 24 |
|
| 25 |
|
| 26 |
|
| 27 |
|
| 28 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] |
| 29 |
#[serde(rename_all = "kebab-case")] |
| 30 |
pub enum Variant { |
| 31 |
Light, |
| 32 |
Dark, |
| 33 |
HighContrast, |
| 34 |
} |
| 35 |
|
| 36 |
impl Variant { |
| 37 |
|
| 38 |
#[must_use] |
| 39 |
pub const fn as_str(self) -> &'static str { |
| 40 |
match self { |
| 41 |
Variant::Light => "light", |
| 42 |
Variant::Dark => "dark", |
| 43 |
Variant::HighContrast => "high-contrast", |
| 44 |
} |
| 45 |
} |
| 46 |
|
| 47 |
|
| 48 |
#[must_use] |
| 49 |
pub fn parse(raw: &str) -> Option<Self> { |
| 50 |
match raw { |
| 51 |
"light" => Some(Variant::Light), |
| 52 |
"dark" => Some(Variant::Dark), |
| 53 |
"high-contrast" => Some(Variant::HighContrast), |
| 54 |
_ => None, |
| 55 |
} |
| 56 |
} |
| 57 |
} |
| 58 |
|
| 59 |
impl std::fmt::Display for Variant { |
| 60 |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 61 |
f.write_str(self.as_str()) |
| 62 |
} |
| 63 |
} |
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
impl From<&str> for Variant { |
| 69 |
fn from(raw: &str) -> Self { |
| 70 |
Variant::parse(raw).unwrap_or(Variant::Dark) |
| 71 |
} |
| 72 |
} |
| 73 |
|
| 74 |
impl ThemeMeta { |
| 75 |
|
| 76 |
#[must_use] |
| 77 |
pub fn kind(&self) -> Variant { |
| 78 |
Variant::from(self.variant.as_str()) |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
|
| 83 |
pub const FOLLOW: &str = "system"; |
| 84 |
|
| 85 |
|
| 86 |
|
| 87 |
|
| 88 |
|
| 89 |
|
| 90 |
|
| 91 |
#[derive(Debug, Clone, PartialEq, Eq, Default)] |
| 92 |
pub enum ThemeSelection { |
| 93 |
|
| 94 |
#[default] |
| 95 |
Follow, |
| 96 |
|
| 97 |
Fixed(String), |
| 98 |
} |
| 99 |
|
| 100 |
impl ThemeSelection { |
| 101 |
|
| 102 |
|
| 103 |
|
| 104 |
|
| 105 |
#[must_use] |
| 106 |
pub fn parse(raw: Option<&str>) -> Self { |
| 107 |
match raw.map(str::trim) { |
| 108 |
None | Some("" | FOLLOW) => ThemeSelection::Follow, |
| 109 |
Some(id) => ThemeSelection::Fixed(id.to_string()), |
| 110 |
} |
| 111 |
} |
| 112 |
|
| 113 |
|
| 114 |
#[must_use] |
| 115 |
pub fn as_str(&self) -> &str { |
| 116 |
match self { |
| 117 |
ThemeSelection::Follow => FOLLOW, |
| 118 |
ThemeSelection::Fixed(id) => id, |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
|
| 123 |
|
| 124 |
|
| 125 |
|
| 126 |
|
| 127 |
|
| 128 |
|
| 129 |
|
| 130 |
|
| 131 |
|
| 132 |
|
| 133 |
|
| 134 |
|
| 135 |
|
| 136 |
|
| 137 |
|
| 138 |
#[must_use] |
| 139 |
pub fn resolve( |
| 140 |
&self, |
| 141 |
ambient: Variant, |
| 142 |
defaults: &ThemeDefaults, |
| 143 |
available: &[ThemeMeta], |
| 144 |
) -> String { |
| 145 |
let installed = |id: &str| available.iter().any(|meta| meta.id == id); |
| 146 |
|
| 147 |
if let ThemeSelection::Fixed(id) = self |
| 148 |
&& installed(id) |
| 149 |
{ |
| 150 |
return id.clone(); |
| 151 |
} |
| 152 |
|
| 153 |
let preferred = defaults.for_variant(ambient); |
| 154 |
if installed(preferred) { |
| 155 |
return preferred.to_string(); |
| 156 |
} |
| 157 |
available |
| 158 |
.iter() |
| 159 |
.find(|meta| meta.kind() == ambient) |
| 160 |
.map_or_else(|| preferred.to_string(), |meta| meta.id.clone()) |
| 161 |
} |
| 162 |
} |
| 163 |
|
| 164 |
impl std::fmt::Display for ThemeSelection { |
| 165 |
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 166 |
f.write_str(self.as_str()) |
| 167 |
} |
| 168 |
} |
| 169 |
|
| 170 |
|
| 171 |
|
| 172 |
|
| 173 |
|
| 174 |
#[derive(Debug, Clone)] |
| 175 |
pub struct ThemeDefaults { |
| 176 |
light: String, |
| 177 |
dark: String, |
| 178 |
high_contrast: Option<String>, |
| 179 |
} |
| 180 |
|
| 181 |
impl ThemeDefaults { |
| 182 |
pub fn new(light: impl Into<String>, dark: impl Into<String>) -> Self { |
| 183 |
Self { |
| 184 |
light: light.into(), |
| 185 |
dark: dark.into(), |
| 186 |
high_contrast: None, |
| 187 |
} |
| 188 |
} |
| 189 |
|
| 190 |
|
| 191 |
|
| 192 |
#[must_use] |
| 193 |
pub fn high_contrast(mut self, id: impl Into<String>) -> Self { |
| 194 |
self.high_contrast = Some(id.into()); |
| 195 |
self |
| 196 |
} |
| 197 |
|
| 198 |
|
| 199 |
|
| 200 |
|
| 201 |
|
| 202 |
|
| 203 |
|
| 204 |
|
| 205 |
|
| 206 |
|
| 207 |
#[must_use] |
| 208 |
pub const fn names_high_contrast(&self) -> bool { |
| 209 |
self.high_contrast.is_some() |
| 210 |
} |
| 211 |
|
| 212 |
#[must_use] |
| 213 |
pub fn for_variant(&self, variant: Variant) -> &str { |
| 214 |
match variant { |
| 215 |
Variant::Light => &self.light, |
| 216 |
Variant::Dark => &self.dark, |
| 217 |
Variant::HighContrast => self.high_contrast.as_ref().unwrap_or(&self.dark), |
| 218 |
} |
| 219 |
} |
| 220 |
} |
| 221 |
|
| 222 |
|
| 223 |
|
| 224 |
|
| 225 |
|
| 226 |
|
| 227 |
|
| 228 |
|
| 229 |
|
| 230 |
|
| 231 |
|
| 232 |
|
| 233 |
|
| 234 |
|
| 235 |
|
| 236 |
|
| 237 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)] |
| 238 |
#[serde(rename_all = "kebab-case")] |
| 239 |
pub enum ContrastTier { |
| 240 |
|
| 241 |
Low, |
| 242 |
|
| 243 |
Standard, |
| 244 |
|
| 245 |
High, |
| 246 |
} |
| 247 |
|
| 248 |
impl ContrastTier { |
| 249 |
|
| 250 |
#[must_use] |
| 251 |
pub const fn as_str(self) -> &'static str { |
| 252 |
match self { |
| 253 |
ContrastTier::Low => "low", |
| 254 |
ContrastTier::Standard => "standard", |
| 255 |
ContrastTier::High => "high", |
| 256 |
} |
| 257 |
} |
| 258 |
|
| 259 |
|
| 260 |
|
| 261 |
|
| 262 |
|
| 263 |
|
| 264 |
|
| 265 |
#[must_use] |
| 266 |
pub fn of(theme: &ThemeColors) -> Self { |
| 267 |
let colour = |key: &str| theme.colors.get(key).and_then(|v| Rgb::from_hex(v)); |
| 268 |
let (Some(muted), Some(page), Some(sunken)) = ( |
| 269 |
colour("content.muted"), |
| 270 |
colour("surface.page"), |
| 271 |
colour("surface.sunken"), |
| 272 |
) else { |
| 273 |
return ContrastTier::Standard; |
| 274 |
}; |
| 275 |
|
| 276 |
let worst = wcag_contrast(muted, page).min(wcag_contrast(muted, sunken)); |
| 277 |
if worst >= 4.5 { |
| 278 |
ContrastTier::High |
| 279 |
} else if worst >= 3.0 { |
| 280 |
ContrastTier::Standard |
| 281 |
} else { |
| 282 |
ContrastTier::Low |
| 283 |
} |
| 284 |
} |
| 285 |
} |
| 286 |
|
| 287 |
|
| 288 |
|
| 289 |
|
| 290 |
|
| 291 |
|
| 292 |
|
| 293 |
|
| 294 |
|
| 295 |
|
| 296 |
|
| 297 |
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] |
| 298 |
#[serde(rename_all = "camelCase")] |
| 299 |
pub struct ThemeOption { |
| 300 |
|
| 301 |
pub id: String, |
| 302 |
|
| 303 |
pub name: String, |
| 304 |
|
| 305 |
pub variant: Variant, |
| 306 |
|
| 307 |
pub contrast: ContrastTier, |
| 308 |
} |
| 309 |
|
| 310 |
|
| 311 |
|
| 312 |
|
| 313 |
|
| 314 |
|
| 315 |
|
| 316 |
|
| 317 |
|
| 318 |
|
| 319 |
|
| 320 |
|
| 321 |
|
| 322 |
|
| 323 |
|
| 324 |
|
| 325 |
|
| 326 |
|
| 327 |
|
| 328 |
|
| 329 |
|
| 330 |
|
| 331 |
|
| 332 |
|
| 333 |
|
| 334 |
|
| 335 |
|
| 336 |
|
| 337 |
|
| 338 |
|
| 339 |
|
| 340 |
|
| 341 |
|
| 342 |
|
| 343 |
|
| 344 |
#[must_use] |
| 345 |
pub fn theme_options(dirs: &[(PathBuf, bool)]) -> Vec<ThemeOption> { |
| 346 |
let mut options: Vec<ThemeOption> = list_themes_from_dirs(dirs) |
| 347 |
.into_iter() |
| 348 |
.map(|meta| { |
| 349 |
let contrast = load_theme(dirs, &meta.id) |
| 350 |
.map_or(ContrastTier::Standard, |theme| ContrastTier::of(&theme)); |
| 351 |
ThemeOption { |
| 352 |
variant: meta.kind(), |
| 353 |
contrast, |
| 354 |
id: meta.id, |
| 355 |
name: meta.name, |
| 356 |
} |
| 357 |
}) |
| 358 |
.collect(); |
| 359 |
|
| 360 |
order_theme_options(&mut options); |
| 361 |
options |
| 362 |
} |
| 363 |
|
| 364 |
|
| 365 |
|
| 366 |
|
| 367 |
|
| 368 |
|
| 369 |
|
| 370 |
|
| 371 |
|
| 372 |
|
| 373 |
|
| 374 |
|
| 375 |
pub fn order_theme_options(options: &mut [ThemeOption]) { |
| 376 |
options.sort_by(|a, b| { |
| 377 |
variant_order(a.variant) |
| 378 |
.cmp(&variant_order(b.variant)) |
| 379 |
.then(b.contrast.cmp(&a.contrast)) |
| 380 |
.then_with(|| a.name.cmp(&b.name)) |
| 381 |
}); |
| 382 |
} |
| 383 |
|
| 384 |
|
| 385 |
|
| 386 |
|
| 387 |
|
| 388 |
|
| 389 |
const fn variant_order(variant: Variant) -> u8 { |
| 390 |
match variant { |
| 391 |
Variant::Light => 0, |
| 392 |
Variant::Dark => 1, |
| 393 |
Variant::HighContrast => 2, |
| 394 |
} |
| 395 |
} |
| 396 |
|
| 397 |
#[cfg(test)] |
| 398 |
mod tests { |
| 399 |
use super::*; |
| 400 |
use crate::bundled_themes_dir; |
| 401 |
use std::collections::HashMap; |
| 402 |
|
| 403 |
fn meta(id: &str, variant: &str) -> ThemeMeta { |
| 404 |
ThemeMeta { |
| 405 |
id: id.to_string(), |
| 406 |
name: id.to_string(), |
| 407 |
variant: variant.to_string(), |
| 408 |
is_custom: false, |
| 409 |
} |
| 410 |
} |
| 411 |
|
| 412 |
fn defaults() -> ThemeDefaults { |
| 413 |
ThemeDefaults::new("flatwhite", "nord") |
| 414 |
} |
| 415 |
|
| 416 |
|
| 417 |
#[test] |
| 418 |
fn every_shipped_variant_parses() { |
| 419 |
assert_eq!(Variant::parse("light"), Some(Variant::Light)); |
| 420 |
assert_eq!(Variant::parse("dark"), Some(Variant::Dark)); |
| 421 |
assert_eq!(Variant::parse("high-contrast"), Some(Variant::HighContrast)); |
| 422 |
assert_eq!(Variant::parse("sepia"), None); |
| 423 |
} |
| 424 |
|
| 425 |
|
| 426 |
|
| 427 |
|
| 428 |
#[test] |
| 429 |
fn an_unrecognized_variant_reads_the_way_a_missing_one_does() { |
| 430 |
assert_eq!(Variant::from("sepia"), Variant::Dark); |
| 431 |
assert_eq!(Variant::from(""), Variant::Dark); |
| 432 |
|
| 433 |
let missing: toml::Table = "[meta]\nname = \"X\"\n".parse().unwrap(); |
| 434 |
assert_eq!(parse_meta("x", &missing, false).kind(), Variant::Dark); |
| 435 |
} |
| 436 |
|
| 437 |
#[test] |
| 438 |
fn a_selection_round_trips_through_any_store() { |
| 439 |
for (stored, expect) in [ |
| 440 |
(Some("system"), ThemeSelection::Follow), |
| 441 |
(None, ThemeSelection::Follow), |
| 442 |
(Some(""), ThemeSelection::Follow), |
| 443 |
(Some(" "), ThemeSelection::Follow), |
| 444 |
(Some("nord"), ThemeSelection::Fixed("nord".into())), |
| 445 |
] { |
| 446 |
let parsed = ThemeSelection::parse(stored); |
| 447 |
assert_eq!(parsed, expect, "{stored:?}"); |
| 448 |
assert_eq!( |
| 449 |
ThemeSelection::parse(Some(parsed.as_str())), |
| 450 |
expect, |
| 451 |
"what is written reads back as what was meant", |
| 452 |
); |
| 453 |
} |
| 454 |
} |
| 455 |
|
| 456 |
|
| 457 |
|
| 458 |
|
| 459 |
#[test] |
| 460 |
fn nothing_chosen_yet_is_follow() { |
| 461 |
assert_eq!(ThemeSelection::default(), ThemeSelection::Follow); |
| 462 |
} |
| 463 |
|
| 464 |
#[test] |
| 465 |
fn a_fixed_selection_wins_when_its_theme_is_installed() { |
| 466 |
let available = [meta("nord", "dark"), meta("flatwhite", "light")]; |
| 467 |
let fixed = ThemeSelection::Fixed("nord".into()); |
| 468 |
assert_eq!( |
| 469 |
fixed.resolve(Variant::Light, &defaults(), &available), |
| 470 |
"nord", |
| 471 |
"a chosen theme is not overridden by the ambient mode", |
| 472 |
); |
| 473 |
} |
| 474 |
|
| 475 |
|
| 476 |
|
| 477 |
#[test] |
| 478 |
fn a_fixed_selection_whose_theme_is_gone_falls_back() { |
| 479 |
let available = [meta("nord", "dark"), meta("flatwhite", "light")]; |
| 480 |
let fixed = ThemeSelection::Fixed("deleted".into()); |
| 481 |
assert_eq!( |
| 482 |
fixed.resolve(Variant::Light, &defaults(), &available), |
| 483 |
"flatwhite", |
| 484 |
); |
| 485 |
} |
| 486 |
|
| 487 |
#[test] |
| 488 |
fn follow_picks_the_apps_default_for_the_ambient_mode() { |
| 489 |
let available = [meta("nord", "dark"), meta("flatwhite", "light")]; |
| 490 |
let follow = ThemeSelection::Follow; |
| 491 |
assert_eq!( |
| 492 |
follow.resolve(Variant::Dark, &defaults(), &available), |
| 493 |
"nord", |
| 494 |
); |
| 495 |
assert_eq!( |
| 496 |
follow.resolve(Variant::Light, &defaults(), &available), |
| 497 |
"flatwhite", |
| 498 |
); |
| 499 |
} |
| 500 |
|
| 501 |
|
| 502 |
|
| 503 |
#[test] |
| 504 |
fn follow_uses_any_installed_theme_of_the_right_variant() { |
| 505 |
let available = [meta("solarized-light", "light"), meta("mine", "dark")]; |
| 506 |
assert_eq!( |
| 507 |
ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &available), |
| 508 |
"mine", |
| 509 |
"the app's `nord` is not installed, but a dark theme is", |
| 510 |
); |
| 511 |
} |
| 512 |
|
| 513 |
|
| 514 |
|
| 515 |
#[test] |
| 516 |
fn an_empty_catalog_still_names_the_apps_default() { |
| 517 |
assert_eq!( |
| 518 |
ThemeSelection::Follow.resolve(Variant::Dark, &defaults(), &[]), |
| 519 |
"nord", |
| 520 |
); |
| 521 |
} |
| 522 |
|
| 523 |
#[test] |
| 524 |
fn high_contrast_falls_back_to_dark_unless_named() { |
| 525 |
let plain = defaults(); |
| 526 |
assert_eq!(plain.for_variant(Variant::HighContrast), "nord"); |
| 527 |
|
| 528 |
let named = defaults().high_contrast("sharp"); |
| 529 |
assert_eq!(named.for_variant(Variant::HighContrast), "sharp"); |
| 530 |
} |
| 531 |
|
| 532 |
#[test] |
| 533 |
fn theme_options_groups_by_variant_light_first() { |
| 534 |
let dirs = vec![(bundled_themes_dir().unwrap(), false)]; |
| 535 |
let options = theme_options(&dirs); |
| 536 |
assert!(!options.is_empty(), "the shipped set is not empty"); |
| 537 |
|
| 538 |
let order: Vec<u8> = options.iter().map(|o| variant_order(o.variant)).collect(); |
| 539 |
let mut sorted = order.clone(); |
| 540 |
sorted.sort_unstable(); |
| 541 |
assert_eq!( |
| 542 |
order, sorted, |
| 543 |
"every variant should occupy one run, light first" |
| 544 |
); |
| 545 |
} |
| 546 |
|
| 547 |
#[test] |
| 548 |
fn theme_options_puts_the_most_legible_theme_first_in_its_group() { |
| 549 |
let dirs = vec![(bundled_themes_dir().unwrap(), false)]; |
| 550 |
let options = theme_options(&dirs); |
| 551 |
|
| 552 |
for pair in options.windows(2) { |
| 553 |
let (a, b) = (&pair[0], &pair[1]); |
| 554 |
if a.variant != b.variant { |
| 555 |
continue; |
| 556 |
} |
| 557 |
assert!( |
| 558 |
a.contrast >= b.contrast, |
| 559 |
"within {}, {} ({:?}) should not follow {} ({:?})", |
| 560 |
a.variant, |
| 561 |
b.id, |
| 562 |
b.contrast, |
| 563 |
a.id, |
| 564 |
a.contrast |
| 565 |
); |
| 566 |
if a.contrast == b.contrast { |
| 567 |
assert!( |
| 568 |
a.name <= b.name, |
| 569 |
"ties break by name: {} then {}", |
| 570 |
a.name, |
| 571 |
b.name |
| 572 |
); |
| 573 |
} |
| 574 |
} |
| 575 |
} |
| 576 |
|
| 577 |
#[test] |
| 578 |
fn theme_options_carries_every_theme_the_scan_found() { |
| 579 |
let dirs = vec![(bundled_themes_dir().unwrap(), false)]; |
| 580 |
let mut scanned: Vec<String> = list_themes_from_dirs(&dirs) |
| 581 |
.into_iter() |
| 582 |
.map(|meta| meta.id) |
| 583 |
.collect(); |
| 584 |
let mut offered: Vec<String> = theme_options(&dirs).into_iter().map(|o| o.id).collect(); |
| 585 |
scanned.sort(); |
| 586 |
offered.sort(); |
| 587 |
assert_eq!(scanned, offered, "ordering must not drop a theme"); |
| 588 |
} |
| 589 |
|
| 590 |
#[test] |
| 591 |
fn a_theme_that_cannot_be_measured_reads_as_standard() { |
| 592 |
|
| 593 |
|
| 594 |
let theme = ThemeColors { |
| 595 |
meta: ThemeMeta { |
| 596 |
id: "unmeasurable".to_string(), |
| 597 |
name: "Unmeasurable".to_string(), |
| 598 |
variant: "dark".to_string(), |
| 599 |
is_custom: false, |
| 600 |
}, |
| 601 |
colors: HashMap::new(), |
| 602 |
}; |
| 603 |
assert_eq!(ContrastTier::of(&theme), ContrastTier::Standard); |
| 604 |
} |
| 605 |
|
| 606 |
#[test] |
| 607 |
fn the_house_themes_measure_high() { |
| 608 |
|
| 609 |
|
| 610 |
|
| 611 |
|
| 612 |
|
| 613 |
|
| 614 |
|
| 615 |
let dirs = vec![(bundled_themes_dir().unwrap(), false)]; |
| 616 |
for id in ["goingson", "audiofiles"] { |
| 617 |
let theme = load_theme(&dirs, id).expect("shipped"); |
| 618 |
assert_eq!( |
| 619 |
ContrastTier::of(&theme), |
| 620 |
ContrastTier::High, |
| 621 |
"{id} is one of ours and should meet AA on both grounds" |
| 622 |
); |
| 623 |
} |
| 624 |
} |
| 625 |
|
| 626 |
#[test] |
| 627 |
fn contrast_tiers_order_worst_first() { |
| 628 |
assert!(ContrastTier::Low < ContrastTier::Standard); |
| 629 |
assert!(ContrastTier::Standard < ContrastTier::High); |
| 630 |
} |
| 631 |
} |
| 632 |
|