From 2b916fa530645209b546b8693ea63d8c8e520b24 Mon Sep 17 00:00:00 2001 From: Luniiya <86487331+luniiya@users.noreply.github.com> Date: Sun, 11 Oct 2026 16:12:36 +0200 Subject: [PATCH 1/2] UI: favorite themes and appearance shortcuts Star a theme in the Preferences theme cards to favorite it. Right-click the header's appearance button to switch between favorites in one click. --- ATTRIBUTION.md | 2 + crates/engine/src/settings.rs | 20 ++++++ crates/engine/src/settings_tests.rs | 18 ++++++ crates/ui-egui/src/header.rs | 4 +- crates/ui-egui/src/i18n/de.tsv | 6 ++ crates/ui-egui/src/i18n/es.tsv | 6 ++ crates/ui-egui/src/i18n/fr.tsv | 6 ++ crates/ui-egui/src/i18n/ja.tsv | 6 ++ crates/ui-egui/src/i18n/pt-br.tsv | 6 ++ crates/ui-egui/src/i18n/ru.tsv | 6 ++ crates/ui-egui/src/i18n/uk.tsv | 6 ++ crates/ui-egui/src/i18n/zh-cn.tsv | 6 ++ crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/settings.rs | 91 +++++++++++++++++++++++++-- crates/ui-egui/src/theme_favorites.rs | 23 +++++++ 15 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 crates/ui-egui/src/theme_favorites.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0c1793ed1..5170b0d27 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -164,3 +164,5 @@ SHA-256 (`crates/speech/src/models.rs`, `docs/transcripts.md`). | Asset | Author | Source | Licence | |---|---|---|---| | Whisper tiny / base / small speech-recognition weights (`model.safetensors`, `config.json`, `generation_config.json`, `tokenizer.json`) | OpenAI | `https://huggingface.co/openai/whisper-{tiny,base,small}` (revisions `169d4a43…`, `e37978b9…`, `973afd24…`); weights released with `https://github.com/openai/whisper` | MIT (weights and code, github.com/openai/whisper); the Hugging Face conversion is labelled Apache-2.0 | + +Theme favorite star artwork in `crates/ui-egui/src/theme_favorites.rs` is original procedural work by Craft contributors, licensed MIT OR Apache-2.0. diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index e6f91d785..dd7496b9e 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(), @@ -1152,6 +1155,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), @@ -1184,6 +1199,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 6df9e2462..1c05f0fa9 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -675,3 +675,21 @@ 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); +} 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 51a33dd5d..daa479fc4 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -384,3 +384,9 @@ Zoom Out Verkleinern Zoom to Sequence Auf Sequenz zoomen Project Notes Projektnotizen + + Appearance Settings… Erscheinungsbild-Einstellungen… + + Add to favorites Zu Favoriten hinzufügen + + Remove from favorites Aus Favoriten entfernen diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index cdf8ed2c1..7eb53ef39 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3562,3 +3562,9 @@ 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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5f17a4f44..96cfa6581 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3545,3 +3545,9 @@ 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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2c160f4b6..c886ddf68 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3544,3 +3544,9 @@ 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 お気に入りから削除 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fc7b9fd50..4a56c48bd 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -356,3 +356,9 @@ # System appearance mode menu command. Next Appearance Mode Próximo modo de aparência Project Notes Notas do projeto + + 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/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3f46811d3..b4520db68 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -356,3 +356,9 @@ Zoom to Sequence Масштабировать по размеру эпизода Next Appearance Mode Следующий режим оформления Project Notes Заметки проекта + + Appearance Settings… Настройки оформления… + + Add to favorites Добавить в избранное + + Remove from favorites Удалить из избранного diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 13312b311..65183e4e5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -356,3 +356,9 @@ Zoom Out Зменшити Zoom to Sequence Умістити секвенцію Project Notes Нотатки проєкту + + Appearance Settings… Налаштування вигляду… + + Add to favorites Додати до вибраного + + Remove from favorites Видалити з вибраного diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 2e6a688b5..2d9578bf9 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3540,3 +3540,9 @@ Delete Caption Track 删除字幕轨道 Delete Caption Tracks 删除多个字幕轨道 Delete Empty Caption Tracks 删除空字幕轨道 + + Appearance Settings… 外观设置… + + Add to favorites 添加到收藏 + + Remove from favorites 从收藏中移除 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 438112a29..091be9fac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -52,6 +52,7 @@ pub mod scrub; 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 336ba0970..ef9092f3b 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -89,8 +89,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 @@ -232,11 +257,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)); + } + }); + }); } }); }); @@ -795,6 +840,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) +} From d6a10470f3fa6aab29007881e794e8bc44de23a6 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 22:34:11 -0400 Subject: [PATCH 2/2] i18n: theme favorites strings stay out of the menus-only catalogs (de, ru, uk); Indonesian gets them de, ru and uk hold only menu labels (their tests check every entry is one); Indonesian mirrors the Spanish catalog. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/de.tsv | 5 ----- crates/ui-egui/src/i18n/id.tsv | 4 ++++ crates/ui-egui/src/i18n/ru.tsv | 5 ----- crates/ui-egui/src/i18n/uk.tsv | 5 ----- 4 files changed, 4 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index bfb241b00..21c0be07d 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -388,8 +388,3 @@ Zoom to Sequence Auf Sequenz zoomen Project Notes Projektnotizen - Appearance Settings… Erscheinungsbild-Einstellungen… - - Add to favorites Zu Favoriten hinzufügen - - Remove from favorites Aus Favoriten entfernen 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/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b4520db68..747ffde25 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -357,8 +357,3 @@ Next Appearance Mode Следующий режим оформления Project Notes Заметки проекта - Appearance Settings… Настройки оформления… - - Add to favorites Добавить в избранное - - Remove from favorites Удалить из избранного diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 65183e4e5..3ce73588c 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -357,8 +357,3 @@ Zoom to Sequence Умістити секвенцію Project Notes Нотатки проєкту - Appearance Settings… Налаштування вигляду… - - Add to favorites Додати до вибраного - - Remove from favorites Видалити з вибраного