mirror of
https://github.com/storytold/filmcraft.git
synced 2026-11-02 14:37:31 -06:00
Merge pull request #1070 from luniiya/feat/theme-favorites
UI: favorite themes and appearance shortcuts
This commit is contained in:
@@ -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('.', "/"));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -356,3 +356,4 @@
|
||||
Zoom to Sequence Масштабировать по размеру эпизода
|
||||
Next Appearance Mode Следующий режим оформления
|
||||
Project Notes Заметки проекта
|
||||
|
||||
|
||||
|
@@ -356,3 +356,4 @@
|
||||
Zoom Out Зменшити
|
||||
Zoom to Sequence Умістити секвенцію
|
||||
Project Notes Нотатки проєкту
|
||||
|
||||
|
||||
|
@@ -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.
|
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user