diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 1fb709160..6e3cccdf1 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -100,6 +100,8 @@ pub struct AppearancePrefs { pub dark_theme: String, /// The theme shown in light mode: `light`. pub light_theme: String, + /// Theme shortcuts for the header context menu. + pub favorite_themes: Vec, /// "Accessible color contrast": brighter secondary text and borders. pub accessible_contrast: bool, /// Highlight (accent) colour of selections, focus and primary buttons (`#rrggbb`). @@ -116,6 +118,7 @@ impl Default for AppearancePrefs { appearance_mode: "dark".into(), dark_theme: "darkest".into(), light_theme: "light".into(), + favorite_themes: Vec::new(), accessible_contrast: false, highlight_color: DEFAULT_HIGHLIGHT.into(), ui_scale: "auto".into(), @@ -1380,6 +1383,18 @@ pub fn field(key: &str) -> Option { /// Check a value for `key` against the schema (choices, colours). Ranges are clamped later. pub fn validate(key: &str, v: &Value) -> std::result::Result<(), String> { + if key == "appearance.favoriteThemes" { + let items = v.as_array().ok_or("favorite themes must be a list")?; + let mut seen = Vec::new(); + for item in items { + let id = item.as_str().ok_or("favorite theme ids must be strings")?; + if !LIGHT_THEMES.iter().chain(DARK_THEMES).any(|(theme, _)| *theme == id) || seen.contains(&id) { + return Err(format!("unknown or duplicate favorite theme `{id}`")); + } + seen.push(id); + } + return Ok(()); + } let kind = match field(key) { Some(f) => f.kind, None if is_unit_key(key) => Kind::Choice(DURATION_UNITS), @@ -1412,6 +1427,11 @@ pub fn is_unit_key(key: &str) -> bool { /// Repair a preferences value in place against the schema: unknown choices and bad colours go back /// to their defaults, numbers are clamped to their ranges. pub fn sanitize(v: &mut Value, defaults: &Value) { + if let Some(slot) = v.pointer_mut("/appearance/favoriteThemes") + && validate("appearance.favoriteThemes", slot).is_err() + { + *slot = json!([]); + } for (_, f) in fields() { if let Kind::Duration { unit_key } = f.kind { let ptr = format!("/{}", unit_key.replace('.', "/")); diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 34262638a..dff567507 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -686,6 +686,24 @@ fn appearance_choices_validate_and_legacy_theme_still_selects() { assert_eq!(seen, ["auto", "light", "dark", "auto"]); } +#[test] +fn favorite_themes_validate_and_persist_without_changing_other_settings() { + let dir = tmp_dir("theme-favorites"); + let path = dir.join("preferences.json"); + let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() }; + set(&mut s, "appearance.favoriteThemes", json!(["dark", "light"])); + assert_eq!(Preferences::load(&path).appearance.favorite_themes, vec!["dark", "light"]); + for value in [json!("light"), json!(["unknown"]), json!(["dark", "dark"]), json!([3]), json!(null)] { + assert!(s.execute("prefs.set", json!({"key": "appearance.favoriteThemes", "value": value})).is_err()); + assert_eq!(s.prefs.appearance.favorite_themes, vec!["dark", "light"]); + } + let mut corrupt = s.prefs.to_value(); + corrupt["appearance"]["favoriteThemes"] = json!(["invalid", "invalid"]); + let repaired = Preferences::from_value(corrupt); + assert!(repaired.appearance.favorite_themes.is_empty()); + assert_eq!(repaired.appearance.dark_theme, s.prefs.appearance.dark_theme); +} + fn preset_json(name: &str, n: usize, color: &str) -> Vec { let labels: Vec = (0..n).map(|i| json!({"name": format!(" Label {i} "), "color": color})).collect(); serde_json::to_vec(&json!({"name": name, "labels": labels})).unwrap() diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 721e35cfa..5b8f23c85 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -107,7 +107,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { _ => (Icon::Moon, tl!("Dark")), }; let tip = format!("{}: {mode}", tl!("Appearance Mode")); - if btn(ui, icon, "appearance", &tip, app).clicked() { + let appearance = btn(ui, icon, "appearance", &tip, app); + appearance.context_menu(|ui| crate::panels::settings::appearance_context_menu(app, ui)); + if appearance.clicked() { crate::panels::settings::cycle_appearance(app, ui.ctx()); } let ws_resp = btn(ui, Icon::Workspaces, "workspaces", tl!("Workspaces"), app); diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 11c51af7c..21c0be07d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -387,3 +387,4 @@ Zoom Out Verkleinern Zoom to Sequence Auf Sequenz zoomen Project Notes Projektnotizen + diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6119ccc99..25a23480d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3570,6 +3570,12 @@ Edit Project Notes Editar notas del proyecto Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto. Use Effect Shutter Angle Usar el ángulo de obturación del efecto + + Appearance Settings… Ajustes de apariencia… + + Add to favorites Añadir a favoritos + + Remove from favorites Quitar de favoritos Compression Compresión ZIP (1 scan line) ZIP (1 línea de exploración) ZIP (16 scan lines) ZIP (16 líneas de exploración) diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index edcba88a6..4385c0dd5 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3546,6 +3546,12 @@ Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notes pour ce projet : tâches, instructions, numéros d'image… Enregistrées avec le projet. Use Effect Shutter Angle Utiliser l'angle d'obturation de l'effet Settings Préférences + + Appearance Settings… Réglages d’apparence… + + Add to favorites Ajouter aux favoris + + Remove from favorites Retirer des favoris Compression Compression ZIP (1 scan line) ZIP (1 ligne de balayage) ZIP (16 scan lines) ZIP (16 lignes de balayage) diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index c70b1ba56..1c44965f4 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -3562,3 +3562,7 @@ Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Catatan untuk proyek ini: daftar tugas, instruksi, nomor bingkai… Disimpan bersama proyek. Use Effect Shutter Angle Gunakan Sudut Rana Efek Settings Preferensi + + Appearance Settings… Pengaturan Tampilan… + Add to favorites Tambahkan ke favorit + Remove from favorites Hapus dari favorit diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d142b44b8..13673e6cf 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3552,6 +3552,12 @@ Edit Project Notes プロジェクトメモを編集 Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。 GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます + + Appearance Settings… 外観設定… + + Add to favorites お気に入りに追加 + + Remove from favorites お気に入りから削除 Compression 圧縮 ZIP (1 scan line) ZIP(1 スキャンライン) ZIP (16 scan lines) ZIP(16 スキャンライン) diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index b1229d3f5..17498760e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -3556,6 +3556,12 @@ Text to Speech Texto para fala Edit Narration Editar narração Project Notes Notas do projeto + + Appearance Settings… Configurações de aparência… + + Add to favorites Adicionar aos favoritos + + Remove from favorites Remover dos favoritos Get Project Notes Obter notas do projeto Edit Project Notes Editar notas do projeto Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas deste projeto: tarefas, instruções, números de quadro… Salvas com o projeto. diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3f46811d3..747ffde25 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -356,3 +356,4 @@ Zoom to Sequence Масштабировать по размеру эпизода Next Appearance Mode Следующий режим оформления Project Notes Заметки проекта + diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13312b311..3ce73588c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -356,3 +356,4 @@ Zoom Out Зменшити Zoom to Sequence Умістити секвенцію Project Notes Нотатки проєкту + diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 15bc785fd..7459caa13 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3548,6 +3548,12 @@ Delete Caption Track 删除字幕轨道 Delete Caption Tracks 删除多个字幕轨道 Delete Empty Caption Tracks 删除空字幕轨道 + + Appearance Settings… 外观设置… + + Add to favorites 添加到收藏 + + Remove from favorites 从收藏中移除 Compression 压缩 ZIP (1 scan line) ZIP(1 条扫描线) ZIP (16 scan lines) ZIP(16 条扫描线) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cf67ce35c..9143a6624 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -57,6 +57,7 @@ mod shuttle; pub mod source_playback; pub mod state; pub mod theme; +mod theme_favorites; pub mod widgets; use std::collections::HashMap; diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index bc1b867f1..f1ae41dd8 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -97,8 +97,33 @@ pub fn cycle_appearance(app: &mut FilmcraftApp, ctx: &egui::Context) { app.apply_prefs(ctx); } +/// Favorite shortcuts use a stable light-then-dark order, independently of insertion order. +pub fn appearance_context_menu(app: &mut FilmcraftApp, ui: &mut Ui) { + if ui.button(tl!("Appearance Settings…")).clicked() { + if let Err(error) = open(app, "appearance") { + app.ui.status = error; + } + ui.close(); + } + let favorites = app.session.prefs.appearance.favorite_themes.clone(); + for options in [settings::LIGHT_THEMES, settings::DARK_THEMES] { + let group: Vec<_> = options.iter().filter(|(id, _)| favorites.iter().any(|saved| saved == id)).collect(); + if !group.is_empty() { + ui.separator(); + } + for (id, label) in group { + let kind = crate::theme::ThemeKind::from_pref(id); + if ui.selectable_label(app.tokens.kind == kind, crate::i18n::t(label)).clicked() { + set_theme(app, ui.ctx(), kind); + ui.close(); + } + } + } +} + /// Keys the Appearance page draws as the mode selector and theme cards instead of plain fields. -const APPEARANCE_KEYS: &[&str] = &["appearance.appearanceMode", "appearance.lightTheme", "appearance.darkTheme", "appearance.colorTheme"]; +const APPEARANCE_KEYS: &[&str] = + &["appearance.appearanceMode", "appearance.lightTheme", "appearance.darkTheme", "appearance.colorTheme", "appearance.favoriteThemes"]; /// A small FilmCraft editor painted with `t`'s colours: the header with the Import / Edit / Export /// tabs, the Source and Program monitors, the Project panel, the tools, the Timeline with video and @@ -240,11 +265,31 @@ fn theme_card(app: &mut FilmcraftApp, ui: &mut Ui, d: &mut SettingsDraft, key: & theme_preview(ui, &look, width - 20.0); ui.add_space(4.0); for (v, l) in opts { - let r = ui.radio(current == *v, RichText::new(crate::i18n::t(l)).size(12.5)); - app.auto.add(&format!("settings.{key}.{v}"), r.rect, l); - if r.clicked() { - put(&mut d.values, key, json!(v)); - } + let row = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 24.0)); + let hovered = ui.ctx().pointer_hover_pos().is_some_and(|pos| row.contains(pos)); + ui.horizontal(|ui| { + let r = ui.radio(current == *v, RichText::new(crate::i18n::t(l)).size(12.5)); + app.auto.add(&format!("settings.{key}.{v}"), r.rect, l); + if r.clicked() { + put(&mut d.values, key, json!(v)); + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + let mut favorites: Vec = serde_json::from_value(get(&d.values, "appearance.favoriteThemes").clone()).unwrap_or_default(); + let favorite = favorites.iter().any(|id| id == v); + let action = if favorite { tl!("Remove from favorites") } else { tl!("Add to favorites") }; + let label = format!("{action}: {}", crate::i18n::t(l)); + let r = crate::theme_favorites::star(ui, favorite, favorite || hovered, t.accent, &label); + app.auto.add(&format!("settings.favorite.{v}"), r.rect, &label); + if r.clicked() { + if favorite { + favorites.retain(|id| id != v); + } else { + favorites.push((*v).into()); + } + put(&mut d.values, "appearance.favoriteThemes", json!(favorites)); + } + }); + }); } }); }); @@ -890,6 +935,40 @@ mod tests { use super::*; use crate::theme::ThemeKind; + #[test] + fn favorite_theme_star_and_shortcut_select_explicit_family() { + use egui_kittest::{Harness, kittest::Queryable}; + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + open(&mut app, "appearance").unwrap(); + let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).build_eframe(move |_| app); + h.run_steps(4); + h.get_by_label("Add to favorites: Dark").click(); + h.run_steps(3); + assert_eq!(*get(&h.state().ui.settings.as_ref().unwrap().values, "appearance.favoriteThemes"), json!(["dark"])); + h.get_by_label("OK").click(); + h.run_steps(3); + assert_eq!(h.state().session.prefs.appearance.favorite_themes, vec!["dark"]); + let mut app = FilmcraftApp::new(filmcraft_engine::Session::default()); + app.session.execute("prefs.set", json!({"key": "appearance.favoriteThemes", "value": ["light", "dark"]})).unwrap(); + let mut h = Harness::builder().with_size(vec2(400.0, 240.0)).build_ui_state( + |ui, app| { + ui.button("Appearance").context_menu(|ui| appearance_context_menu(app, ui)); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Appearance").click_secondary(); + h.run_steps(3); + assert!(h.query_by_label("Appearance Settings…").is_some()); + if let Some(path) = std::env::var_os("FILMCRAFT_FAVORITES_SCREENSHOT") { + h.render().unwrap().save(path).unwrap(); + } + h.get_by_label("Light").click(); + h.run_steps(3); + assert_eq!(h.state().session.prefs.appearance.appearance_mode, "light"); + assert_eq!(h.state().session.prefs.appearance.dark_theme, "darkest"); + } + /// #457: on a desktop that reports 150%, UI Scale 100% makes a point one physical pixel. It is /// applied once, so Ctrl+- still zooms for the session, and Auto gives the system's scale back. #[test] diff --git a/crates/ui-egui/src/theme_favorites.rs b/crates/ui-egui/src/theme_favorites.rs new file mode 100644 index 000000000..e797df9fd --- /dev/null +++ b/crates/ui-egui/src/theme_favorites.rs @@ -0,0 +1,23 @@ +//! Original theme favorite star artwork, MIT OR Apache-2.0. +pub(crate) fn star(ui: &mut egui::Ui, favorite: bool, visible: bool, accent: egui::Color32, label: &str) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + if visible { + let color = if favorite { accent } else { ui.visuals().weak_text_color() }; + let center = rect.center(); + let points: Vec<_> = (0..10) + .map(|i| { + let angle = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0; + center + egui::vec2(angle.cos(), angle.sin()) * if i % 2 == 0 { 7.0 } else { 3.0 } + }) + .collect(); + if favorite { + for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(10) { + ui.painter().add(egui::Shape::convex_polygon(vec![center, *a, *b], color, egui::Stroke::NONE)); + } + } else { + ui.painter().add(egui::Shape::closed_line(points, egui::Stroke::new(1.2, color))); + } + } + response.on_hover_text(label) +}