diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6750a327..8d5061f7 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -58,7 +58,7 @@ Screenshots of EffectCraft itself, rendered headlessly, showing only procedurall ## Code-drawn assets (original work, MIT OR Apache-2.0) -- UI icons drawn in code: `crates/ui-egui/src/icons.rs`. +- UI icons drawn in code: `crates/ui-egui/src/icons.rs`, including the original filled favorite star (2026-10-11, EffectCraft contributors). - Procedural demo content (solids, shapes, gradients, text animations): `crates/engine/src/demo.rs`. - Built-in project templates (Lower Third, Title Card, Logo Reveal, Kinetic Type, Social Square, Vertical Story, Slideshow, 3D Text Orbit) built from engine commands, and their thumbnails rendered by our own renderer: diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8f6af9c2..09c8b7a1 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -175,6 +175,8 @@ page!(Appearance { dark_theme: String = "dark".into(), /// The theme used in light appearance: `light`. light_theme: String = "light".into(), + /// Theme shortcuts in the appearance context menu. + favorite_themes: Vec = Vec::new(), /// User interface brightness, -1 (darker) … 1 (lighter). brightness: f64 = 0.0, /// The size of the whole interface in percent (75–200), on top of the display's own scale. @@ -567,6 +569,8 @@ impl Prefs { a.interval_minutes = a.interval_minutes.clamp(1, 240); a.max_versions = a.max_versions.clamp(1, 99); let ap = &mut self.appearance; + let mut seen = std::collections::BTreeSet::new(); + ap.favorite_themes.retain(|id| is_choice(THEMES, id) && seen.insert(id.clone())); if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) { ap.appearance_mode = Appearance::default().appearance_mode; } @@ -623,6 +627,23 @@ impl Prefs { let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect(); return Err(format!("`{key}` expects one of {}", all.join(", "))); } + let favorites = if key == "appearance.favoriteThemes" { + Some(&value) + } else if key == "appearance" { + value.get("favoriteThemes") + } else { + None + }; + if let Some(favorites) = favorites { + let values = favorites.as_array().ok_or("favorite themes must be a list")?; + let mut seen = std::collections::BTreeSet::new(); + for value in values { + let id = value.as_str().ok_or("favorite theme must be a string")?; + if !is_choice(THEMES, id) || !seen.insert(id) { + return Err("unknown or duplicate favorite theme".into()); + } + } + } // Older clients set the single theme (by key, or inside the page object): it still picks // a visible theme, fixing the mode to its family. let legacy_theme = key == "appearance.theme" || (key == "appearance" && value.get("theme").is_some() && value.get("appearanceMode").is_none()); diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 1260c9c9..5cf14a2d 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -214,9 +214,35 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { }; let tip = tr_args("Appearance Mode: {}", &[&mode]); let r = Rect::from_min_max(pos2(rx - 26.0, cy - 13.0), pos2(rx, cy + 13.0)); - if widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip).clicked() { + let response = widgets::icon_button(ui, r, icon, false, &t, egui::Id::new("hdr-appearance")).on_hover_text(&tip); + if response.clicked() { app.cycle_appearance(ui.ctx()); } + response.context_menu(|ui| { + let settings = ui.button(tr("Appearance Settings…")); + app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); + if settings.clicked() { + crate::panels::settings::open(app, "appearance"); + ui.close(); + } + let favorites = app.session.prefs.appearance.favorite_themes.clone(); + for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { + let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); + if !group.is_empty() { + ui.separator(); + } + for (label, id) in group { + let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); + app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); + if r.clicked() { + if let Some(kind) = crate::theme::ThemeKind::from_name(id) { + app.set_theme(ui.ctx(), kind); + } + ui.close(); + } + } + } + }); app.auto.add("header.appearance", r, &tip); rx = r.min.x - 4.0; rx -= 10.0; diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f7c77905..65c4de54 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -81,6 +81,9 @@ pub(crate) fn tr_args(text: &str, args: &[&dyn Display]) -> String { /// Japanese: the same rows, in the same order, as the Chinese catalogs below (the tests keep the two in step). const JAPANESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外観設定…"), + ("Add to favorites", "お気に入りに追加"), + ("Remove from favorites", "お気に入りから削除"), (" OK ", " OK "), (" Delete ", " 削除 "), (" Open Auto-Save ", " 自動保存を開く "), @@ -511,6 +514,9 @@ const JAPANESE: &[(&str, &str)] = &[ ]; const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外观设置…"), + ("Add to favorites", "添加到收藏"), + ("Remove from favorites", "从收藏中移除"), (" OK ", " 确定 "), (" Delete ", " 删除 "), (" Open Auto-Save ", " 打开自动保存 "), @@ -1036,6 +1042,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ]; const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ + ("Appearance Settings…", "外觀設定…"), + ("Add to favorites", "加入最愛"), + ("Remove from favorites", "從最愛移除"), (" OK ", " 確定 "), (" Delete ", " 刪除 "), (" Open Auto-Save ", " 開啟自動儲存 "), @@ -1558,6 +1567,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ /// Ukrainian (uk): original translations of the same interface rows as the Chinese catalogs. /// Technical names, key names and substituted project content keep their original spelling. const UKRAINIAN: &[(&str, &str)] = &[ + ("Appearance Settings…", "Налаштування вигляду…"), + ("Add to favorites", "Додати до вибраного"), + ("Remove from favorites", "Видалити з вибраного"), (" OK ", " Гаразд "), (" Delete ", " Видалити "), (" Open Auto-Save ", " Відкрити автозбереження "), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 52066f85..41368628 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -22,6 +22,7 @@ pub enum Icon { Ellipse, Polygon, Star, + StarFilled, Pen, PenAdd, PenDelete, @@ -271,6 +272,18 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { let pts = pen.star(8.0, 8.6, 6.4, 2.7, 5); pen.closed(&pts); } + StarFilled => { + // Five triangles fill the concave star without a convex-polygon approximation. + let pts = pen.star(8.0, 8.6, 6.4, 2.7, 5); + for pair in pts.windows(2) { + if let [a, b] = pair { + pen.fill(&[(8.0, 8.6), *a, *b]); + } + } + if let (Some(first), Some(last)) = (pts.first(), pts.last()) { + pen.fill(&[(8.0, 8.6), *last, *first]); + } + } Pen => { pen.closed(&[(8.0, 1.8), (12.0, 9.0), (8.0, 14.0), (4.0, 9.0)]); pen.dot(8.0, 8.6, 1.1); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 385febef..8cb91fdd 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -331,11 +331,39 @@ fn theme_card( theme_preview(ui, kind, ui.available_width()); ui.add_space(4.0); for (label, value) in options { - let r = ui.radio(cur == *value, tr(label)); - reg(app, &format!("settings.{key}.{value}"), &r, label); - if r.clicked() && cur != *value { - acts.push(Act::Set(key.into(), json!(value))); - } + let row = egui::Rect::from_min_size(ui.cursor().min, 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(cur == *value, tr(label)); + reg(app, &format!("settings.{key}.{value}"), &r, label); + if r.clicked() && cur != *value { + acts.push(Act::Set(key.into(), json!(value))); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let mut favorites = app.session.prefs.appearance.favorite_themes.clone(); + let favorite = favorites.iter().any(|id| id == value); + let tip = tr(if favorite { "Remove from favorites" } else { "Add to favorites" }); + let r = ui.add_visible(favorite || hovered, egui::Button::new("").min_size(vec2(24.0, 24.0)).frame(false)).on_hover_text(tip); + r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("{}: {}", tip, tr(label)))); + reg(app, &format!("settings.appearance.favoriteThemes.{value}"), &r, tip); + 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 != value); + } else { + favorites.push((*value).into()); + } + acts.push(Act::Set("appearance.favoriteThemes".into(), json!(favorites))); + } + }); + }); } }); } diff --git a/crates/ui-egui/tests/ui_theme_favorites.rs b/crates/ui-egui/tests/ui_theme_favorites.rs new file mode 100644 index 00000000..1b700f51 --- /dev/null +++ b/crates/ui-egui/tests/ui_theme_favorites.rs @@ -0,0 +1,68 @@ +use effectcraft_engine::{Session, config::MemoryConfig, prefs::Prefs}; +use effectcraft_ui_egui::{EffectcraftApp, menus}; +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use std::sync::Arc; + +fn at(h: &Harness<'_, EffectcraftApp>, id: &str) -> Pos2 { + let r = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).rect; + pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) +} +fn click(h: &mut Harness<'_, EffectcraftApp>, p: Pos2, button: PointerButton) { + h.event(Event::PointerMoved(p)); + h.step(); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} +#[test] +fn favorites_validate_and_survive_settings_reload() { + let loaded = Prefs::from_json(r#"{"appearance":{"favoriteThemes":["dark","unknown","dark","light"]}}"#); + assert_eq!(loaded.appearance.favorite_themes, vec!["dark", "light"]); + let mut p = Prefs::default(); + for bad in [json!(["dark", "dark"]), json!(["unknown"]), json!([3]), json!("dark")] { + assert!(p.set("appearance.favoriteThemes", bad).is_err()); + assert!(p.appearance.favorite_themes.is_empty()); + } + p.set("appearance.favoriteThemes", json!(["light", "darker"])).unwrap(); + let loaded = Prefs::from_json(&p.to_json()); + assert_eq!(loaded.appearance.favorite_themes, p.appearance.favorite_themes); +} +#[test] +fn star_controls_save_and_favorite_menu_selects_the_family() { + let store = Arc::new(MemoryConfig::default()); + let mut session = Session { config: Some(store.clone()), ..Default::default() }; + session.execute("comp.new", json!({"width":32,"height":32,"duration":2})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(session)); + h.run_steps(4); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "app.settings", json!({"page":"appearance"})).unwrap(); + h.run_steps(3); + let p = at(&h, "settings.appearance.lightTheme.light"); + h.event(Event::PointerMoved(p)); + h.run_steps(2); + let p = at(&h, "settings.appearance.favoriteThemes.light"); + click(&mut h, p, PointerButton::Primary); + assert_eq!(h.state().session.prefs.appearance.favorite_themes, vec!["light"]); + if let Some(path) = std::env::var_os("FAVORITES_SCREENSHOT_DIR") { + std::fs::create_dir_all(&path).unwrap(); + h.render().unwrap().save(std::path::Path::new(&path).join("effectcraft-favorites.png")).unwrap(); + } + assert_eq!(h.state().session.prefs.appearance.appearance_mode, "dark"); + let p = at(&h, "settings.ok"); + click(&mut h, p, PointerButton::Primary); + let mut saved = Session { config: Some(store), ..Default::default() }; + saved.load_settings(); + assert_eq!(saved.prefs.appearance.favorite_themes, vec!["light"]); + h.state_mut().set_pref("appearance.favoriteThemes", json!(["darker", "light"])).unwrap(); + let p = at(&h, "header.appearance"); + click(&mut h, p, PointerButton::Secondary); + let p = at(&h, "header.favoriteTheme.light"); + assert!(p.y < at(&h, "header.favoriteTheme.darker").y); + click(&mut h, p, PointerButton::Primary); + assert_eq!(h.state().session.prefs.appearance.appearance_mode, "light"); + assert_eq!(h.state().session.prefs.appearance.dark_theme, "dark"); +} diff --git a/docs/preferences.md b/docs/preferences.md index 157eca99..8d35f9d8 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -57,6 +57,7 @@ Commands (CLI, MCP, control channel): - `appearance.appearanceMode`: Appearance Mode (`auto` / `dark` / `light`). `auto` (Sync with System) follows the operating system's light or dark appearance while the app runs: on Linux the desktop app reads the XDG desktop portal (`org.freedesktop.appearance` `color-scheme`) once and then listens for its `SettingChanged` signal on a worker thread, repainting only when the value changes (nothing polls); `gsettings` runs at most once at start-up, by absolute path, when the portal gives no answer. Elsewhere it uses what the window system reports. With no answer it shows the dark theme. New installs stay `dark`; Sync with System is opt-in. The Tools bar's appearance button (a monitor, sun or moon) cycles Sync with System, Light and Dark (`view.theme.toggle`, Next Appearance Mode). - `appearance.darkTheme`: Dark Theme (`dark` / `darker`), shown in dark appearance - `appearance.lightTheme`: Light Theme (`light`), shown in light appearance. Settings ▸ Appearance shows the mode above a light and a dark theme card, each with a preview of the EffectCraft window in that theme. +- `appearance.favoriteThemes`: a list of unique theme ids (`dark`, `darker`, `light`), empty by default. Hover a theme row to star it; saved favorites keep an accent-colored star. Right-click the appearance button for Appearance Settings and favorites grouped light first, then dark. Selecting a favorite fixes its appearance family and preserves the opposite family choice; left-click still cycles modes. Settings OK saves favorites and Cancel restores them. Unknown or duplicate ids are rejected by `prefs.set` and removed when loading old settings. - `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use. - `appearance.brightness`: Brightness - `appearance.uiScale`: UI Scale, the size of the whole interface (75–200 %, on top of the