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

UI: favorite themes and appearance shortcuts
This commit is contained in:
Brandon Thomas committed 2026-10-12 03:23:06 -04:00
commit 30f7ce8ae5
8 files changed
+176 -7

No files matched your search

+1 -1
View File
@@ -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:
+21
View File
@@ -172,6 +172,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<String> = 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.
@@ -564,6 +566,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;
}
@@ -620,6 +624,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());
+27 -1
View File
@@ -216,9 +216,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;
+12
View File
@@ -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 ", " 自動保存を開く "),
@@ -521,6 +524,9 @@ const JAPANESE: &[(&str, &str)] = &[
];
const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
("Appearance Settings…", "外观设置…"),
("Add to favorites", "添加到收藏"),
("Remove from favorites", "从收藏中移除"),
(" OK ", " 确定 "),
(" Delete ", " 删除 "),
(" Open Auto-Save ", " 打开自动保存 "),
@@ -1053,6 +1059,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
];
const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
("Appearance Settings…", "外觀設定…"),
("Add to favorites", "加入最愛"),
("Remove from favorites", "從最愛移除"),
(" OK ", " 確定 "),
(" Delete ", " 刪除 "),
(" Open Auto-Save ", " 開啟自動儲存 "),
@@ -1582,6 +1591,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 ", " Відкрити автозбереження "),
+13
View File
@@ -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);
+33 -5
View File
@@ -333,11 +333,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)));
}
});
});
}
});
}
@@ -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");
}
+1
View File
@@ -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 (any whole percentage from