mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #826 from luniiya/feat/theme-favorites
UI: favorite themes and appearance shortcuts
This commit is contained in:
commit
30f7ce8ae5
8 files changed
+176
-7
No files matched your search
+1
-1
@@ -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:
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ", " Відкрити автозбереження "),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user