diff --git a/Cargo.lock b/Cargo.lock index 0b45984f9..08ca88a21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3082,6 +3082,7 @@ dependencies = [ "rfd", "serde_json", "winresource", + "zbus", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b3c0caa17..db854caa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,9 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) +# Linux: the desktop's dark/light setting from the XDG settings portal (Settings ▸ Interface ▸ +# Appearance Mode ▸ Auto). Pure Rust; the version accesskit already brings in, pinned to it. +zbus = "=5.19.0" web-time = "1" # web (apps/lightcraft-web) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index ba6da7254..b635442d7 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -233,6 +233,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen w.open_with_url_and_target(url, "_blank").map(|_| ()).map_err(|_| "the browser blocked the new tab".to_string()) })), pick_folder: None, + // the browser reports prefers-color-scheme to egui + system_theme: None, } } diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index 5873dce7e..345e090d1 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -37,5 +37,8 @@ winresource = "0.1" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rfd = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +zbus = { workspace = true } + [target.'cfg(target_os = "macos")'.dependencies] muda = { workspace = true } diff --git a/apps/lightcraft/src/appearance.rs b/apps/lightcraft/src/appearance.rs new file mode 100644 index 000000000..74333a301 --- /dev/null +++ b/apps/lightcraft/src/appearance.rs @@ -0,0 +1,198 @@ +//! The desktop's dark/light setting for Settings ▸ Interface ▸ Appearance Mode ▸ Auto, on Linux +//! desktops whose windowing layer reports no theme to egui (many Wayland compositors). +//! +//! The XDG settings portal is read once, then its `SettingChanged` signal is followed on a worker +//! thread that wakes the UI only when the value changes. Nothing is polled. `gsettings` is a +//! one-shot fallback at start-up, run by absolute path, only when the portal gives no answer. +//! Elsewhere egui's own report (`Context::system_theme`) is used. + +#[cfg(target_os = "linux")] +const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance"; +#[cfg(target_os = "linux")] +const PORTAL_KEY: &str = "color-scheme"; + +#[cfg(target_os = "linux")] +fn portal_proxy() -> Option> { + let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?; + zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok() +} + +#[cfg(target_os = "linux")] +fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option { + let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?; + decode(portal_code(value)?) +} + +/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest +/// another variant inside it. +#[cfg(target_os = "linux")] +fn portal_code(value: zbus::zvariant::OwnedValue) -> Option { + let mut value: zbus::zvariant::Value<'_> = value.into(); + for _ in 0..4 { + match value { + zbus::zvariant::Value::Value(inner) => value = *inner, + other => return u32::try_from(other).ok(), + } + } + None +} + +/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light. +#[cfg(target_os = "linux")] +fn decode(code: u32) -> Option { + match code { + 1 => Some(egui::Theme::Dark), + 2 => Some(egui::Theme::Light), + _ => None, + } +} + +/// One-shot fallback for desktops without the settings portal. It runs once at start-up, by +/// absolute path, and is never repeated. +#[cfg(target_os = "linux")] +fn gtk_theme() -> Option { + let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; + if !output.status.success() { + return None; + } + parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) +} + +#[cfg(target_os = "linux")] +fn parse_gtk_scheme(s: &str) -> Option { + match s.trim().trim_matches('\'') { + "prefer-dark" => Some(egui::Theme::Dark), + "prefer-light" => Some(egui::Theme::Light), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn code_of(theme: Option) -> u8 { + match theme { + Some(egui::Theme::Dark) => 1, + Some(egui::Theme::Light) => 2, + None => 0, + } +} + +/// Wake the first frame after a slow initial Read as well as subsequent real changes. +#[cfg(target_os = "linux")] +fn publish(value: &std::sync::atomic::AtomicU8, wake: &std::sync::OnceLock, code: u8) { + if value.swap(code, std::sync::atomic::Ordering::Relaxed) != code + && let Some(ctx) = wake.get() + { + ctx.request_repaint(); + } +} + +/// The host's system-appearance service (Linux), or `None` where egui's report is enough. +pub fn service() -> Option { + #[cfg(target_os = "linux")] + { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, Ordering}, + }; + let value = Arc::new(AtomicU8::new(0)); + let wake: Arc> = Arc::new(OnceLock::new()); + let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake)); + let (ready, wait) = std::sync::mpsc::channel(); + if std::thread::Builder::new() + .name("appearance-portal".into()) + .spawn(move || { + let proxy = portal_proxy(); + // Subscribe before Read so a change during startup is queued rather than lost. + let signals = proxy.as_ref().and_then(|proxy| proxy.receive_signal("SettingChanged").ok()); + let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme); + publish(&worker_value, &worker_wake, code_of(initial)); + let _ = ready.send(()); + let Some(signals) = signals else { return }; + // Blocks until the portal sends a signal: no polling. + for message in signals { + let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else { + continue; + }; + if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY { + continue; + } + let code = code_of(portal_code(changed).and_then(decode)); + publish(&worker_value, &worker_wake, code); + } + }) + .is_err() + { + return None; + } + // The first frame shows the right theme when the portal answers quickly; a slow one + // only delays the switch (the worker wakes the UI when the value changes). + let _ = wait.recv_timeout(std::time::Duration::from_millis(250)); + Some(Box::new(move |ctx| { + let _ = wake.set(ctx.clone()); + decode(u32::from(value.load(Ordering::Relaxed))) + })) + } + #[cfg(not(target_os = "linux"))] + { + None + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + #[test] + fn delayed_initial_read_repaints_only_on_change() { + use std::sync::{ + Arc, OnceLock, + atomic::{AtomicU8, AtomicUsize, Ordering}, + }; + let ctx = egui::Context::default(); + let settle = |ctx: &egui::Context| { + for _ in 0..8 { + let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); + output.textures_delta.clear(); + } + }; + settle(&ctx); + let calls = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&calls); + ctx.set_request_repaint_callback(move |_| { + observed.fetch_add(1, Ordering::Relaxed); + }); + let wake = OnceLock::new(); + assert!(wake.set(ctx.clone()).is_ok()); + let value = AtomicU8::new(0); + super::publish(&value, &wake, 2); + assert_eq!(value.load(Ordering::Relaxed), 2); + let after_change = calls.load(Ordering::Relaxed); + assert!(after_change > 0); + settle(&ctx); + let after_settle = calls.load(Ordering::Relaxed); + super::publish(&value, &wake, 2); + assert_eq!(calls.load(Ordering::Relaxed), after_settle); + } + + #[cfg(target_os = "linux")] + #[test] + fn portal_values_and_gsettings_output_decode() { + assert_eq!(super::decode(0), None); + assert_eq!(super::decode(1), Some(egui::Theme::Dark)); + assert_eq!(super::decode(2), Some(egui::Theme::Light)); + assert_eq!(super::decode(9), None); + let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2))))); + assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2)); + let plain = zbus::zvariant::OwnedValue::from(1u32); + assert_eq!(super::portal_code(plain), Some(1)); + let wrong = zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap(); + assert_eq!(super::portal_code(wrong), None); + assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light)); + assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark)); + assert_eq!(super::parse_gtk_scheme("'default'\n"), None); + assert_eq!(super::parse_gtk_scheme(""), None); + for theme in [None, Some(egui::Theme::Dark), Some(egui::Theme::Light)] { + assert_eq!(super::decode(u32::from(super::code_of(theme))), theme); + } + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 42435e39b..49cde2da2 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -23,6 +23,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod alloc_release; +mod appearance; mod control_server; mod dialog_filter; mod editors; @@ -579,6 +580,7 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> // the library is a folder on disk: backed up with the user's other files backup_library: None, restore_library: None, + system_theme: appearance::service(), } } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c9a788bd2..c1854810c 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2030,6 +2030,19 @@ "Not saved": "Nicht gespeichert", "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", + "Appearance": "Erscheinungsbild", + "Appearance Mode": "Erscheinungsmodus", + "Sync with System": "Mit System synchronisieren", + "Light Mode": "Heller Modus", + "Dark Mode": "Dunkler Modus", + "Next Appearance Mode": "Nächster Erscheinungsmodus", + "Light Theme": "Helles Design", + "Dark Theme": "Dunkles Design", + "In Use": "In Verwendung", + "Charcoal": "Anthrazit", + "Midnight": "Mitternacht", + "Silver": "Silber", + "Paper": "Papier", "Show less": "Weniger anzeigen", "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", "Exporting contact sheet": "Kontaktabzug wird exportiert", @@ -2080,5 +2093,8 @@ "Move to Folder": "In Ordner verschieben", "Move to Folder…": "In Ordner verschieben…", "Move Selected Photos Here": "Ausgewählte Fotos hierher verschieben", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben" + "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben", + "Appearance Settings…": "Erscheinungsbild-Einstellungen…", + "Add to favorites": "Zu Favoriten hinzufügen", + "Remove from favorites": "Aus Favoriten entfernen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index fd9c74647..792ca4d02 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1875,6 +1875,19 @@ "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", "Wait for the import to finish": "Espera a que termine la importación", + "Appearance": "Apariencia", + "Appearance Mode": "Modo de apariencia", + "Sync with System": "Sincronizar con el sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo oscuro", + "Next Appearance Mode": "Siguiente modo de apariencia", + "Light Theme": "Tema claro", + "Dark Theme": "Tema oscuro", + "In Use": "En uso", + "Charcoal": "Carbón", + "Midnight": "Medianoche", + "Silver": "Plata", + "Paper": "Papel", "Show less": "Mostrar menos", "Quit while tasks are running?": "¿Salir con tareas en curso?", "Exporting contact sheet": "Exportando hoja de contactos", @@ -1922,5 +1935,8 @@ "Move to Folder": "Mover a carpeta", "Move to Folder…": "Mover a carpeta…", "Move Selected Photos Here": "Mover aquí las fotos seleccionadas", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe" + "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe", + "Appearance Settings…": "Ajustes de apariencia…", + "Add to favorites": "Añadir a favoritos", + "Remove from favorites": "Quitar de favoritos" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 014c340eb..2055d4385 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1922,5 +1922,8 @@ "Move to Folder": "Déplacer vers un dossier", "Move to Folder…": "Déplacer vers un dossier…", "Move Selected Photos Here": "Déplacer ici les photos sélectionnées", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Déplace les fichiers et leurs fichiers annexes XMP dans ce dossier ; n'écrase jamais rien" + "Moves the files and their XMP sidecars into this folder; never overwrites": "Déplace les fichiers et leurs fichiers annexes XMP dans ce dossier ; n'écrase jamais rien", + "Appearance Settings…": "Réglages d’apparence…", + "Add to favorites": "Ajouter aux favoris", + "Remove from favorites": "Retirer des favoris" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 39206b6e0..f38a87708 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1877,6 +1877,19 @@ "Not saved": "保存されていません", "A folder is being synchronized": "フォルダを同期中です", "Wait for the import to finish": "読み込みが終わるまでお待ちください", + "Appearance": "外観", + "Appearance Mode": "外観モード", + "Sync with System": "システムと同期", + "Light Mode": "ライトモード", + "Dark Mode": "ダークモード", + "Next Appearance Mode": "次の外観モード", + "Light Theme": "ライトテーマ", + "Dark Theme": "ダークテーマ", + "In Use": "使用中", + "Charcoal": "チャコール", + "Midnight": "ミッドナイト", + "Silver": "シルバー", + "Paper": "ペーパー", "Show less": "表示を減らす", "Quit while tasks are running?": "タスクの実行中に終了しますか?", "Exporting contact sheet": "コンタクトシートを書き出し中", @@ -1924,5 +1937,8 @@ "Move to Folder": "フォルダへ移動", "Move to Folder…": "フォルダへ移動…", "Move Selected Photos Here": "選択した写真をここへ移動", - "Moves the files and their XMP sidecars into this folder; never overwrites": "ファイルと XMP サイドカーをこのフォルダへ移動します。上書きはしません" + "Moves the files and their XMP sidecars into this folder; never overwrites": "ファイルと XMP サイドカーをこのフォルダへ移動します。上書きはしません", + "Appearance Settings…": "外観設定…", + "Add to favorites": "お気に入りに追加", + "Remove from favorites": "お気に入りから削除" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f27bd0e36..5ab30b05f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1875,6 +1875,19 @@ "Not saved": "Não salvo", "A folder is being synchronized": "Uma pasta está sendo sincronizada", "Wait for the import to finish": "Aguarde a importação terminar", + "Appearance": "Aparência", + "Appearance Mode": "Modo de aparência", + "Sync with System": "Sincronizar com o sistema", + "Light Mode": "Modo claro", + "Dark Mode": "Modo escuro", + "Next Appearance Mode": "Próximo modo de aparência", + "Light Theme": "Tema claro", + "Dark Theme": "Tema escuro", + "In Use": "Em uso", + "Charcoal": "Carvão", + "Midnight": "Meia-noite", + "Silver": "Prata", + "Paper": "Papel", "Show less": "Mostrar menos", "Quit while tasks are running?": "Sair com tarefas em andamento?", "Exporting contact sheet": "Exportando folha de contato", @@ -1922,5 +1935,8 @@ "Move to Folder": "Mover para pasta", "Move to Folder…": "Mover para pasta…", "Move Selected Photos Here": "Mover as fotos selecionadas para cá", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Move os arquivos e seus arquivos XMP auxiliares para esta pasta; nunca sobrescreve" + "Moves the files and their XMP sidecars into this folder; never overwrites": "Move os arquivos e seus arquivos XMP auxiliares para esta pasta; nunca sobrescreve", + "Appearance Settings…": "Configurações de aparência…", + "Add to favorites": "Adicionar aos favoritos", + "Remove from favorites": "Remover dos favoritos" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 2177adbeb..d777309c9 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1588,6 +1588,19 @@ "Not saved": "Не сохранено", "A folder is being synchronized": "Идёт синхронизация папки", "Wait for the import to finish": "Дождитесь окончания импорта", + "Appearance": "Оформление", + "Appearance Mode": "Режим оформления", + "Sync with System": "Как в системе", + "Light Mode": "Светлый режим", + "Dark Mode": "Тёмный режим", + "Next Appearance Mode": "Следующий режим оформления", + "Light Theme": "Светлая тема", + "Dark Theme": "Тёмная тема", + "In Use": "Используется", + "Charcoal": "Графит", + "Midnight": "Полночь", + "Silver": "Серебро", + "Paper": "Бумага", "Show less": "Свернуть", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", "Exporting contact sheet": "Экспорт контрольного листа", @@ -1636,5 +1649,8 @@ "Move to Folder": "Переместить в папку", "Move to Folder…": "Переместить в папку…", "Move Selected Photos Here": "Переместить выбранные фото сюда", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Перемещает файлы и их XMP-файлы в эту папку; ничего не перезаписывает" + "Moves the files and their XMP sidecars into this folder; never overwrites": "Перемещает файлы и их XMP-файлы в эту папку; ничего не перезаписывает", + "Appearance Settings…": "Настройки оформления…", + "Add to favorites": "Добавить в избранное", + "Remove from favorites": "Удалить из избранного" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index c1e559e42..7d6237d27 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2116,5 +2116,8 @@ "Move to Folder": "Перемістити до теки", "Move to Folder…": "Перемістити до теки…", "Move Selected Photos Here": "Перемістити вибрані фото сюди", - "Moves the files and their XMP sidecars into this folder; never overwrites": "Переміщує файли та їхні XMP-файли до цієї теки; нічого не перезаписує" + "Moves the files and their XMP sidecars into this folder; never overwrites": "Переміщує файли та їхні XMP-файли до цієї теки; нічого не перезаписує", + "Appearance Settings…": "Налаштування вигляду…", + "Add to favorites": "Додати до вибраного", + "Remove from favorites": "Видалити з вибраного" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index c28b4eee5..4226c22a1 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1875,6 +1875,19 @@ "Not saved": "未保存", "A folder is being synchronized": "正在同步文件夹", "Wait for the import to finish": "请等待导入完成", + "Appearance": "外观", + "Appearance Mode": "外观模式", + "Sync with System": "与系统同步", + "Light Mode": "浅色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一个外观模式", + "Light Theme": "浅色主题", + "Dark Theme": "深色主题", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "银色", + "Paper": "纸白", "Show less": "收起", "Quit while tasks are running?": "要在任务运行时退出吗?", "Exporting contact sheet": "正在导出联系表", @@ -1922,5 +1935,8 @@ "Move to Folder": "移动到文件夹", "Move to Folder…": "移动到文件夹…", "Move Selected Photos Here": "将所选照片移到此处", - "Moves the files and their XMP sidecars into this folder; never overwrites": "将文件及其 XMP 附属文件移到此文件夹;绝不覆盖" + "Moves the files and their XMP sidecars into this folder; never overwrites": "将文件及其 XMP 附属文件移到此文件夹;绝不覆盖", + "Appearance Settings…": "外观设置…", + "Add to favorites": "添加到收藏", + "Remove from favorites": "从收藏中移除" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index fba96a088..717c1449c 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1875,6 +1875,19 @@ "Not saved": "未儲存", "A folder is being synchronized": "正在同步資料夾", "Wait for the import to finish": "請等待匯入完成", + "Appearance": "外觀", + "Appearance Mode": "外觀模式", + "Sync with System": "與系統同步", + "Light Mode": "淺色模式", + "Dark Mode": "深色模式", + "Next Appearance Mode": "下一個外觀模式", + "Light Theme": "淺色主題", + "Dark Theme": "深色主題", + "In Use": "使用中", + "Charcoal": "炭灰", + "Midnight": "午夜", + "Silver": "銀色", + "Paper": "紙白", "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", "Exporting contact sheet": "正在匯出聯絡表", @@ -1922,5 +1935,8 @@ "Move to Folder": "移至資料夾", "Move to Folder…": "移至資料夾…", "Move Selected Photos Here": "將所選照片移至此處", - "Moves the files and their XMP sidecars into this folder; never overwrites": "將檔案及其 XMP 附屬檔案移至此資料夾;絕不覆寫" + "Moves the files and their XMP sidecars into this folder; never overwrites": "將檔案及其 XMP 附屬檔案移至此資料夾;絕不覆寫", + "Appearance Settings…": "外觀設定…", + "Add to favorites": "加入最愛", + "Remove from favorites": "從最愛移除" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 7e30d3ca9..a317069e0 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3238,10 +3238,21 @@ mod tests { assert_eq!(h.app.ui.settings.startup_view, crate::state::StartupView::Detail); // Interface: filmstrip names off, grid badges always h.request("ui.clickWidget", json!({"id": "button:settingsTab-interface"}), t); + // Appearance cards (one row per theme, with its favorite star) sit above these controls; + // scroll the compact settings viewport past them. + h.request("ui.hoverWidget", json!({"id": "radio:theme-charcoal"}), t); + h.request("ui.scroll", json!({"dy": -700.0}), t); + for _ in 0..4 { + h.step(); + } h.request("ui.clickWidget", json!({"id": "check:settings.filmNames"}), t); assert!(!h.app.ui.settings.film_names); h.request("ui.clickWidget", json!({"id": "button:settingsGridBadges-1"}), t); assert_eq!(h.app.ui.settings.grid_badges, crate::state::GridBadges::Always); + h.request("ui.scroll", json!({"dy": 700.0}), t); + for _ in 0..4 { + h.step(); + } // Performance: the thumbnail cache size goes to the library preferences h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), t); h.request("ui.clickWidget", json!({"id": "button:settingsCache-0"}), t); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 0cf998e0f..3441f109e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,12 @@ pub enum Icon { Chat, /// Face boxes on/off: four corner brackets round a small face. FaceBox, + /// Appearance Mode ▸ Auto (follow the system): a monitor on its stand. + Monitor, + /// Appearance Mode ▸ Light: a sun (a disc with eight rays). + Sun, + /// Appearance Mode ▸ Dark: a crescent moon. + Moon, } struct Pen<'a> { @@ -178,6 +184,24 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.line(&[(4.0, 10.0), (16.0, 10.0)]); pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); } + Monitor => { + pen.rect(2.5, 3.5, 17.5, 13.5, 1.5); + pen.line(&[(10.0, 13.5), (10.0, 16.5)]); + pen.line(&[(6.5, 16.5), (13.5, 16.5)]); + } + Sun => { + pen.circle(10.0, 10.0, 3.2); + for i in 0..8 { + let a = (i as f32 * 45.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + pen.line(&[(10.0 + 5.4 * c, 10.0 + 5.4 * s), (10.0 + 7.4 * c, 10.0 + 7.4 * s)]); + } + } + Moon => { + // outer arc of the disc, then the inner arc of the bite, meeting at the horns + pen.arc(10.0, 10.0, 6.5, 2.9, 263.8); + pen.arc(13.4, 6.4, 5.0, 214.9, 51.8); + } Search => { pen.circle(8.5, 8.5, 5.0); pen.line(&[(12.2, 12.2), (16.5, 16.5)]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 299728743..57630067f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,6 +49,8 @@ mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; #[cfg(test)] +mod tests_appearance; +#[cfg(test)] mod tests_crop_rotate; #[cfg(test)] mod tests_crop_tool; @@ -147,6 +149,11 @@ pub type CheckEditorFn = Box Result<(), String>>; /// storage); the work may finish asynchronously. pub type HostAction = Box Result>; +/// The system's dark/light appearance where egui doesn't report one (Linux desktops whose +/// windowing layer says nothing, e.g. many Wayland compositors). The host answers from what it +/// last heard and wakes the UI itself when that changes; it is never polled for changes. +pub type SystemThemeFn = Box Option>; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { @@ -203,6 +210,8 @@ pub struct Services { pub backup_library: Option, /// File ▸ Restore Library from Backup… (web only; keeps the current library). pub restore_library: Option, + /// The system appearance for Appearance Mode ▸ Auto, ahead of egui's own report. + pub system_theme: Option, } #[derive(Clone, Copy, Debug, Default)] @@ -774,6 +783,20 @@ impl LightcraftApp { } } + /// The system's appearance: the host's answer first, then egui's (none: unknown). + pub fn system_theme(&self, ctx: &egui::Context) -> Option { + self.services.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()) + } + + /// Restyle `ctx` when the theme the settings select (Appearance Mode, the dark and light + /// themes, the system's appearance in Auto) is not the one it shows. A no-op otherwise. + pub fn sync_theme(&mut self, ctx: &egui::Context) { + let wanted = self.ui.settings.theme(self.system_theme(ctx)); + if theme::current(ctx) != Some(wanted) { + theme::apply_kind(ctx, wanted); + } + } + /// Draw the UI into an offscreen context at the window's size; with `capture`, rasterize it /// on the CPU (photo textures come from the renderer's CPU copies). pub fn headless_screenshot(&mut self, main: &egui::Context, capture: bool) -> Option { @@ -877,6 +900,7 @@ impl LightcraftApp { } else { self.fonts_ready = true; } + self.sync_theme(ctx); panels::notices::logic(self); // closing the window (or Quit) with changes only in memory: retry, else ask first if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index 855babdf5..8a0cede2d 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -135,6 +135,7 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.histogram", "view.navigator", "view.infoOverlay", + "@Appearance", "---", "view.fullScreenPreview", "view.lightsOut", @@ -467,6 +468,29 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v.push(item("dialog.labelNames", Value::Null, "Edit Label Names…", None, true, None)); v } + "Appearance" => { + use crate::state::AppearanceMode; + use crate::theme::ThemeKind; + let s = &app.ui.settings; + let mut v: Vec = [ + ("auto", "Sync with System", AppearanceMode::Auto), + ("light", "Light Mode", AppearanceMode::Light), + ("dark", "Dark Mode", AppearanceMode::Dark), + ] + .into_iter() + .map(|(mode, label, m)| item("view.appearance", json!({"mode": mode}), label, None, true, Some(s.appearance_mode == m))) + .collect(); + v.push(item("view.appearance", Value::Null, "Next Appearance Mode", None, true, None)); + // the saved theme of each family is checked + for group in [[ThemeKind::Charcoal, ThemeKind::Midnight], [ThemeKind::Silver, ThemeKind::Paper]] { + v.push(MenuNode::Separator); + for k in group { + let on = if k.is_dark() { s.dark_theme.kind() == k } else { s.light_theme.kind() == k }; + v.push(item("view.theme", json!({"theme": k.id()}), k.label(), None, true, Some(on))); + } + } + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ea027ef1c..8ce9f20be 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -145,6 +145,11 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("tool.keywordPainter", "Keyword Painter", None, ""), ("view.gridInfo", "Grid Info", None, ""), + // {mode?: auto | light | dark}: Appearance Mode (cycles Auto, Light, Dark when omitted; the + // top bar's appearance button). View ▸ Appearance lists the modes. + ("view.appearance", "Next Appearance Mode", None, ""), + // {theme: charcoal | midnight | silver | paper}: pick a theme and fix the mode to its family. + ("view.theme", "Theme", None, ""), ("dialog.allMetadata", "All Metadata…", None, "Photo"), ("dialog.faceModel", "Add Face Model…", None, ""), ("dialog.newSmartAlbum", "New Smart Album from Filter…", Some("Cmd+Alt+N"), "File"), @@ -272,6 +277,41 @@ pub fn parse_rgb(v: &Value) -> Option<[u8; 3]> { Some([c(0)?, c(1)?, c(2)?]) } +fn appearance_json(app: &LightcraftApp) -> Value { + let s = &app.ui.settings; + json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) +} + +/// `view.appearance {mode?}`: set Appearance Mode, or step to the next one (Auto, Light, Dark). +/// The theme follows on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance_command(app: &mut LightcraftApp, p: &Value) -> Result { + use crate::state::AppearanceMode; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.settings.appearance_mode.next(), + Some(v) => match v.as_str() { + Some("auto") => AppearanceMode::Auto, + Some("light") => AppearanceMode::Light, + Some("dark") => AppearanceMode::Dark, + _ => return Err(format!("view.appearance: unknown mode {v} (auto|light|dark)")), + }, + }; + app.ui.settings.appearance_mode = mode; + Ok(appearance_json(app)) +} + +/// `view.theme {theme}`: pick a theme; the mode becomes its family's (Dark or Light). +fn theme_command(app: &mut LightcraftApp, p: &Value) -> Result { + let ids = crate::theme::ThemeKind::ALL.map(|k| k.id()).join("|"); + let Some(id) = p.get("theme").and_then(Value::as_str) else { + return Err(format!("view.theme: `theme` is required ({ids})")); + }; + let Some(kind) = crate::theme::ThemeKind::ALL.into_iter().find(|k| k.id() == id) else { + return Err(format!("view.theme: unknown theme `{id}` ({ids})")); + }; + app.ui.settings.select_theme(kind); + Ok(appearance_json(app)) +} + /// Handle UI commands; `None` means "not a UI command — send it to the engine". pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option> { if matches!(id, "ui.dock" | "ui.dock.reset") { @@ -335,6 +375,8 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option appearance_command(app, p), + "view.theme" => theme_command(app, p), "view.gridToggle" => { app.ui.view = if app.ui.view == ViewMode::PhotoGrid { ViewMode::SquareGrid } else { ViewMode::PhotoGrid }; Ok(Value::Null) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 661224af2..26684fdec 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -171,8 +171,14 @@ pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", " /// Help ▸ What's New (docs/whats-new.md). pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); +/// The import review and Settings keep window ids of their own, so the size each is given +/// doesn't carry over to the other dialogs. fn window_id(dialog: &Dialog) -> egui::Id { - egui::Id::new(if matches!(dialog, Dialog::Import { .. }) { "lightcraft-import-dialog" } else { "lightcraft-dialog" }) + egui::Id::new(match dialog { + Dialog::Import { .. } => "lightcraft-import-dialog", + Dialog::Settings { .. } => "lightcraft-settings-dialog", + _ => "lightcraft-dialog", + }) } /// Block the underlying panels from the first frame, before the window establishes its modal diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 86da2cc47..7baf475ad 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -36,9 +36,8 @@ const LABEL_W: f32 = 150.0; /// The dialog body for `tab` (the tab bar switches `tab`). pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { let t = Tokens::get(ui.ctx()); - ui.set_min_width(560.0); - ui.set_min_height(330.0); - ui.horizontal(|ui| { + ui.set_width((ui.ctx().content_rect().width() - 64.0).clamp(240.0, 560.0)); + ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 4.0; for (id, label) in TABS { if crate::widgets::text_button(ui, &format!("settingsTab-{id}"), label, tab == id).clicked() { @@ -632,6 +631,8 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let system = app.system_theme(ui.ctx()); + appearance(ui, t, &mut app.ui.settings, system); heading(ui, t, crate::i18n::tr("Filmstrip")); check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); check(ui, "settings.filmBadges", &mut app.ui.settings.film_badges, "Show ratings, flags and edit badges"); @@ -653,6 +654,229 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +/// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and +/// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in +/// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). +fn appearance(ui: &mut egui::Ui, t: &Tokens, s: &mut crate::state::AppSettings, system: Option) { + use crate::state::{AppearanceMode as M, DarkTheme, LightTheme}; + heading(ui, t, crate::i18n::tr("Appearance")); + ui.label(RichText::new(crate::i18n::tr("Appearance Mode")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsAppearance", &[(M::Auto, "Sync with System"), (M::Dark, "Dark Mode"), (M::Light, "Light Mode")], &mut s.appearance_mode); + }); + ui.add_space(4.0); + let light_active = s.theme(system) == s.light_theme.kind(); + let available = ui.available_width(); + let mut cards = |ui: &mut egui::Ui, width: f32| { + ui.spacing_mut().item_spacing.x = 12.0; + let light: Vec<_> = LightTheme::ALL.iter().map(|l| (*l, l.kind())).collect(); + theme_card(ui, t, "Light Theme", light_active, &light, &mut s.light_theme, &mut s.favorite_themes, width); + let dark: Vec<_> = DarkTheme::ALL.iter().map(|d| (*d, d.kind())).collect(); + theme_card(ui, t, "Dark Theme", !light_active, &dark, &mut s.dark_theme, &mut s.favorite_themes, width); + }; + if available < 440.0 { + ui.vertical(|ui| cards(ui, available)); + } else { + ui.horizontal_top(|ui| cards(ui, ((available - 12.0) / 2.0).floor())); + } + ui.add_space(2.0); +} + +/// One family's card: its title (and "Active" when it is the one shown), a preview of the +/// selected theme and a radio button per theme. +fn theme_card( + ui: &mut egui::Ui, + t: &Tokens, + title: &str, + active: bool, + options: &[(V, crate::theme::ThemeKind)], + value: &mut V, + favorites: &mut Vec, + width: f32, +) { + egui::Frame::new() + .fill(t.inset) + .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border })) + .corner_radius(6) + .inner_margin(8) + .show(ui, |ui| { + ui.vertical(|ui| { + let inner = (width - 18.0).clamp(120.0, 240.0); + ui.set_width(inner); + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(title)).font(t.semibold(12.0)).color(t.text)); + if active { + ui.label(RichText::new(crate::i18n::tr("In Use")).size(11.0).color(t.accent)); + } + }); + let shown = options.iter().find(|(v, _)| v == value).or(options.first()).map(|(_, k)| *k).unwrap_or_default(); + theme_preview(ui, shown, inner); + for (v, kind) in options { + let row = egui::Rect::from_min_size(ui.cursor().min, egui::vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|p| row.contains(p)); + ui.horizontal(|ui| { + let r = ui.radio_value(value, *v, crate::i18n::tr(kind.label())); + register(ui.ctx(), format!("radio:theme-{}", kind.id()), r.rect); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let favorite = favorites.iter().any(|id| id == kind.id()); + let r = ui + .add_visible(favorite || hovered, egui::Button::new("").min_size(egui::vec2(24.0, 24.0)).frame(false)) + .on_hover_text(crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" })); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + true, + format!( + "{}: {}", + crate::i18n::tr(if favorite { "Remove from favorites" } else { "Add to favorites" }), + crate::i18n::tr(kind.label()) + ), + ) + }); + register(ui.ctx(), format!("button:themeFavorite-{}", kind.id()), r.rect); + if favorite || hovered { + crate::icons::paint( + ui.painter(), + r.rect.shrink(4.0), + if favorite { crate::icons::Icon::StarFilled } else { crate::icons::Icon::Star }, + if favorite { t.accent } else { t.text_dim }, + ); + } + if r.clicked() { + if favorite { + favorites.retain(|id| id != kind.id()); + } else { + favorites.push(kind.id().into()); + } + } + }); + }); + } + }); + }); +} + +/// A small LightCraft window painted with `kind`'s tokens: the top bar with its search field, the +/// photo with the filmstrip under it, the Edit panel (histogram and sliders), the tool strip and +/// the bottom bar. +fn theme_preview(ui: &mut egui::Ui, kind: crate::theme::ThemeKind, width: f32) { + use egui::{Color32, Rect, Stroke, StrokeKind, pos2, vec2}; + let p = Tokens::for_kind(kind); + let (rect, _) = ui.allocate_exact_size(vec2(width, (width * 0.56).round().min(130.0)), egui::Sense::hover()); + let painter = ui.painter_at(rect); + let bar = |y: f32, x: f32, w: f32, c: Color32| painter.rect_filled(Rect::from_min_size(pos2(x, y), vec2(w, 2.0)), 1.0, c); + painter.rect_filled(rect, 3.0, p.canvas); + // top bar: sidebar and back icons, the centred search field, icons on the right + let top = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0)); + painter.rect_filled(top, 0.0, p.chrome); + for x in [6.0, 14.0, 20.0] { + painter.rect_filled(Rect::from_min_size(top.min + vec2(x, 4.5), vec2(4.0, 4.0)), 1.0, p.icon); + } + let search = Rect::from_center_size(top.center(), vec2(rect.width() * 0.34, 8.0)); + painter.rect(search, 2.0, p.field, Stroke::new(1.0, p.field_border), StrokeKind::Inside); + bar(search.center().y - 1.0, search.center().x - 9.0, 18.0, p.text_dim); + for i in 0..4 { + painter.rect_filled(Rect::from_min_size(pos2(top.right() - 10.0 - i as f32 * 9.0, top.top() + 4.5), vec2(4.0, 4.0)), 2.0, p.icon); + } + // bottom bar: view buttons, rating stars, a button + let bottom = Rect::from_min_max(pos2(rect.left(), rect.bottom() - 12.0), rect.max); + painter.rect_filled(bottom, 0.0, p.chrome); + for x in [6.0, 13.0, 20.0] { + painter.rect_filled(Rect::from_min_size(bottom.min + vec2(x, 4.0), vec2(4.0, 4.0)), 1.0, p.icon); + } + let stars_x = bottom.center().x - 30.0; + for i in 0..5 { + painter.circle_filled(pos2(stars_x + i as f32 * 5.0, bottom.center().y), 1.4, p.star); + } + let copy = Rect::from_min_size(pos2(stars_x + 28.0, bottom.top() + 2.5), vec2(30.0, 7.0)); + painter.rect(copy, 3.0, p.button, Stroke::new(1.0, p.button_border), StrokeKind::Inside); + // tool strip on the right edge, the first tool (Edit) active + let strip = Rect::from_min_max(pos2(rect.right() - 12.0, top.bottom()), pos2(rect.right(), bottom.top())); + painter.rect_filled(strip, 0.0, p.chrome); + painter.line_segment([strip.left_top(), strip.left_bottom()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let r = Rect::from_min_size(pos2(strip.left() + 3.0, strip.top() + 4.0 + i as f32 * 9.0), vec2(6.0, 6.0)); + if i == 0 { + painter.rect_filled(r.expand(1.5), 1.5, p.tool_active); + } + painter.rect_stroke(r, 1.0, Stroke::new(1.0, if i == 0 { p.text } else { p.icon }), StrokeKind::Inside); + } + // Edit panel: histogram, then labelled sliders + let panel = Rect::from_min_max(pos2(strip.left() - (rect.width() * 0.27).round(), top.bottom()), pos2(strip.left(), bottom.top())); + painter.rect_filled(panel, 0.0, p.chrome); + painter.line_segment([panel.left_top(), panel.left_bottom()], Stroke::new(1.0, p.divider)); + let hist = Rect::from_min_size(panel.min + vec2(4.0, 4.0), vec2(panel.width() - 8.0, 14.0)); + painter.rect_filled(hist, 1.0, p.inset); + let curve = [0.2, 0.55, 0.9, 0.7, 0.5, 0.62, 0.4, 0.3, 0.18, 0.1]; + let mut pts = vec![hist.left_bottom()]; + for (i, h) in curve.iter().enumerate() { + pts.push(pos2(hist.left() + hist.width() * i as f32 / (curve.len() - 1) as f32, hist.bottom() - hist.height() * h)); + } + pts.push(hist.right_bottom()); + // the area under the curve, a column per stretch between two points + for pair in pts.windows(2) { + if let [a, b] = pair { + let r = Rect::from_min_max(pos2(a.x, a.y.max(b.y)), pos2(b.x, hist.bottom())); + painter.rect_filled(r, 0.0, p.track.gamma_multiply(0.5)); + } + } + painter.add(egui::Shape::line(pts, Stroke::new(1.0, p.text_dim))); + let sliders = [0.55, 0.62, 0.35, 0.7, 0.6, 0.45]; + let mut y = hist.bottom() + 7.0; + bar(y, panel.left() + 5.0, 14.0, p.text); + y += 6.0; + for v in sliders { + if y + 7.0 > panel.bottom() { + break; + } + bar(y, panel.left() + 5.0, 12.0, p.text_label); + bar(y, panel.right() - 10.0, 5.0, p.text_dim); + let track_y = y + 5.0; + painter.line_segment([pos2(panel.left() + 5.0, track_y), pos2(panel.right() - 5.0, track_y)], Stroke::new(1.0, p.track)); + painter.circle_filled(pos2(panel.left() + 5.0 + (panel.width() - 10.0) * v, track_y), 1.8, p.thumb); + y += 10.0; + } + // the photo (its own colours: a photo looks the same in every theme) above the filmstrip + let film = Rect::from_min_max(pos2(rect.left(), bottom.top() - 18.0), pos2(panel.left(), bottom.top())); + let stage = Rect::from_min_max(pos2(rect.left(), top.bottom()), pos2(panel.left(), film.top())); + let photo = Rect::from_center_size(stage.center(), vec2(stage.width() * 0.62, stage.height() - 8.0)); + painter.rect_filled(photo, 0.0, Color32::from_rgb(0x6f, 0x8f, 0xb4)); + let horizon = photo.top() + photo.height() * 0.62; + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left(), horizon), + pos2(photo.left() + photo.width() * 0.3, photo.top() + photo.height() * 0.3), + pos2(photo.left() + photo.width() * 0.55, horizon), + ], + Color32::from_rgb(0x4a, 0x55, 0x66), + Stroke::NONE, + )); + painter.add(egui::Shape::convex_polygon( + vec![ + pos2(photo.left() + photo.width() * 0.35, horizon), + pos2(photo.left() + photo.width() * 0.68, photo.top() + photo.height() * 0.2), + pos2(photo.right(), horizon), + ], + Color32::from_rgb(0x5a, 0x63, 0x72), + Stroke::NONE, + )); + painter.rect_filled(Rect::from_min_max(pos2(photo.left(), horizon), photo.max), 0.0, Color32::from_rgb(0x3d, 0x5a, 0x3a)); + painter.line_segment([film.left_top(), film.right_top()], Stroke::new(1.0, p.divider)); + for i in 0..5 { + let cell = Rect::from_min_size(pos2(film.left() + 4.0 + i as f32 * 17.0, film.top() + 3.0), vec2(15.0, 12.0)); + if cell.right() > film.right() - 2.0 { + break; + } + let selected = i == 1; + painter.rect_filled(cell, 0.0, if selected { p.cell_selected } else { p.cell }); + let thumb = cell.shrink(2.5); + painter.rect_filled(thumb, 0.0, Color32::from_rgb(0x6f + 8 * i as u8, 0x80, 0x8f)); + if selected { + painter.rect_stroke(thumb, 0.0, Stroke::new(1.0, p.pick), StrokeKind::Outside); + } + } +} + // ------------------------------------------------------------------------------- Open Library… /// `app.openLibrary {path?}`: close the current library and open (or create) the one at `path`, diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 6462c87d6..a95805f83 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -53,7 +53,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.integrated_titlebar { window_handle(app, ui, full, left as f32, 12.0); } - let mut sw = 640.0f32.min(full.width() - 460.0).max(200.0); + // room for the filter icon and the six icons on the right + let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; @@ -187,6 +188,50 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } x -= 40.0; } + // Appearance Mode: a click steps Auto → Light → Dark → Auto; the icon shows the mode + let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); + let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); + let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + register(ui.ctx(), "icon:appearance", r); + paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); + if resp.clicked() { + let _ = app.run("view.appearance", json!({})); + } + resp.context_menu(|ui| { + let settings = ui.button(crate::i18n::tr("Appearance Settings…")); + register(ui.ctx(), "menu:appearanceSettings", settings.rect); + if settings.clicked() { + let _ = app.run("app.settings", json!({"tab": "interface"})); + ui.close(); + } + let favorites = app.ui.settings.favorite_themes.clone(); + for dark in [false, true] { + let group: Vec<_> = + crate::theme::ThemeKind::ALL.iter().filter(|k| k.is_dark() == dark && favorites.iter().any(|id| id == k.id())).collect(); + if !group.is_empty() { + ui.separator(); + } + for kind in group { + let r = ui.selectable_label(app.ui.settings.theme(app.system_theme(ui.ctx())) == *kind, crate::i18n::tr(kind.label())); + register(ui.ctx(), format!("menu:themeFavorite-{}", kind.id()), r.rect); + if r.clicked() { + let _ = app.run("view.theme", json!({"theme": kind.id()})); + ui.close(); + } + } + } + }); let _ = Align2::CENTER_CENTER; }); } + +/// The appearance button's icon and the mode's name for its tooltip. +pub fn appearance_icon(mode: crate::state::AppearanceMode) -> (Icon, &'static str) { + use crate::state::AppearanceMode; + match mode { + AppearanceMode::Auto => (Icon::Monitor, "Sync with System"), + AppearanceMode::Light => (Icon::Sun, "Light Mode"), + AppearanceMode::Dark => (Icon::Moon, "Dark Mode"), + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index f3e64e001..d1ca27aca 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -161,6 +161,67 @@ impl LightsOut { /// How many libraries File ▸ Open Recent Library remembers. pub const RECENT_LIBRARIES: usize = 8; +/// Whether the interface uses the dark or the light theme (Settings ▸ Interface ▸ Appearance). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AppearanceMode { + /// Follow the operating system's dark/light setting (dark when it gives no answer). + Auto, + /// The dark theme, whatever the system says (the default: LightCraft's look so far). + #[default] + Dark, + Light, +} + +impl AppearanceMode { + /// The header button's order: Auto, Light, Dark, then Auto again. + pub fn next(self) -> AppearanceMode { + match self { + AppearanceMode::Auto => AppearanceMode::Light, + AppearanceMode::Light => AppearanceMode::Dark, + AppearanceMode::Dark => AppearanceMode::Auto, + } + } +} + +/// The theme the dark appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DarkTheme { + #[default] + Charcoal, + Midnight, +} + +/// The theme the light appearance uses. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum LightTheme { + #[default] + Silver, + Paper, +} + +impl DarkTheme { + pub const ALL: [DarkTheme; 2] = [DarkTheme::Charcoal, DarkTheme::Midnight]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + DarkTheme::Charcoal => crate::theme::ThemeKind::Charcoal, + DarkTheme::Midnight => crate::theme::ThemeKind::Midnight, + } + } +} + +impl LightTheme { + pub const ALL: [LightTheme; 2] = [LightTheme::Silver, LightTheme::Paper]; + pub fn kind(self) -> crate::theme::ThemeKind { + match self { + LightTheme::Silver => crate::theme::ThemeKind::Silver, + LightTheme::Paper => crate::theme::ThemeKind::Paper, + } + } +} + /// App-level preferences (Settings dialog). They belong to the app, not a library, and are /// saved with the UI state in the app's config folder (`ui.json`, key `settings`); library /// preferences (import defaults, XMP, cache size) live in the library's `prefs.json`. @@ -200,6 +261,15 @@ pub struct AppSettings { pub grid_badges: GridBadges, /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. pub keymap: crate::shortcuts::Keymap, + /// Auto (follow the system), Dark or Light. + pub appearance_mode: AppearanceMode, + /// The theme of the dark appearance. + pub dark_theme: DarkTheme, + /// The theme of the light appearance. + pub light_theme: LightTheme, + /// Theme shortcuts in the appearance context menu. + #[serde(deserialize_with = "deserialize_favorite_themes")] + pub favorite_themes: Vec, /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: /// the display is treated as sRGB). pub display_profile: String, @@ -223,6 +293,10 @@ impl Default for AppSettings { film_badges: true, grid_badges: GridBadges::Auto, keymap: Default::default(), + appearance_mode: AppearanceMode::Dark, + dark_theme: DarkTheme::Charcoal, + light_theme: LightTheme::Silver, + favorite_themes: Vec::new(), display_profile: String::new(), } } @@ -306,6 +380,29 @@ pub const LOUPE_EDGE_STEP: usize = 64; pub const STANDARD_PREVIEW_EDGE: u32 = 2560; impl AppSettings { + /// The theme to show: the light theme in Light mode, or in Auto when `system` is light; the + /// dark theme otherwise (Auto with no answer from the system included). + pub fn theme(&self, system: Option) -> crate::theme::ThemeKind { + let light = match self.appearance_mode { + AppearanceMode::Light => true, + AppearanceMode::Dark => false, + AppearanceMode::Auto => system == Some(egui::Theme::Light), + }; + if light { self.light_theme.kind() } else { self.dark_theme.kind() } + } + + /// Pick `kind` and fix the mode to its family (the View ▸ Theme items). + pub fn select_theme(&mut self, kind: crate::theme::ThemeKind) { + use crate::theme::ThemeKind; + match kind { + ThemeKind::Charcoal => self.dark_theme = DarkTheme::Charcoal, + ThemeKind::Midnight => self.dark_theme = DarkTheme::Midnight, + ThemeKind::Silver => self.light_theme = LightTheme::Silver, + ThemeKind::Paper => self.light_theme = LightTheme::Paper, + } + self.appearance_mode = if kind.is_dark() { AppearanceMode::Dark } else { AppearanceMode::Light }; + } + /// Long edge (pixels) to render the loupe at when it is drawn `wanted_px` wide on screen: /// that size (rounded up to [`LOUPE_EDGE_STEP`], so resizing a window doesn't re-render at /// every pixel), never above the photo's own `native_long_edge`, the user's limit, the ceiling @@ -1070,3 +1167,14 @@ impl UiState { self } } + +fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let values = Vec::::deserialize(d)?; + let mut seen = std::collections::BTreeSet::new(); + for id in &values { + if !crate::theme::ThemeKind::ALL.iter().any(|k| k.id() == id) || !seen.insert(id) { + return Err(serde::de::Error::custom("unknown or duplicate favorite theme")); + } + } + Ok(values) +} diff --git a/crates/ui-egui/src/tests_appearance.rs b/crates/ui-egui/src/tests_appearance.rs new file mode 100644 index 000000000..947e6cf9f --- /dev/null +++ b/crates/ui-egui/src/tests_appearance.rs @@ -0,0 +1,247 @@ +//! Appearance Mode: Auto (follow the system), Dark or Light, each with its own saved theme. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::{AppSettings, AppearanceMode, DarkTheme, LightTheme, UiState}; +use crate::theme::ThemeKind; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn app() -> LightcraftApp { + LightcraftApp::new(lightcraft_engine::Session::new(), Services { png: None, ..Default::default() }) +} + +/// One frame of logic on `ctx`: where the theme is applied. +fn frame(app: &mut LightcraftApp, ctx: &egui::Context) { + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())); + output.textures_delta.clear(); +} + +#[test] +fn new_users_keep_the_dark_look_and_old_settings_load_unchanged() { + let s = AppSettings::default(); + assert_eq!((s.appearance_mode, s.dark_theme, s.light_theme), (AppearanceMode::Dark, DarkTheme::Charcoal, LightTheme::Silver)); + assert_eq!(s.theme(Some(egui::Theme::Light)), ThemeKind::Charcoal, "Auto is opt-in"); + // a ui.json saved before appearance modes existed + let old: UiState = serde_json::from_str(r#"{"settings": {"gridBadges": "always", "confirmDelete": true}}"#).unwrap(); + assert_eq!(old.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(old.settings.dark_theme, DarkTheme::Charcoal); + assert_eq!(old.settings.light_theme, LightTheme::Silver); + assert!(old.settings.confirm_delete); + // and the Charcoal tokens are the ones LightCraft always had + assert_eq!(crate::theme::Tokens::for_kind(ThemeKind::Charcoal).chrome, crate::theme::Tokens::default().chrome); +} + +#[test] +fn choices_validate_and_survive_a_restart() { + let mut a = app(); + let ctx = egui::Context::default(); + let set = |a: &mut LightcraftApp, p: serde_json::Value| { + let (req, _) = crate::control::ControlRequest::new("ui.set", p); + match crate::control::handle(a, &ctx, &req) { + crate::control::Outcome::Done(reply) => reply, + _ => panic!("expected a reply"), + } + }; + let ok = set(&mut a, json!({"settings": {"appearanceMode": "auto", "darkTheme": "midnight", "lightTheme": "paper"}})); + assert_eq!(ok["ok"], true, "{ok}"); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); + for bad in [json!({"appearanceMode": "sepia"}), json!({"darkTheme": "paper"}), json!({"lightTheme": "midnight"}), json!({"appearanceMode": 3})] { + let reply = set(&mut a, json!({ "settings": bad })); + assert_eq!(reply["ok"], false, "{bad} accepted"); + } + assert_eq!((a.ui.settings.dark_theme, a.ui.settings.light_theme), (DarkTheme::Midnight, LightTheme::Paper), "a rejected value changes nothing"); + let saved = serde_json::to_string(&a.ui).unwrap(); + assert!(saved.contains(r#""appearanceMode":"auto""#) && saved.contains(r#""darkTheme":"midnight""#) && saved.contains(r#""lightTheme":"paper""#)); + let restarted: UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restarted.settings, a.ui.settings); +} + +#[test] +fn the_button_cycles_auto_light_dark_without_losing_theme_choices() { + let mut a = app(); + let ctx = egui::Context::default(); + a.services.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark))); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + a.run("view.theme", json!({"theme": "midnight"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Dark, "a dark theme fixes the mode to Dark"); + for (mode, shown) in + [(AppearanceMode::Auto, ThemeKind::Midnight), (AppearanceMode::Light, ThemeKind::Paper), (AppearanceMode::Dark, ThemeKind::Midnight)] + { + let r = a.run("view.appearance", json!({})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, mode); + assert_eq!(r["darkTheme"], "midnight"); + assert_eq!(r["lightTheme"], "paper"); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(shown)); + assert_eq!(crate::theme::Tokens::get(&ctx).chrome, crate::theme::Tokens::for_kind(shown).chrome); + let icon = match mode { + AppearanceMode::Auto => crate::icons::Icon::Monitor, + AppearanceMode::Light => crate::icons::Icon::Sun, + AppearanceMode::Dark => crate::icons::Icon::Moon, + }; + assert_eq!(crate::panels::topbar::appearance_icon(mode).0, icon); + } + // a light theme from the menu fixes Light + a.run("view.theme", json!({"theme": "silver"})).unwrap(); + assert_eq!( + (a.ui.settings.appearance_mode, a.ui.settings.light_theme, a.ui.settings.dark_theme), + (AppearanceMode::Light, LightTheme::Silver, DarkTheme::Midnight) + ); + // bad parameters are errors, never a panic, and change nothing + for p in [json!({}), json!({"theme": 4}), json!({"theme": "neon"})] { + assert!(a.run("view.theme", p.clone()).is_err(), "{p}"); + } + for p in [json!({"mode": "sepia"}), json!({"mode": 1}), json!({"mode": []})] { + assert!(a.run("view.appearance", p.clone()).is_err(), "{p}"); + } + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Light); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + assert_eq!(a.ui.settings.appearance_mode, AppearanceMode::Auto); +} + +#[test] +fn auto_follows_the_system_and_falls_back_to_dark() { + use std::sync::{Arc, Mutex}; + let system = Arc::new(Mutex::new(Some(egui::Theme::Light))); + let mut a = app(); + let reported = Arc::clone(&system); + a.services.system_theme = Some(Box::new(move |_| *reported.lock().unwrap())); + a.run("view.appearance", json!({"mode": "auto"})).unwrap(); + let ctx = egui::Context::default(); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); + assert_eq!(ctx.theme(), egui::Theme::Light); + *system.lock().unwrap() = Some(egui::Theme::Dark); + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + assert_eq!(ctx.theme(), egui::Theme::Dark); + // no answer from the host or from egui: dark + *system.lock().unwrap() = None; + frame(&mut a, &ctx); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Charcoal)); + // without a host service, egui's report is used + let mut plain = app(); + plain.ui.settings.appearance_mode = AppearanceMode::Auto; + let ctx = egui::Context::default(); + let input = egui::RawInput { system_theme: Some(egui::Theme::Light), ..Default::default() }; + let mut output = ctx.run_ui(input, |ui| { + assert_eq!(plain.system_theme(ui.ctx()), Some(egui::Theme::Light)); + plain.logic(ui.ctx()); + }); + output.textures_delta.clear(); + assert_eq!(crate::theme::current(&ctx), Some(ThemeKind::Silver)); +} + +#[test] +fn view_menu_lists_modes_and_themes_with_checks() { + let mut a = app(); + a.run("view.theme", json!({"theme": "paper"})).unwrap(); + let menus = crate::menubar::menu_bar(&a); + let text = serde_json::to_string(&menus).unwrap(); + for label in ["Appearance", "Sync with System", "Light Mode", "Dark Mode", "Next Appearance Mode", "Charcoal", "Midnight", "Silver", "Paper"] { + assert!(text.contains(&format!("\"{label}\"")), "{label} missing from the menus"); + } + assert!(text.contains(r#""params":{"theme":"paper"}"#)); +} + +#[test] +fn the_top_bar_button_and_settings_cards_drive_the_mode() { + let mut h = Headless::new(app(), [1400.0, 900.0], 1.0); + h.settle(Duration::from_secs(60)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Auto); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Light); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Silver)); + // Settings ▸ Interface: the mode buttons and the theme radios + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + h.step(); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "radio:theme-paper"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Paper); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Paper)); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:settingsAppearance-1"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Dark); + assert_eq!(crate::theme::current(&h.view.ctx), Some(ThemeKind::Charcoal)); +} + +#[test] +fn interface_settings_fit_small_windows_and_stack_theme_choices() { + // Desktop windows have a 900×560 minimum; also stress a smaller headless viewport. + for size in [[900.0, 560.0], [640.0, 480.0]] { + let mut h = Headless::new(app(), size, 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + for _ in 0..4 { + h.step(); + } + let window = h.app.widgets.iter().find(|(id, _)| id == "dialog:window").unwrap().1; + assert!(window.width() <= size[0], "{size:?}: {window:?}"); + assert!(window.height() <= size[1], "{size:?}: {window:?}"); + let silver = h.app.widgets.iter().find(|(id, _)| id == "radio:theme-silver").unwrap().1; + let mode = h.app.widgets.iter().find(|(id, _)| id == "button:settingsAppearance-0").unwrap().1; + assert!(silver.top() > mode.bottom() + 80.0, "theme choices must sit below their preview"); + assert!(silver.right() <= window.right(), "theme choices stay inside the modal"); + } +} + +#[test] +fn favorites_validate_roundtrip_and_stars_toggle_without_selecting() { + for bad in [json!(["paper", "paper"]), json!(["unknown"]), json!([3]), json!("paper")] { + assert!(serde_json::from_value::(json!({"favoriteThemes": bad})).is_err()); + } + let mut h = Headless::new(app(), [1400.0, 900.0], 1.0); + h.app.ui.dialog = Some(crate::state::Dialog::Settings { tab: "interface".into() }); + for _ in 0..4 { + h.step(); + } + assert_eq!(h.request("ui.hoverWidget", json!({"id": "radio:theme-paper"}), T)["ok"], true); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:themeFavorite-paper"}), T)["ok"], true); + h.step(); + assert_eq!(h.app.ui.settings.favorite_themes, vec!["paper"]); + if let Some(path) = std::env::var_os("FAVORITES_SCREENSHOT_DIR") { + h.request("ui.scroll", json!({"dy": -90.0}), T); + for _ in 0..4 { + h.step(); + } + let pixels = h.paint(); + let image = + lightcraft_raster::Rgba8 { width: pixels.size[0], height: pixels.size[1], data: pixels.pixels.iter().map(|c| c.to_array()).collect() }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&image), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(&path).unwrap(); + std::fs::write(std::path::Path::new(&path).join("lightcraft-favorites.png"), png).unwrap(); + } + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Silver, "starring does not select"); + let saved = serde_json::to_value(&h.app.ui.settings).unwrap(); + assert_eq!(serde_json::from_value::(saved).unwrap(), h.app.ui.settings); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:themeFavorite-paper"}), T)["ok"], true); + h.step(); + assert!(h.app.ui.settings.favorite_themes.is_empty()); + h.app.ui.dialog = None; + h.app.ui.settings.favorite_themes = vec!["midnight".into(), "paper".into()]; + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:appearance", "button": "right"}), T)["ok"], true); + h.step(); + h.step(); + let light = h.app.widgets.iter().find(|(id, _)| id == "menu:themeFavorite-paper").unwrap().1; + let dark = h.app.widgets.iter().find(|(id, _)| id == "menu:themeFavorite-midnight").unwrap().1; + assert!(light.top() < dark.top(), "light favorites precede dark favorites"); + assert_eq!(h.request("ui.clickWidget", json!({"id": "menu:themeFavorite-paper"}), T)["ok"], true); + h.step(); + h.step(); + assert_eq!(h.app.ui.settings.appearance_mode, AppearanceMode::Light); + assert_eq!(h.app.ui.settings.light_theme, LightTheme::Paper); + assert_eq!(h.app.ui.settings.dark_theme, DarkTheme::Charcoal); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 620871c36..f383f3337 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,5 +1,9 @@ //! Design tokens (colours, sizes, fonts). Values measured from black-box observation of the //! reference app's dark theme (see plan/lightroom/10-observed-ui.md); everything is our own code. +//! +//! Themes come in two families, picked by the Appearance Mode setting (Settings ▸ Interface): +//! - dark: **Charcoal** (the default, the observed dark look) and **Midnight** (near black); +//! - light: **Silver** (light grey chrome) and **Paper** (near white). use std::sync::Arc; @@ -75,6 +79,44 @@ pub struct Tokens { pub film_h: f32, } +/// An interface theme. Each belongs to the dark or the light family. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum ThemeKind { + #[default] + Charcoal, + Midnight, + Silver, + Paper, +} + +impl ThemeKind { + pub const ALL: [ThemeKind; 4] = [ThemeKind::Charcoal, ThemeKind::Midnight, ThemeKind::Silver, ThemeKind::Paper]; + + pub fn is_dark(self) -> bool { + matches!(self, ThemeKind::Charcoal | ThemeKind::Midnight) + } + + /// The theme's id (settings value and command suffix). + pub fn id(self) -> &'static str { + match self { + ThemeKind::Charcoal => "charcoal", + ThemeKind::Midnight => "midnight", + ThemeKind::Silver => "silver", + ThemeKind::Paper => "paper", + } + } + + /// The English display name. + pub fn label(self) -> &'static str { + match self { + ThemeKind::Charcoal => "Charcoal", + ThemeKind::Midnight => "Midnight", + ThemeKind::Silver => "Silver", + ThemeKind::Paper => "Paper", + } + } +} + impl Default for Tokens { fn default() -> Self { Tokens { @@ -118,6 +160,92 @@ impl Default for Tokens { } impl Tokens { + /// The tokens of `kind`. Metrics are shared; only colours change. + pub fn for_kind(kind: ThemeKind) -> Tokens { + let rgb = |v: u32| Color32::from_rgb((v >> 16) as u8, (v >> 8) as u8, v as u8); + let base = Tokens::default(); + match kind { + ThemeKind::Charcoal => base, + ThemeKind::Midnight => Tokens { + chrome: rgb(0x1e1e1e), + canvas: rgb(0x121212), + grid_bg: rgb(0x080808), + cell: rgb(0x121212), + cell_selected: rgb(0x262626), + divider: rgb(0x0e0e0e), + inset: rgb(0x181818), + field: rgb(0x161616), + field_border: rgb(0x333333), + button: rgb(0x191919), + button_border: rgb(0x404040), + hover: rgb(0x2e2e2e), + pressed: rgb(0x343434), + tool_active: rgb(0x343434), + ..base + }, + ThemeKind::Silver => Tokens { + chrome: rgb(0xe6e6e6), + canvas: rgb(0xcfcfcf), + grid_bg: rgb(0xd9d9d9), + cell: rgb(0xcbcbcb), + cell_selected: rgb(0xb4b4b4), + divider: rgb(0xc4c4c4), + inset: rgb(0xdcdcdc), + field: rgb(0xf6f6f6), + field_border: rgb(0xb8b8b8), + button: rgb(0xf2f2f2), + button_border: rgb(0xb0b0b0), + hover: rgb(0xd2d2d2), + pressed: rgb(0xc4c4c4), + tool_active: rgb(0xcacaca), + text: rgb(0x1c1c1c), + text_label: rgb(0x383838), + text_dim: rgb(0x6a6a6a), + text_disabled: rgb(0xa2a2a2), + icon: rgb(0x555555), + track: rgb(0xa8a8a8), + thumb: rgb(0x5e5e5e), + thumb_hover: rgb(0x262626), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + ThemeKind::Paper => Tokens { + chrome: rgb(0xfafafa), + canvas: rgb(0xececec), + grid_bg: rgb(0xf2f2f2), + cell: rgb(0xe8e8e8), + cell_selected: rgb(0xd2d2d2), + divider: rgb(0xe0e0e0), + inset: rgb(0xf0f0f0), + field: rgb(0xffffff), + field_border: rgb(0xcccccc), + button: rgb(0xffffff), + button_border: rgb(0xc6c6c6), + hover: rgb(0xebebeb), + pressed: rgb(0xdedede), + tool_active: rgb(0xe2e2e2), + text: rgb(0x161616), + text_label: rgb(0x343434), + text_dim: rgb(0x707070), + text_disabled: rgb(0xababab), + icon: rgb(0x585858), + track: rgb(0xbdbdbd), + thumb: rgb(0x606060), + thumb_hover: rgb(0x202020), + accent: rgb(0x0a62d0), + star: rgb(0x3a3a3a), + pick: rgb(0x2a2a2a), + reject: rgb(0xc83434), + caution: rgb(0xb0761a), + ..base + }, + } + } + pub fn get(ctx: &egui::Context) -> Tokens { ctx.data(|d| d.get_temp::(egui::Id::NULL)).unwrap_or_default() } @@ -215,15 +343,32 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { format!("craft-fonts {} {}", f.family, f.style) } +fn theme_id() -> egui::Id { + egui::Id::new("lightcraft-theme") +} + +/// The theme `ctx` is styled with (none before the first [`apply_kind`]). +pub fn current(ctx: &egui::Context) -> Option { + ctx.data(|d| d.get_temp::(theme_id())) +} + +/// Style `ctx` with the default theme. pub fn apply(ctx: &egui::Context) { - // Cmd+= / Cmd+- / Cmd+0 (Ctrl on Windows and Linux) are View ▸ Zoom In / Zoom Out / Zoom to Fit - // for the photo. egui would take the same keys at the end of every frame for its browser-style - // interface zoom (issue #566); on macOS the native menu bar got them first, elsewhere the whole - // UI scaled. + apply_kind(ctx, ThemeKind::default()); +} + +/// Style `ctx` with `kind`. The visuals go to both of egui's styles, so egui following the +/// system's dark/light setting on its own never swaps in its stock look. +pub fn apply_kind(ctx: &egui::Context, kind: ThemeKind) { + // Keep photo zoom shortcuts from resizing egui itself. ctx.options_mut(|o| o.zoom_with_keyboard = false); - let t = Tokens::default(); - ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t)); - let mut v = Visuals::dark(); + let t = Tokens::for_kind(kind); + ctx.data_mut(|d| { + d.insert_temp(egui::Id::NULL, t); + d.insert_temp(theme_id(), kind); + }); + let dark = kind.is_dark(); + let mut v = if dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.chrome; v.window_fill = t.chrome; v.extreme_bg_color = t.field; @@ -234,8 +379,9 @@ pub fn apply(ctx: &egui::Context) { v.selection.bg_fill = t.accent.gamma_multiply(0.6); v.selection.stroke = Stroke::new(1.0, t.accent); v.override_text_color = Some(t.text_label); - v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(140) }; - v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(160) }; + let (popup, window) = if dark { (140, 160) } else { (50, 60) }; + v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(popup) }; + v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 30, spread: 0, color: Color32::from_black_alpha(window) }; let w = &mut v.widgets; craft_ui::theme::WidgetPalette { noninteractive: t.chrome, inactive: t.button, hovered: t.hover, active: t.pressed, open: t.hover }.apply_to( w, @@ -244,8 +390,10 @@ pub fn apply(ctx: &egui::Context) { ); w.noninteractive.bg_stroke = Stroke::new(1.0, t.divider); w.inactive.bg_stroke = Stroke::new(1.0, t.button_border); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + ctx.set_visuals_of(egui::Theme::Dark, v.clone()); + ctx.set_visuals_of(egui::Theme::Light, v); + ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light }); + ctx.all_styles_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d6c0c3d17..964aa042e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -87,6 +87,12 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, +Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: +"charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. +Both reply `{mode, darkTheme, lightTheme}`; the same values are `settings.appearanceMode`, +`settings.darkTheme` and `settings.lightTheme` in `ui.set`. Auto follows the system (on Linux the +XDG desktop portal, watched for changes rather than polled) and is dark when the system says nothing. Mask brushing uses the UI command `tool.brush` through `engine.execute`. With `{}` it activates painting on the current mask. `{"new": true}` creates a separate brush mask; `{"op": "add"}` or `{"op": "subtract"}` appends a brush component to the selected mask. Creating either starts in diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7c345055d..ee6a5e1b2 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -424,7 +424,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Display / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-