| 7 |
7 |
|
use super::ApiError;
|
| 8 |
8 |
|
|
| 9 |
9 |
|
pub use makeover::{SemanticTokens, ThemeMeta};
|
|
10 |
+ |
use makeover::{ThemeDefaults, ThemeDirs, ThemeSelection, Variant};
|
| 10 |
11 |
|
|
| 11 |
|
- |
/// Returns theme directories in priority order (later overrides earlier by ID).
|
| 12 |
|
- |
/// Each entry is `(path, is_custom)`.
|
|
12 |
+ |
/// The theme search path: the user's own themes win, then whatever the app
|
|
13 |
+ |
/// bundles. Built through makeover's [`ThemeDirs`] so the precedence is stated
|
|
14 |
+ |
/// once, in the library, rather than in each app that needs it. This function
|
|
15 |
+ |
/// used to be duplicated byte-for-byte in Balanced Breakfast.
|
| 13 |
16 |
|
fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> {
|
| 14 |
|
- |
let mut dirs = Vec::new();
|
|
17 |
+ |
ThemeDirs::new()
|
|
18 |
+ |
// Bundled themes, packaged with the app in production.
|
|
19 |
+ |
.bundled(app.path().resource_dir().ok().map(|d| d.join("themes")))
|
|
20 |
+ |
// Dev fallback: the tree build.rs materialized from makeover. A `cargo
|
|
21 |
+ |
// run` has no resource dir; in production this is redundant and
|
|
22 |
+ |
// harmless, and `ThemeDirs` drops it when it does not exist.
|
|
23 |
+ |
.bundled(Some(
|
|
24 |
+ |
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"),
|
|
25 |
+ |
))
|
|
26 |
+ |
.custom(app.path().app_config_dir().ok().map(|d| d.join("themes")))
|
|
27 |
+ |
.build()
|
|
28 |
+ |
}
|
| 15 |
29 |
|
|
| 16 |
|
- |
// 1. Bundled themes (production)
|
| 17 |
|
- |
if let Ok(resource_dir) = app.path().resource_dir() {
|
| 18 |
|
- |
let bundled = resource_dir.join("themes");
|
| 19 |
|
- |
if bundled.is_dir() {
|
| 20 |
|
- |
dirs.push((bundled, false));
|
| 21 |
|
- |
}
|
| 22 |
|
- |
}
|
| 23 |
|
- |
|
| 24 |
|
- |
// 2. Dev fallback: the themes build.rs materialized from makeover. In a
|
| 25 |
|
- |
// dev run there is no bundled resource dir, so this is what supplies the
|
| 26 |
|
- |
// stock set; in production it is redundant with (1) and harmless.
|
| 27 |
|
- |
let generated = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes");
|
| 28 |
|
- |
if generated.is_dir() {
|
| 29 |
|
- |
dirs.push((generated, false));
|
| 30 |
|
- |
}
|
| 31 |
|
- |
|
| 32 |
|
- |
// 3. User custom themes (highest priority)
|
| 33 |
|
- |
if let Ok(config_dir) = app.path().app_config_dir() {
|
| 34 |
|
- |
let custom = config_dir.join("themes");
|
| 35 |
|
- |
if custom.is_dir() {
|
| 36 |
|
- |
dirs.push((custom, true));
|
| 37 |
|
- |
}
|
| 38 |
|
- |
}
|
| 39 |
|
- |
|
| 40 |
|
- |
dirs
|
|
30 |
+ |
/// The themes GoingsOn falls back to when the user is following the system
|
|
31 |
+ |
/// rather than pinning one.
|
|
32 |
+ |
///
|
|
33 |
+ |
/// App identity, which is why it stays here and not in makeover: what "the
|
|
34 |
+ |
/// light one" means is this app's answer. Everything around it (the encoding,
|
|
35 |
+ |
/// the resolution, the variant matching) is shared.
|
|
36 |
+ |
fn defaults() -> ThemeDefaults {
|
|
37 |
+ |
ThemeDefaults::new("goingson", "catppuccin-mocha")
|
| 41 |
38 |
|
}
|
| 42 |
39 |
|
|
| 43 |
40 |
|
#[tauri::command]
|
| 62 |
59 |
|
makeover::load_semantic(&dirs, &id).map_err(ApiError::internal)
|
| 63 |
60 |
|
}
|
| 64 |
61 |
|
|
|
62 |
+ |
/// Turn a stored selection into the theme id to render.
|
|
63 |
+ |
///
|
|
64 |
+ |
/// `selection` is what the user chose, verbatim from the store: `"system"` to
|
|
65 |
+ |
/// follow, or a theme id to pin. `ambient` is what the OS reports right now,
|
|
66 |
+ |
/// which only the frontend can see (`prefers-color-scheme`).
|
|
67 |
+ |
///
|
|
68 |
+ |
/// The pairing lives here rather than in the frontend, and that is the point of
|
|
69 |
+ |
/// the change. The JS used to hold `dark ? 'catppuccin-mocha' : 'goingson'`, so
|
|
70 |
+ |
/// following the system could only ever reach those two: a user who imported a
|
|
71 |
+ |
/// dark theme and set the OS to dark still got Catppuccin. makeover resolves
|
|
72 |
+ |
/// against `ThemeMeta.variant` and what is actually installed, so following now
|
|
73 |
+ |
/// reaches any theme of the right kind, and a pinned theme that has since been
|
|
74 |
+ |
/// deleted falls back instead of failing to load.
|
|
75 |
+ |
#[tauri::command]
|
|
76 |
+ |
#[instrument(skip_all)]
|
|
77 |
+ |
#[allow(
|
|
78 |
+ |
clippy::needless_pass_by_value,
|
|
79 |
+ |
reason = "Tauri command handler: AppHandle and payload args are supplied by value per the #[tauri::command] contract; command parameters cannot be borrowed"
|
|
80 |
+ |
)]
|
|
81 |
+ |
pub fn resolve_theme(app: AppHandle, selection: Option<String>, ambient: String) -> String {
|
|
82 |
+ |
let available = makeover::list_themes_from_dirs(&theme_dirs(&app));
|
|
83 |
+ |
ThemeSelection::parse(selection.as_deref()).resolve(
|
|
84 |
+ |
Variant::from(ambient.as_str()),
|
|
85 |
+ |
&defaults(),
|
|
86 |
+ |
&available,
|
|
87 |
+ |
)
|
|
88 |
+ |
}
|
|
89 |
+ |
|
| 65 |
90 |
|
#[tauri::command]
|
| 66 |
91 |
|
#[instrument(skip_all)]
|
| 67 |
92 |
|
#[allow(
|
| 107 |
132 |
|
|
| 108 |
133 |
|
#[cfg(test)]
|
| 109 |
134 |
|
mod tests {
|
| 110 |
|
- |
// Core theme logic tests live in makeover crate.
|
| 111 |
|
- |
// App-specific tests here only if needed for theme_dirs behavior.
|
|
135 |
+ |
use super::*;
|
|
136 |
+ |
|
|
137 |
+ |
// The resolver and the search-path precedence are makeover's, tested there.
|
|
138 |
+ |
// What is this app's to get right is `defaults()`: the two ids it names as
|
|
139 |
+ |
// its light/dark fallbacks have to be themes that actually ship, or
|
|
140 |
+ |
// following the system resolves to a theme that then fails to load. The
|
|
141 |
+ |
// frontend used to hardcode the same two strings, where nothing checked
|
|
142 |
+ |
// them at all.
|
|
143 |
+ |
#[test]
|
|
144 |
+ |
fn the_default_theme_ids_are_ones_the_app_ships() {
|
|
145 |
+ |
let shipped: Vec<String> =
|
|
146 |
+ |
std::fs::read_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"))
|
|
147 |
+ |
.expect("themes/ exists; build.rs materializes it")
|
|
148 |
+ |
.flatten()
|
|
149 |
+ |
.filter_map(|entry| {
|
|
150 |
+ |
let path = entry.path();
|
|
151 |
+ |
(path.extension()? == "toml")
|
|
152 |
+ |
.then(|| path.file_stem()?.to_str().map(str::to_string))
|
|
153 |
+ |
.flatten()
|
|
154 |
+ |
})
|
|
155 |
+ |
.collect();
|
|
156 |
+ |
|
|
157 |
+ |
let defaults = defaults();
|
|
158 |
+ |
for variant in [Variant::Light, Variant::Dark] {
|
|
159 |
+ |
let id = defaults.for_variant(variant);
|
|
160 |
+ |
assert!(
|
|
161 |
+ |
shipped.iter().any(|s| s == id),
|
|
162 |
+ |
"default {variant} theme `{id}` is not in themes/: {shipped:?}",
|
|
163 |
+ |
);
|
|
164 |
+ |
}
|
|
165 |
+ |
}
|
| 112 |
166 |
|
}
|