| 4 |
4 |
|
//! that return `Color32` values. Derived colors (row stripes, selection highlight)
|
| 5 |
5 |
|
//! are computed from the base palette.
|
| 6 |
6 |
|
//!
|
| 7 |
|
- |
//! Themes are embedded at compile time from `themes/` within this crate. Users can also
|
| 8 |
|
- |
//! drop custom `.toml` files into their platform config directory
|
| 9 |
|
- |
//! (`<config>/audiofiles/themes/`) which override bundled themes by ID.
|
|
7 |
+ |
//! The bundled themes come from `makeover`, embedded at compile time. Users can
|
|
8 |
+ |
//! also drop custom `.toml` files into their platform config directory
|
|
9 |
+ |
//! (`<config>/audiofiles/themes/`), which override bundled themes by ID.
|
|
10 |
+ |
//!
|
|
11 |
+ |
//! Which theme is active is two questions, not one: what the user chose
|
|
12 |
+ |
//! ([`chosen`], `Follow` or `Fixed`) and what that resolves to right now
|
|
13 |
+ |
//! ([`active_id`]). Following the system means the second changes while the
|
|
14 |
+ |
//! first does not. See `docs/design-system.md`.
|
|
15 |
+ |
//!
|
|
16 |
+ |
//! <!-- wiki: af-platinum -->
|
| 10 |
17 |
|
|
| 11 |
18 |
|
use egui::Color32;
|
|
19 |
+ |
use makeover::Variant;
|
| 12 |
20 |
|
use parking_lot::RwLock;
|
| 13 |
21 |
|
use std::collections::HashMap;
|
| 14 |
|
- |
use std::path::{Path, PathBuf};
|
|
22 |
+ |
use std::path::PathBuf;
|
| 15 |
23 |
|
use std::sync::LazyLock;
|
| 16 |
24 |
|
use std::sync::atomic::{AtomicU32, Ordering};
|
| 17 |
25 |
|
use tracing::{error, warn};
|
| 185 |
193 |
|
}
|
| 186 |
194 |
|
}
|
| 187 |
195 |
|
|
| 188 |
|
- |
pub use makeover::ThemeMeta;
|
|
196 |
+ |
pub use makeover::{ThemeMeta, ThemeSelection};
|
| 189 |
197 |
|
|
| 190 |
198 |
|
static THEME: LazyLock<RwLock<ThemeColors>> = LazyLock::new(|| RwLock::new(ThemeColors::default()));
|
| 191 |
199 |
|
|
| 359 |
367 |
|
dirs::config_dir().map(|c| c.join("audiofiles").join("themes"))
|
| 360 |
368 |
|
}
|
| 361 |
369 |
|
|
|
370 |
+ |
/// The on-disk search path, in makeover's precedence order.
|
|
371 |
+ |
///
|
|
372 |
+ |
/// One tier, because the bundled set is not on disk: it is embedded at compile
|
|
373 |
+ |
/// time through [`BUNDLED_THEMES`], which is the only form that survives into a
|
|
374 |
+ |
/// shipped AppImage (`makeover::bundled_themes_dir` resolves against the
|
|
375 |
+ |
/// crate's manifest directory, which exists on a dev box and nowhere else).
|
|
376 |
+ |
/// So the builder is doing one job here rather than three, and it is still
|
|
377 |
+ |
/// worth going through: id validation, traversal refusal and "later wins" are
|
|
378 |
+ |
/// the library's to state, not this file's to restate.
|
|
379 |
+ |
fn theme_dirs() -> Vec<(PathBuf, bool)> {
|
|
380 |
+ |
makeover::ThemeDirs::new()
|
|
381 |
+ |
.custom(custom_themes_dir())
|
|
382 |
+ |
.build()
|
|
383 |
+ |
}
|
|
384 |
+ |
|
|
385 |
+ |
/// A theme's TOML source by id, or `None` if no tier has it.
|
|
386 |
+ |
///
|
|
387 |
+ |
/// On-disk themes win over embedded ones of the same id. That is the order
|
|
388 |
+ |
/// [`list_themes`] has always shown in the picker and the order the old
|
|
389 |
+ |
/// `set_theme` had backwards: it checked bundled first, so a custom theme
|
|
390 |
+ |
/// saved under a bundled id was listed as custom, badged as custom, and then
|
|
391 |
+ |
/// silently loaded the bundled file.
|
|
392 |
+ |
fn theme_source(id: &str) -> Option<String> {
|
|
393 |
+ |
if let Some((path, _)) = makeover::find_theme_path(&theme_dirs(), id) {
|
|
394 |
+ |
match std::fs::read_to_string(&path) {
|
|
395 |
+ |
Ok(content) => return Some(content),
|
|
396 |
+ |
Err(e) => warn!("Failed to read custom theme '{id}': {e}"),
|
|
397 |
+ |
}
|
|
398 |
+ |
}
|
|
399 |
+ |
BUNDLED_THEMES
|
|
400 |
+ |
.iter()
|
|
401 |
+ |
.find(|(bundled_id, _)| *bundled_id == id)
|
|
402 |
+ |
.map(|(_, content)| (*content).to_string())
|
|
403 |
+ |
}
|
|
404 |
+ |
|
| 362 |
405 |
|
/// Accessibility tier for a theme's muted (tertiary) text, by WCAG contrast of
|
| 363 |
406 |
|
/// `content_muted` against the theme's darkest and lightest panel backgrounds. We
|
| 364 |
407 |
|
/// surface this in the picker rather than overriding curated palettes' colors,
|
| 384 |
427 |
|
}
|
| 385 |
428 |
|
}
|
| 386 |
429 |
|
|
| 387 |
|
- |
|
| 388 |
430 |
|
/// Load a theme's full color set by id (bundled or custom on disk).
|
| 389 |
431 |
|
fn theme_colors_for(id: &str) -> Option<ThemeColors> {
|
| 390 |
|
- |
for (bundled_id, content) in BUNDLED_THEMES.iter() {
|
| 391 |
|
- |
if *bundled_id == id {
|
| 392 |
|
- |
return parse_theme(content).ok();
|
| 393 |
|
- |
}
|
| 394 |
|
- |
}
|
| 395 |
|
- |
let dir = custom_themes_dir()?;
|
| 396 |
|
- |
let content = std::fs::read_to_string(dir.join(format!("{id}.toml"))).ok()?;
|
| 397 |
|
- |
parse_theme(&content).ok()
|
|
432 |
+ |
parse_theme(&theme_source(id)?).ok()
|
| 398 |
433 |
|
}
|
| 399 |
434 |
|
|
| 400 |
435 |
|
/// Compute the muted-text contrast tier for a theme id. Defaults to `Standard`
|
| 416 |
451 |
|
|
| 417 |
452 |
|
/// List all available themes (bundled + custom). Custom themes override bundled by ID.
|
| 418 |
453 |
|
pub fn list_themes() -> Vec<ThemeMeta> {
|
| 419 |
|
- |
let mut themes: Vec<ThemeMeta> = Vec::new();
|
| 420 |
|
- |
let mut seen = std::collections::HashSet::new();
|
|
454 |
+ |
let mut themes: Vec<ThemeMeta> = BUNDLED_THEMES
|
|
455 |
+ |
.iter()
|
|
456 |
+ |
.filter_map(|(id, content)| {
|
|
457 |
+ |
let table: toml::Table = content.parse().ok()?;
|
|
458 |
+ |
Some(makeover::parse_meta(id, &table, false))
|
|
459 |
+ |
})
|
|
460 |
+ |
.collect();
|
| 421 |
461 |
|
|
| 422 |
|
- |
// Bundled themes first
|
| 423 |
|
- |
for (id, content) in BUNDLED_THEMES.iter() {
|
| 424 |
|
- |
let table: toml::Table = match content.parse() {
|
| 425 |
|
- |
Ok(t) => t,
|
| 426 |
|
- |
Err(_) => continue,
|
| 427 |
|
- |
};
|
| 428 |
|
- |
seen.insert(id.to_string());
|
| 429 |
|
- |
themes.push(makeover::parse_meta(id, &table, false));
|
| 430 |
|
- |
}
|
| 431 |
|
- |
|
| 432 |
|
- |
// Custom themes from config dir (override bundled by ID)
|
| 433 |
|
- |
if let Some(dir) = custom_themes_dir()
|
| 434 |
|
- |
&& let Ok(entries) = std::fs::read_dir(&dir)
|
| 435 |
|
- |
{
|
| 436 |
|
- |
for entry in entries.flatten() {
|
| 437 |
|
- |
let path = entry.path();
|
| 438 |
|
- |
if path.extension().is_some_and(|e| e == "toml") {
|
| 439 |
|
- |
let id = path
|
| 440 |
|
- |
.file_stem()
|
| 441 |
|
- |
.unwrap_or_default()
|
| 442 |
|
- |
.to_string_lossy()
|
| 443 |
|
- |
.to_string();
|
| 444 |
|
- |
if let Ok(content) = std::fs::read_to_string(&path) {
|
| 445 |
|
- |
let table: toml::Table = match content.parse() {
|
| 446 |
|
- |
Ok(t) => t,
|
| 447 |
|
- |
Err(_) => continue,
|
| 448 |
|
- |
};
|
| 449 |
|
- |
let meta = makeover::parse_meta(&id, &table, true);
|
| 450 |
|
- |
if seen.contains(&id) {
|
| 451 |
|
- |
if let Some(existing) = themes.iter_mut().find(|t| t.id == id) {
|
| 452 |
|
- |
existing.name = meta.name;
|
| 453 |
|
- |
existing.variant = meta.variant;
|
| 454 |
|
- |
existing.is_custom = true;
|
| 455 |
|
- |
}
|
| 456 |
|
- |
} else {
|
| 457 |
|
- |
seen.insert(id.clone());
|
| 458 |
|
- |
themes.push(meta);
|
| 459 |
|
- |
}
|
| 460 |
|
- |
}
|
| 461 |
|
- |
}
|
|
462 |
+ |
for custom in makeover::list_themes_from_dirs(&theme_dirs()) {
|
|
463 |
+ |
match themes.iter_mut().find(|t| t.id == custom.id) {
|
|
464 |
+ |
Some(shadowed) => *shadowed = custom,
|
|
465 |
+ |
None => themes.push(custom),
|
| 462 |
466 |
|
}
|
| 463 |
467 |
|
}
|
| 464 |
468 |
|
|
|
469 |
+ |
themes.sort_by(|a, b| a.name.cmp(&b.name));
|
| 465 |
470 |
|
themes
|
| 466 |
471 |
|
}
|
| 467 |
472 |
|
|
| 538 |
543 |
|
})
|
| 539 |
544 |
|
}
|
| 540 |
545 |
|
|
| 541 |
|
- |
/// Load a theme from a TOML file path. Returns the parsed ThemeColors.
|
| 542 |
|
- |
pub fn load_theme(path: &Path) -> Result<ThemeColors, crate::error::ThemeError> {
|
| 543 |
|
- |
let content = std::fs::read_to_string(path).map_err(|e| crate::error::ThemeError::Read {
|
| 544 |
|
- |
path: path.to_path_buf(),
|
| 545 |
|
- |
source: e,
|
| 546 |
|
- |
})?;
|
| 547 |
|
- |
parse_theme(&content).map_err(|e| crate::error::ThemeError::Parse {
|
| 548 |
|
- |
path: path.to_path_buf(),
|
| 549 |
|
- |
source: e,
|
| 550 |
|
- |
})
|
| 551 |
|
- |
}
|
| 552 |
|
- |
|
| 553 |
|
- |
/// Initialise the active theme. If `id` is provided, loads that theme;
|
| 554 |
|
- |
/// otherwise falls back to "tokyonight".
|
| 555 |
|
- |
pub fn init(id: Option<&str>) {
|
| 556 |
|
- |
set_theme(id.unwrap_or("audiofiles"));
|
| 557 |
|
- |
}
|
| 558 |
|
- |
|
| 559 |
|
- |
/// Switch the active theme. Checks bundled themes first, then custom directory.
|
| 560 |
|
- |
pub fn set_theme(id: &str) {
|
| 561 |
|
- |
// Try bundled themes first
|
| 562 |
|
- |
for (bundled_id, content) in BUNDLED_THEMES.iter() {
|
| 563 |
|
- |
if *bundled_id == id {
|
| 564 |
|
- |
match parse_theme(content) {
|
| 565 |
|
- |
Ok(colors) => {
|
| 566 |
|
- |
install_theme(colors);
|
| 567 |
|
- |
return;
|
| 568 |
|
- |
}
|
| 569 |
|
- |
Err(e) => {
|
| 570 |
|
- |
error!("Failed to parse bundled theme '{id}': {e}");
|
| 571 |
|
- |
return;
|
| 572 |
|
- |
}
|
| 573 |
|
- |
}
|
| 574 |
|
- |
}
|
| 575 |
|
- |
}
|
| 576 |
|
- |
|
| 577 |
|
- |
// Try custom themes directory
|
| 578 |
|
- |
if let Some(dir) = custom_themes_dir() {
|
| 579 |
|
- |
let path = dir.join(format!("{id}.toml"));
|
| 580 |
|
- |
if path.exists() {
|
| 581 |
|
- |
match load_theme(&path) {
|
| 582 |
|
- |
Ok(colors) => {
|
| 583 |
|
- |
install_theme(colors);
|
| 584 |
|
- |
return;
|
| 585 |
|
- |
}
|
| 586 |
|
- |
Err(e) => {
|
| 587 |
|
- |
error!("Failed to load custom theme '{id}': {e}");
|
| 588 |
|
- |
return;
|
| 589 |
|
- |
}
|
| 590 |
|
- |
}
|
| 591 |
|
- |
}
|
| 592 |
|
- |
}
|
| 593 |
|
- |
|
| 594 |
|
- |
warn!("Theme '{id}' not found; keeping current theme");
|
| 595 |
|
- |
}
|
| 596 |
|
- |
|
| 597 |
546 |
|
/// Get preview colors (background, accent, foreground) for a theme by ID.
|
| 598 |
547 |
|
/// Returns (surface_page, action, content) or None if theme can't be loaded.
|
|
548 |
+ |
///
|
|
549 |
+ |
/// Not `makeover::load_theme_preview`, which reads a path: the bundled set is
|
|
550 |
+ |
/// embedded here, so half the ids in the picker have no file to point it at.
|
|
551 |
+ |
/// The swatch keys are the same ones it reads.
|
| 599 |
552 |
|
pub fn theme_preview_colors(id: &str) -> Option<(Color32, Color32, Color32)> {
|
| 600 |
|
- |
let content = {
|
| 601 |
|
- |
// Check bundled first
|
| 602 |
|
- |
let mut found = None;
|
| 603 |
|
- |
for (bundled_id, c) in BUNDLED_THEMES.iter() {
|
| 604 |
|
- |
if *bundled_id == id {
|
| 605 |
|
- |
found = Some(c.to_string());
|
| 606 |
|
- |
break;
|
| 607 |
|
- |
}
|
| 608 |
|
- |
}
|
| 609 |
|
- |
if found.is_none()
|
| 610 |
|
- |
&& let Some(dir) = custom_themes_dir()
|
| 611 |
|
- |
{
|
| 612 |
|
- |
let path = dir.join(format!("{id}.toml"));
|
| 613 |
|
- |
found = std::fs::read_to_string(&path).ok();
|
| 614 |
|
- |
}
|
| 615 |
|
- |
found?
|
| 616 |
|
- |
};
|
| 617 |
|
- |
|
| 618 |
|
- |
let table: toml::Table = content.parse().ok()?;
|
|
553 |
+ |
let table: toml::Table = theme_source(id)?.parse().ok()?;
|
| 619 |
554 |
|
let colors = makeover::extract_colors(&table);
|
| 620 |
555 |
|
let bg = get_color(&colors, "surface.page").unwrap_or(Color32::from_rgb(30, 30, 30));
|
| 621 |
556 |
|
let accent = get_color(&colors, "action.primary").unwrap_or(Color32::from_rgb(100, 100, 255));
|
| 623 |
558 |
|
Some((bg, accent, fg))
|
| 624 |
559 |
|
}
|
| 625 |
560 |
|
|
| 626 |
|
- |
/// Export a theme's TOML content by ID. Checks custom directory first, then
|
| 627 |
|
- |
/// bundled themes. Returns the raw TOML string or `None` if not found.
|
|
561 |
+ |
/// Export a theme's TOML content by ID, or `None` if no tier has it.
|
| 628 |
562 |
|
pub fn export_theme_content(id: &str) -> Option<String> {
|
| 629 |
|
- |
// Check custom directory first
|
| 630 |
|
- |
if let Some(dir) = custom_themes_dir() {
|
| 631 |
|
- |
let path = dir.join(format!("{id}.toml"));
|
| 632 |
|
- |
if let Ok(content) = std::fs::read_to_string(&path) {
|
| 633 |
|
- |
return Some(content);
|
| 634 |
|
- |
}
|
| 635 |
|
- |
}
|
|
563 |
+ |
theme_source(id)
|
|
564 |
+ |
}
|
| 636 |
565 |
|
|
| 637 |
|
- |
for (bundled_id, content) in BUNDLED_THEMES.iter() {
|
| 638 |
|
- |
if *bundled_id == id {
|
| 639 |
|
- |
return Some(content.to_string());
|
| 640 |
|
- |
}
|
| 641 |
|
- |
}
|
|
566 |
+ |
// --- The active selection ---
|
| 642 |
567 |
|
|
| 643 |
|
- |
None
|
|
568 |
+ |
/// The theme this app is named for, and the one a light system gets.
|
|
569 |
+ |
pub const DEFAULT_THEME_ID: &str = "audiofiles";
|
|
570 |
+ |
|
|
571 |
+ |
/// The dark counterpart. There is no `audiofiles-dark`, and this is the theme
|
|
572 |
+ |
/// the app already named as its fallback before the skin existed.
|
|
573 |
+ |
const DEFAULT_DARK_THEME_ID: &str = "tokyonight";
|
|
574 |
+ |
|
|
575 |
+ |
/// What the user chose, what that resolved to, and the ambient mode it was
|
|
576 |
+ |
/// resolved against.
|
|
577 |
+ |
///
|
|
578 |
+ |
/// The three travel together because a resolution is only good for one ambient
|
|
579 |
+ |
/// variant: `Follow` means "decide again when the system flips", and deciding
|
|
580 |
+ |
/// again costs a directory scan and a TOML parse, so [`apply_theme`] compares
|
|
581 |
+ |
/// before it acts rather than resolving every frame.
|
|
582 |
+ |
struct Selection {
|
|
583 |
+ |
chosen: ThemeSelection,
|
|
584 |
+ |
active_id: String,
|
|
585 |
+ |
ambient: Variant,
|
|
586 |
+ |
}
|
|
587 |
+ |
|
|
588 |
+ |
static SELECTION: LazyLock<RwLock<Selection>> = LazyLock::new(|| {
|
|
589 |
+ |
RwLock::new(Selection {
|
|
590 |
+ |
chosen: ThemeSelection::default(),
|
|
591 |
+ |
active_id: DEFAULT_THEME_ID.to_string(),
|
|
592 |
+ |
// Nothing has asked the windowing system yet, and light is what this
|
|
593 |
+ |
// app's own theme is. The first `apply_theme` corrects it.
|
|
594 |
+ |
ambient: Variant::Light,
|
|
595 |
+ |
})
|
|
596 |
+ |
});
|
|
597 |
+ |
|
|
598 |
+ |
/// The themes a `Follow` choice lands on, and where a `Fixed` choice falls back
|
|
599 |
+ |
/// to when it names a theme that is no longer installed.
|
|
600 |
+ |
fn defaults() -> makeover::ThemeDefaults {
|
|
601 |
+ |
makeover::ThemeDefaults::new(DEFAULT_THEME_ID, DEFAULT_DARK_THEME_ID)
|
|
602 |
+ |
.high_contrast("high-contrast")
|
|
603 |
+ |
}
|
|
604 |
+ |
|
|
605 |
+ |
/// The system's light/dark preference, as makeover spells it.
|
|
606 |
+ |
///
|
|
607 |
+ |
/// egui reports `None` when the windowing system never told it. makeover's own
|
|
608 |
+ |
/// rule for a variant it cannot read is dark, so follow that rather than
|
|
609 |
+ |
/// inventing a second answer to the same question.
|
|
610 |
+ |
fn ambient_variant(ctx: &egui::Context) -> Variant {
|
|
611 |
+ |
match ctx.system_theme() {
|
|
612 |
+ |
Some(egui::Theme::Light) => Variant::Light,
|
|
613 |
+ |
Some(egui::Theme::Dark) | None => Variant::Dark,
|
|
614 |
+ |
}
|
|
615 |
+ |
}
|
|
616 |
+ |
|
|
617 |
+ |
/// Resolve a choice against the ambient mode and install what comes out.
|
|
618 |
+ |
/// Returns the id actually installed.
|
|
619 |
+ |
fn resolve_and_install(chosen: &ThemeSelection, ambient: Variant) -> String {
|
|
620 |
+ |
let id = chosen.resolve(ambient, &defaults(), &list_themes());
|
|
621 |
+ |
match theme_source(&id) {
|
|
622 |
+ |
Some(content) => match parse_theme(&content) {
|
|
623 |
+ |
Ok(colors) => install_theme(colors),
|
|
624 |
+ |
Err(e) => error!("Failed to parse theme '{id}': {e}"),
|
|
625 |
+ |
},
|
|
626 |
+ |
None => warn!("Theme '{id}' not found; keeping current theme"),
|
|
627 |
+ |
}
|
|
628 |
+ |
id
|
|
629 |
+ |
}
|
|
630 |
+ |
|
|
631 |
+ |
/// Initialise the active theme from a stored selection.
|
|
632 |
+ |
///
|
|
633 |
+ |
/// `None`, empty, or `"system"` all mean follow the system. Anything else is a
|
|
634 |
+ |
/// theme id; one naming a theme that has since been deleted resolves the same
|
|
635 |
+ |
/// way `Follow` does rather than failing.
|
|
636 |
+ |
pub fn init(stored: Option<&str>) {
|
|
637 |
+ |
set_selection(ThemeSelection::parse(stored));
|
|
638 |
+ |
}
|
|
639 |
+ |
|
|
640 |
+ |
/// Record a new choice and install what it resolves to, returning that id.
|
|
641 |
+ |
pub fn set_selection(chosen: ThemeSelection) -> String {
|
|
642 |
+ |
let ambient = SELECTION.read().ambient;
|
|
643 |
+ |
let id = resolve_and_install(&chosen, ambient);
|
|
644 |
+ |
let mut selection = SELECTION.write();
|
|
645 |
+ |
selection.chosen = chosen;
|
|
646 |
+ |
selection.active_id.clone_from(&id);
|
|
647 |
+ |
id
|
|
648 |
+ |
}
|
|
649 |
+ |
|
|
650 |
+ |
/// The user's standing choice, which is what gets persisted and what the
|
|
651 |
+ |
/// picker ticks. Named apart from [`selection`], the selected-row color.
|
|
652 |
+ |
pub fn chosen() -> ThemeSelection {
|
|
653 |
+ |
SELECTION.read().chosen.clone()
|
|
654 |
+ |
}
|
|
655 |
+ |
|
|
656 |
+ |
/// The id of the theme actually installed, which is what gets exported and
|
|
657 |
+ |
/// what a `Follow` choice currently resolves to.
|
|
658 |
+ |
pub fn active_id() -> String {
|
|
659 |
+ |
SELECTION.read().active_id.clone()
|
| 644 |
660 |
|
}
|
| 645 |
661 |
|
|
| 646 |
662 |
|
// --- Classification colors (domain-specific, not from theme TOML) ---
|
| 691 |
707 |
|
// to this surface's quantum.
|
| 692 |
708 |
|
PIXELS_PER_POINT.store(ctx.pixels_per_point().to_bits(), Ordering::Relaxed);
|
| 693 |
709 |
|
|
|
710 |
+ |
// The ambient mode is only knowable from a live context, so a `Follow`
|
|
711 |
+ |
// choice settles here rather than at init: egui learns the system
|
|
712 |
+ |
// appearance from the windowing system and reports it through the context,
|
|
713 |
+ |
// which nothing has at the point the stored selection is read. A flip is
|
|
714 |
+ |
// rare and re-resolving is not free, so this compares first.
|
|
715 |
+ |
let ambient = ambient_variant(ctx);
|
|
716 |
+ |
if SELECTION.read().ambient != ambient {
|
|
717 |
+ |
let chosen = chosen();
|
|
718 |
+ |
let id = resolve_and_install(&chosen, ambient);
|
|
719 |
+ |
let mut selection = SELECTION.write();
|
|
720 |
+ |
selection.ambient = ambient;
|
|
721 |
+ |
selection.active_id = id;
|
|
722 |
+ |
}
|
|
723 |
+ |
|
| 694 |
724 |
|
let t = THEME.read();
|
| 695 |
725 |
|
// Pick the base preset by luminance of the page background: a light theme
|
| 696 |
726 |
|
// needs `dark_mode = false` so `RichText::strong()` renders BLACK (readable
|
| 1183 |
1213 |
|
}
|
| 1184 |
1214 |
|
}
|
| 1185 |
1215 |
|
|
|
1216 |
+ |
// Theme selection
|
|
1217 |
+ |
//
|
|
1218 |
+ |
// These resolve against the bundled set rather than `list_themes()`, which
|
|
1219 |
+ |
// also scans the running user's config directory: a developer with a custom
|
|
1220 |
+ |
// theme installed should not get different test results from CI.
|
|
1221 |
+ |
|
|
1222 |
+ |
fn bundled_metas() -> Vec<ThemeMeta> {
|
|
1223 |
+ |
BUNDLED_THEMES
|
|
1224 |
+ |
.iter()
|
|
1225 |
+ |
.filter_map(|(id, content)| {
|
|
1226 |
+ |
let table: toml::Table = content.parse().ok()?;
|
|
1227 |
+ |
Some(makeover::parse_meta(id, &table, false))
|
|
1228 |
+ |
})
|
|
1229 |
+ |
.collect()
|
|
1230 |
+ |
}
|
|
1231 |
+ |
|
|
1232 |
+ |
#[test]
|
|
1233 |
+ |
fn the_named_defaults_are_actually_bundled() {
|
|
1234 |
+ |
// A typo here degrades quietly: `resolve` falls through to "any theme
|
|
1235 |
+ |
// of that variant", so the app would come up themed but not its own.
|
|
1236 |
+ |
let available = bundled_metas();
|
|
1237 |
+ |
for id in [DEFAULT_THEME_ID, DEFAULT_DARK_THEME_ID, "high-contrast"] {
|
|
1238 |
+ |
assert!(
|
|
1239 |
+ |
available.iter().any(|meta| meta.id == id),
|
|
1240 |
+ |
"default `{id}` is not in the bundled set"
|
|
1241 |
+ |
);
|
|
1242 |
+ |
}
|
|
1243 |
+ |
}
|
|
1244 |
+ |
|
|
1245 |
+ |
#[test]
|
|
1246 |
+ |
fn following_the_system_lands_on_this_app_in_light_and_its_counterpart_in_dark() {
|
|
1247 |
+ |
let available = bundled_metas();
|
|
1248 |
+ |
let follow = ThemeSelection::Follow;
|
|
1249 |
+ |
assert_eq!(
|
|
1250 |
+ |
follow.resolve(Variant::Light, &defaults(), &available),
|
|
1251 |
+ |
DEFAULT_THEME_ID
|
|
1252 |
+ |
);
|
|
1253 |
+ |
assert_eq!(
|
|
1254 |
+ |
follow.resolve(Variant::Dark, &defaults(), &available),
|
|
1255 |
+ |
DEFAULT_DARK_THEME_ID
|
|
1256 |
+ |
);
|
|
1257 |
+ |
assert_eq!(
|
|
1258 |
+ |
follow.resolve(Variant::HighContrast, &defaults(), &available),
|
|
1259 |
+ |
"high-contrast"
|
|
1260 |
+ |
);
|
|
1261 |
+ |
}
|
|
1262 |
+ |
|
|
1263 |
+ |
#[test]
|
|
1264 |
+ |
fn a_fixed_choice_wins_over_the_ambient_mode() {
|
|
1265 |
+ |
let available = bundled_metas();
|
|
1266 |
+ |
let fixed = ThemeSelection::Fixed("nord".to_string());
|
|
1267 |
+ |
// A dark theme chosen explicitly stays chosen on a light desktop.
|
|
1268 |
+ |
assert_eq!(
|
|
1269 |
+ |
fixed.resolve(Variant::Light, &defaults(), &available),
|
|
1270 |
+ |
"nord"
|
|
1271 |
+ |
);
|
|
1272 |
+ |
}
|
|
1273 |
+ |
|
|
1274 |
+ |
#[test]
|
|
1275 |
+ |
fn a_fixed_choice_that_was_deleted_falls_back_rather_than_failing() {
|
|
1276 |
+ |
let available = bundled_metas();
|
|
1277 |
+ |
let gone = ThemeSelection::Fixed("a-theme-the-user-deleted".to_string());
|
|
1278 |
+ |
assert_eq!(
|
|
1279 |
+ |
gone.resolve(Variant::Light, &defaults(), &available),
|
|
1280 |
+ |
DEFAULT_THEME_ID
|
|
1281 |
+ |
);
|
|
1282 |
+ |
}
|
|
1283 |
+ |
|
|
1284 |
+ |
#[test]
|
|
1285 |
+ |
fn stored_selections_round_trip() {
|
|
1286 |
+ |
// What `init` reads out of the synced config table, and what
|
|
1287 |
+ |
// `save_theme_preference` writes back into it.
|
|
1288 |
+ |
assert_eq!(ThemeSelection::parse(None), ThemeSelection::Follow);
|
|
1289 |
+ |
assert_eq!(ThemeSelection::parse(Some("")), ThemeSelection::Follow);
|
|
1290 |
+ |
assert_eq!(
|
|
1291 |
+ |
ThemeSelection::parse(Some("system")),
|
|
1292 |
+ |
ThemeSelection::Follow
|
|
1293 |
+ |
);
|
|
1294 |
+ |
assert_eq!(ThemeSelection::Follow.as_str(), "system");
|
|
1295 |
+ |
|
|
1296 |
+ |
let fixed = ThemeSelection::parse(Some("nord"));
|
|
1297 |
+ |
assert_eq!(fixed, ThemeSelection::Fixed("nord".to_string()));
|
|
1298 |
+ |
assert_eq!(fixed.as_str(), "nord");
|
|
1299 |
+ |
}
|
|
1300 |
+ |
|
|
1301 |
+ |
#[test]
|
|
1302 |
+ |
fn an_embedded_theme_is_reachable_by_id() {
|
|
1303 |
+ |
// The bundled tier is embedded, not on disk, so every lookup path goes
|
|
1304 |
+ |
// through `theme_source` rather than `makeover::load_theme`.
|
|
1305 |
+ |
assert!(theme_source(DEFAULT_THEME_ID).is_some());
|
|
1306 |
+ |
assert!(theme_source(DEFAULT_DARK_THEME_ID).is_some());
|
|
1307 |
+ |
assert!(theme_source("a-theme-that-does-not-exist").is_none());
|
|
1308 |
+ |
assert!(theme_preview_colors(DEFAULT_THEME_ID).is_some());
|
|
1309 |
+ |
assert!(export_theme_content(DEFAULT_THEME_ID).is_some());
|
|
1310 |
+ |
}
|
|
1311 |
+ |
|
| 1186 |
1312 |
|
#[test]
|
| 1187 |
1313 |
|
fn bundled_theme_colors_are_not_all_black() {
|
| 1188 |
1314 |
|
// Sanity check: a properly parsed theme shouldn't have all-black fields
|