Merge pull request #1070 from luniiya/feat/theme-favorites

UI: favorite themes and appearance shortcuts
This commit is contained in:
Brandon Thomas
2026-10-11 22:34:19 -04:00
15 changed files with 187 additions and 7 deletions
+20
View File
@@ -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<String>,
/// "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<Field> {
/// 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('.', "/"));
+18
View File
@@ -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<u8> {
let labels: Vec<Value> = (0..n).map(|i| json!({"name": format!(" Label {i} "), "color": color})).collect();
serde_json::to_vec(&json!({"name": name, "labels": labels})).unwrap()
+3 -1
View File
@@ -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);
+1
View File
@@ -387,3 +387,4 @@
Zoom Out Verkleinern
Zoom to Sequence Auf Sequenz zoomen
Project Notes Projektnotizen
Can't render this file because it contains an unexpected character in line 6 and column 45.
+6
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 3 and column 95.
+6
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 5 and column 83.
+4
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 14.
+6
View File
@@ -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 スキャンライン)
Can't render this file because it contains an unexpected character in line 11 and column 59.
+6
View File
@@ -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.
Can't render this file because it contains an unexpected character in line 6 and column 4.
+1
View File
@@ -356,3 +356,4 @@
Zoom to Sequence Масштабировать по размеру эпизода
Next Appearance Mode Следующий режим оформления
Project Notes Заметки проекта
1 # Russian (ru) catalog. Columns separated by TAB: context <TAB> source <TAB> translation
356
357
358
359
+1
View File
@@ -356,3 +356,4 @@
Zoom Out Зменшити
Zoom to Sequence Умістити секвенцію
Project Notes Нотатки проєкту
1 # Ukrainian (uk) catalog. Columns separated by TAB: context <TAB> source <TAB> translation
356
357
358
359
+6
View File
@@ -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 条扫描线)
Can't render this file because it contains an unexpected character in line 3 and column 95.
+1
View File
@@ -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;
+85 -6
View File
@@ -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<String> = 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]
+23
View File
@@ -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)
}