max / balanced_breakfast
6 files changed,
+177 insertions,
-58 deletions
| @@ -3102,13 +3102,13 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" | |||
| 3102 | 3102 | ||
| 3103 | 3103 | [[package]] | |
| 3104 | 3104 | name = "makeover" | |
| 3105 | - | version = "1.0.0" | |
| 3105 | + | version = "2.1.0" | |
| 3106 | 3106 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3107 | - | checksum = "15a1acf8af277255389ab1d7e53412e442983886413025c2f32f9d1dbde81d33" | |
| 3107 | + | checksum = "a26aeb770bb59143a83a7bffa1150930064b785abbe6b7e99da59697cb592032" | |
| 3108 | 3108 | dependencies = [ | |
| 3109 | 3109 | "include_dir", | |
| 3110 | 3110 | "serde", | |
| 3111 | - | "toml 0.8.2", | |
| 3111 | + | "toml 1.1.3+spec-1.1.0", | |
| 3112 | 3112 | ] | |
| 3113 | 3113 | ||
| 3114 | 3114 | [[package]] |
| @@ -53,7 +53,7 @@ open = "5" | |||
| 53 | 53 | # Utilities | |
| 54 | 54 | chrono = { version = "0.4.43", features = ["serde"] } | |
| 55 | 55 | uuid = { version = "1.20.0", features = ["v4", "serde"] } | |
| 56 | - | makeover = "1.0.0" | |
| 56 | + | makeover = "2.1.0" | |
| 57 | 57 | toml = "1.1" | |
| 58 | 58 | dirs = "6.0.0" | |
| 59 | 59 |
| @@ -612,7 +612,7 @@ BB.api.sync = { | |||
| 612 | 612 | ||
| 613 | 613 | require('../settings-sync'); | |
| 614 | 614 | ||
| 615 | - | describe('BB.sync.openSettings — renderState routing', () => { | |
| 615 | + | describe('BB.sync.openSettings: renderState routing', () => { | |
| 616 | 616 | test('not configured shows Connect button', async () => { | |
| 617 | 617 | syncStatusResult = { configured: false, authenticated: false }; | |
| 618 | 618 | const body = document.getElementById('modal-body'); |
| @@ -11,6 +11,51 @@ | |||
| 11 | 11 | let currentThemeId = null; | |
| 12 | 12 | let themeCache = {}; | |
| 13 | 13 | ||
| 14 | + | // The key every app in the family stores its theme under. It was `bb-theme` | |
| 15 | + | // here, `goingson-theme` there and `theme` in a third — three names for one | |
| 16 | + | // concept. `LEGACY_KEY` is read once so an existing install keeps its | |
| 17 | + | // choice, and never written. | |
| 18 | + | const KEY = 'theme'; | |
| 19 | + | const LEGACY_KEY = 'bb-theme'; | |
| 20 | + | ||
| 21 | + | // What "follow the system" is stored as, in every app. makeover parses and | |
| 22 | + | // resolves it; nothing here needs to know more than the string. | |
| 23 | + | const FOLLOW = 'system'; | |
| 24 | + | ||
| 25 | + | /** | |
| 26 | + | * The stored selection: 'system', a theme id, or null if never set. | |
| 27 | + | * Migrates the pre-convention key on first read. | |
| 28 | + | * @returns {Promise<string|null>} | |
| 29 | + | */ | |
| 30 | + | async function storedSelection() { | |
| 31 | + | const current = await invoke('get_config', { key: KEY }); | |
| 32 | + | if (current) return current; | |
| 33 | + | ||
| 34 | + | const legacy = await invoke('get_config', { key: LEGACY_KEY }); | |
| 35 | + | if (legacy) { | |
| 36 | + | await invoke('set_config', { key: KEY, value: legacy }); | |
| 37 | + | return legacy; | |
| 38 | + | } | |
| 39 | + | return null; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | /** The OS light/dark preference, as makeover's variant vocabulary. */ | |
| 43 | + | function ambient() { | |
| 44 | + | const dark = window.matchMedia | |
| 45 | + | && window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 46 | + | return dark ? 'dark' : 'light'; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** | |
| 50 | + | * Apply whatever the stored selection resolves to right now. | |
| 51 | + | * @returns {Promise<void>} | |
| 52 | + | */ | |
| 53 | + | async function applySelection() { | |
| 54 | + | const selection = await storedSelection(); | |
| 55 | + | const id = await invoke('resolve_theme', { selection, ambient: ambient() }); | |
| 56 | + | await applyThemeById(id); | |
| 57 | + | } | |
| 58 | + | ||
| 14 | 59 | /** | |
| 15 | 60 | * Apply a theme's resolved intent tokens as CSS variables on :root. Each | |
| 16 | 61 | * intent key becomes `--{key}` (e.g. `surface-page` -> `--surface-page`); | |
| @@ -27,11 +72,12 @@ | |||
| 27 | 72 | } | |
| 28 | 73 | ||
| 29 | 74 | /** | |
| 30 | - | * Load and apply a theme by ID. | |
| 75 | + | * Render a theme by ID. Does not record anything — see `choose`. | |
| 31 | 76 | * @param {string} themeId - Theme identifier (e.g. 'catppuccin-mocha'). | |
| 77 | + | * @param {string} [selectorValue] - What the picker should show as current. | |
| 32 | 78 | * @returns {Promise<void>} | |
| 33 | 79 | */ | |
| 34 | - | async function loadTheme(themeId) { | |
| 80 | + | async function applyThemeById(themeId, selectorValue = themeId) { | |
| 35 | 81 | let theme = themeCache[themeId]; | |
| 36 | 82 | if (!theme) { | |
| 37 | 83 | try { | |
| @@ -45,25 +91,33 @@ | |||
| 45 | 91 | ||
| 46 | 92 | applyTheme(theme.intents); | |
| 47 | 93 | currentThemeId = themeId; | |
| 48 | - | invoke('set_config', { key: 'bb-theme', value: themeId }); | |
| 49 | 94 | ||
| 50 | 95 | const selector = document.getElementById('theme-selector'); | |
| 51 | - | if (selector) selector.value = themeId; | |
| 96 | + | if (selector) selector.value = selectorValue; | |
| 52 | 97 | } | |
| 53 | 98 | ||
| 54 | 99 | /** | |
| 55 | - | * Load saved theme or default. Follows system dark/light preference if unset. | |
| 100 | + | * Record a choice and apply it. `selection` is 'system' or a theme id — what | |
| 101 | + | * the user picked, which is not the same as what gets rendered. | |
| 102 | + | * | |
| 103 | + | * Storing only the rendered id is what made the old code unable to tell a | |
| 104 | + | * standing "follow the system" from a pin: an absent value meant follow, so | |
| 105 | + | * the moment anything was saved the app stopped tracking the OS forever. | |
| 106 | + | * @param {string} selection | |
| 107 | + | * @returns {Promise<void>} | |
| 108 | + | */ | |
| 109 | + | async function choose(selection) { | |
| 110 | + | await invoke('set_config', { key: KEY, value: selection }); | |
| 111 | + | const id = await invoke('resolve_theme', { selection, ambient: ambient() }); | |
| 112 | + | await applyThemeById(id, selection); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | /** | |
| 116 | + | * Apply the stored selection, which is 'system' until told otherwise. | |
| 56 | 117 | * @returns {Promise<void>} | |
| 57 | 118 | */ | |
| 58 | 119 | async function init() { | |
| 59 | - | const saved = await invoke('get_config', { key: 'bb-theme' }); | |
| 60 | - | if (saved) { | |
| 61 | - | await loadTheme(saved); | |
| 62 | - | } else { | |
| 63 | - | // Default: follow system preference | |
| 64 | - | const dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 65 | - | await loadTheme(dark ? 'catppuccin-mocha' : 'flatwhite'); | |
| 66 | - | } | |
| 120 | + | await applySelection(); | |
| 67 | 121 | } | |
| 68 | 122 | ||
| 69 | 123 | /** | |
| @@ -81,6 +135,7 @@ | |||
| 81 | 135 | return; | |
| 82 | 136 | } | |
| 83 | 137 | ||
| 138 | + | const selection = await storedSelection() ?? FOLLOW; | |
| 84 | 139 | const light = themes.filter(t => t.variant === 'light'); | |
| 85 | 140 | const dark = themes.filter(t => t.variant === 'dark'); | |
| 86 | 141 | const highContrast = themes.filter(t => t.variant === 'high-contrast'); | |
| @@ -96,28 +151,34 @@ | |||
| 96 | 151 | const opt = document.createElement('option'); | |
| 97 | 152 | opt.value = t.id; | |
| 98 | 153 | opt.textContent = t.name; | |
| 99 | - | if (t.id === currentThemeId) opt.selected = true; | |
| 154 | + | if (t.id === selection) opt.selected = true; | |
| 100 | 155 | group.appendChild(opt); | |
| 101 | 156 | } | |
| 102 | 157 | select.appendChild(group); | |
| 103 | 158 | }; | |
| 104 | 159 | ||
| 160 | + | // First, because it is what an app nobody has configured does, and | |
| 161 | + | // because it is a choice rather than the absence of one. | |
| 162 | + | const follow = document.createElement('option'); | |
| 163 | + | follow.value = FOLLOW; | |
| 164 | + | follow.textContent = 'Follow the system'; | |
| 165 | + | if (selection === FOLLOW) follow.selected = true; | |
| 166 | + | select.appendChild(follow); | |
| 167 | + | ||
| 105 | 168 | addGroup('Light', light); | |
| 106 | 169 | addGroup('Dark', dark); | |
| 107 | 170 | if (highContrast.length > 0) addGroup('High Contrast', highContrast); | |
| 108 | 171 | ||
| 109 | - | select.addEventListener('change', () => loadTheme(select.value)); | |
| 172 | + | select.addEventListener('change', () => choose(select.value)); | |
| 110 | 173 | container.appendChild(select); | |
| 111 | 174 | } | |
| 112 | 175 | ||
| 113 | - | // Listen for system theme changes | |
| 176 | + | // Follow the system when it flips. Re-resolving rather than recomputing a | |
| 177 | + | // pair means this reaches whatever dark theme is installed, including one | |
| 178 | + | // the user imported. | |
| 114 | 179 | if (window.matchMedia) { | |
| 115 | 180 | window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', async () => { | |
| 116 | - | const saved = await invoke('get_config', { key: 'bb-theme' }); | |
| 117 | - | if (!saved) { | |
| 118 | - | const dark = window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| 119 | - | loadTheme(dark ? 'catppuccin-mocha' : 'flatwhite'); | |
| 120 | - | } | |
| 181 | + | if (await storedSelection() === FOLLOW) await applySelection(); | |
| 121 | 182 | }); | |
| 122 | 183 | } | |
| 123 | 184 | ||
| @@ -137,7 +198,9 @@ | |||
| 137 | 198 | const meta = await invoke('import_theme', { path }); | |
| 138 | 199 | themeCache = {}; // Clear cache so new theme is discoverable | |
| 139 | 200 | BB.ui.showToast(`Imported "${meta.name}"`); | |
| 140 | - | await loadTheme(meta.id); | |
| 201 | + | // Importing a theme is choosing it: pinned, not a one-off render, | |
| 202 | + | // or the next system flip would throw it away. | |
| 203 | + | await choose(meta.id); | |
| 141 | 204 | } catch (e) { | |
| 142 | 205 | BB.ui.showToast('Import failed: ' + (e.message || e), 'error'); | |
| 143 | 206 | } | |
| @@ -166,7 +229,7 @@ | |||
| 166 | 229 | ||
| 167 | 230 | BB.themes = { | |
| 168 | 231 | init, | |
| 169 | - | load: loadTheme, | |
| 232 | + | load: choose, | |
| 170 | 233 | buildSelector, | |
| 171 | 234 | importTheme, | |
| 172 | 235 | exportTheme, |
| @@ -7,37 +7,35 @@ use tracing::instrument; | |||
| 7 | 7 | use super::error::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 GoingsOn, and the Alloy console had | |
| 16 | + | /// its own copy with the tiers in the wrong order. | |
| 13 | 17 | fn theme_dirs(app: &AppHandle) -> Vec<(PathBuf, bool)> { | |
| 14 | - | let mut dirs = Vec::new(); | |
| 15 | - | ||
| 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 | - | } | |
| 18 | + | ThemeDirs::new() | |
| 19 | + | // Bundled themes, packaged with the app in production. | |
| 20 | + | .bundled(app.path().resource_dir().ok().map(|d| d.join("themes"))) | |
| 21 | + | // Dev fallback: the tree build.rs materialized from makeover. A `cargo | |
| 22 | + | // run` has no resource dir; in production this is redundant and | |
| 23 | + | // harmless, and `ThemeDirs` drops it when it does not exist. | |
| 24 | + | .bundled(Some( | |
| 25 | + | PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes"), | |
| 26 | + | )) | |
| 27 | + | .custom(app.path().app_config_dir().ok().map(|d| d.join("themes"))) | |
| 28 | + | .build() | |
| 29 | + | } | |
| 39 | 30 | ||
| 40 | - | dirs | |
| 31 | + | /// The themes Balanced Breakfast falls back to when the user is following the | |
| 32 | + | /// system rather than pinning one. | |
| 33 | + | /// | |
| 34 | + | /// App identity, which is why it stays here and not in makeover: what "the | |
| 35 | + | /// light one" means is this app's answer. Everything around it — the encoding, | |
| 36 | + | /// the resolution, the variant matching — is shared. | |
| 37 | + | fn defaults() -> ThemeDefaults { | |
| 38 | + | ThemeDefaults::new("flatwhite", "catppuccin-mocha") | |
| 41 | 39 | } | |
| 42 | 40 | ||
| 43 | 41 | #[tauri::command] | |
| @@ -62,6 +60,34 @@ pub fn get_theme(app: AppHandle, id: String) -> Result<SemanticTokens, ApiError> | |||
| 62 | 60 | makeover::load_semantic(&dirs, &id).map_err(ApiError::internal) | |
| 63 | 61 | } | |
| 64 | 62 | ||
| 63 | + | /// Turn a stored selection into the theme id to render. | |
| 64 | + | /// | |
| 65 | + | /// `selection` is what the user chose, verbatim from the store: `"system"` to | |
| 66 | + | /// follow, or a theme id to pin. `ambient` is what the OS reports right now, | |
| 67 | + | /// which only the frontend can see (`prefers-color-scheme`). | |
| 68 | + | /// | |
| 69 | + | /// The pairing lives here rather than in the frontend, and that is the point of | |
| 70 | + | /// the change. The JS used to hold `dark ? 'catppuccin-mocha' : 'flatwhite'`, | |
| 71 | + | /// so following the system could only ever reach those two — a user who | |
| 72 | + | /// imported a dark theme and set the OS to dark still got Catppuccin. makeover | |
| 73 | + | /// resolves against `ThemeMeta.variant` and what is actually installed, so | |
| 74 | + | /// following now reaches any theme of the right kind, and a pinned theme that | |
| 75 | + | /// has since been deleted falls back instead of failing to load. | |
| 76 | + | #[tauri::command] | |
| 77 | + | #[instrument(skip_all)] | |
| 78 | + | #[allow( | |
| 79 | + | clippy::needless_pass_by_value, | |
| 80 | + | reason = "Tauri command handler: the command macro injects AppHandle and deserializes String args by value" | |
| 81 | + | )] | |
| 82 | + | pub fn resolve_theme(app: AppHandle, selection: Option<String>, ambient: String) -> String { | |
| 83 | + | let available = makeover::list_themes_from_dirs(&theme_dirs(&app)); | |
| 84 | + | ThemeSelection::parse(selection.as_deref()).resolve( | |
| 85 | + | Variant::from(ambient.as_str()), | |
| 86 | + | &defaults(), | |
| 87 | + | &available, | |
| 88 | + | ) | |
| 89 | + | } | |
| 90 | + | ||
| 65 | 91 | #[tauri::command] | |
| 66 | 92 | #[instrument(skip_all)] | |
| 67 | 93 | #[allow( | |
| @@ -107,6 +133,35 @@ pub fn export_theme(app: AppHandle, id: String, path: String) -> Result<(), ApiE | |||
| 107 | 133 | ||
| 108 | 134 | #[cfg(test)] | |
| 109 | 135 | mod tests { | |
| 110 | - | // Core theme logic tests live in the makeover crate. | |
| 111 | - | // App-specific tests here only if needed for theme_dirs behavior. | |
| 136 | + | use super::*; | |
| 137 | + | ||
| 138 | + | // The resolver and the search-path precedence are makeover's, tested there. | |
| 139 | + | // What is this app's to get right is `defaults()`: the two ids it names as | |
| 140 | + | // its light/dark fallbacks have to be themes that actually ship, or | |
| 141 | + | // following the system resolves to a theme that then fails to load. The | |
| 142 | + | // frontend used to hardcode the same two strings, where nothing checked | |
| 143 | + | // them at all. | |
| 144 | + | #[test] | |
| 145 | + | fn the_default_theme_ids_are_ones_the_app_ships() { | |
| 146 | + | let shipped: Vec<String> = | |
| 147 | + | std::fs::read_dir(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("themes")) | |
| 148 | + | .expect("themes/ exists; build.rs materializes it") | |
| 149 | + | .flatten() | |
| 150 | + | .filter_map(|entry| { | |
| 151 | + | let path = entry.path(); | |
| 152 | + | (path.extension()? == "toml") | |
| 153 | + | .then(|| path.file_stem()?.to_str().map(str::to_string)) | |
| 154 | + | .flatten() | |
| 155 | + | }) | |
| 156 | + | .collect(); | |
| 157 | + | ||
| 158 | + | let defaults = defaults(); | |
| 159 | + | for variant in [Variant::Light, Variant::Dark] { | |
| 160 | + | let id = defaults.for_variant(variant); | |
| 161 | + | assert!( | |
| 162 | + | shipped.iter().any(|s| s == id), | |
| 163 | + | "default {variant} theme `{id}` is not in themes/: {shipped:?}", | |
| 164 | + | ); | |
| 165 | + | } | |
| 166 | + | } | |
| 112 | 167 | } |
| @@ -97,6 +97,7 @@ pub fn build_app() -> tauri::Builder<tauri::Wry> { | |||
| 97 | 97 | commands::list_themes, | |
| 98 | 98 | commands::get_theme, | |
| 99 | 99 | commands::get_custom_themes_dir, | |
| 100 | + | commands::resolve_theme, | |
| 100 | 101 | commands::import_theme, | |
| 101 | 102 | commands::export_theme, | |
| 102 | 103 | commands::sync_get_tiers, |