mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #519 from luniiya/feat/system-appearance
This commit is contained in:
15 files changed
+686
-19
No files matched your search
@@ -118,3 +118,5 @@ under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and
|
||||
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), black) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-mark.png` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
|
||||
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | Original work: first-party ArtCraft brand mark (ArtCraft symbol ("A" mark), blue) | ArtCraft brand terms, not open source (`docs/brand/LICENSE-brand.txt`) |
|
||||
|
||||
- Appearance theme previews, drawn procedurally from original shapes: `crates/ui-egui/src/panels/settings.rs` (original work, MIT OR Apache-2.0).
|
||||
Generated
+1
@@ -1152,6 +1152,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"winit",
|
||||
"winresource",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -21,6 +21,11 @@ rfd = { workspace = true }
|
||||
cpal = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in
|
||||
# Cargo.lock (accesskit, through eframe); pinned exactly to the same version.
|
||||
zbus = "=5.19.0"
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for effectcraft.exe; build.rs only uses it when targeting Windows.
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! The desktop's light or dark appearance on Linux, for Settings ▸ Appearance ▸ Appearance Mode ▸
|
||||
//! Sync with System. Wayland compositors often report no theme to winit, so this reads the XDG
|
||||
//! desktop portal instead. Other platforms use what egui reports.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
|
||||
#[cfg(target_os = "linux")]
|
||||
const PORTAL_KEY: &str = "color-scheme";
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
|
||||
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
|
||||
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_theme(proxy: &zbus::blocking::Proxy<'_>) -> Option<egui::Theme> {
|
||||
let value: zbus::zvariant::OwnedValue = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok()?;
|
||||
decode(portal_code(value)?)
|
||||
}
|
||||
|
||||
/// The colour-scheme number inside a portal reply.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
|
||||
let mut value: zbus::zvariant::Value<'_> = value.into();
|
||||
// `Settings.Read` answers with a variant, and some portals nest another variant inside it.
|
||||
for _ in 0..4 {
|
||||
match value {
|
||||
zbus::zvariant::Value::Value(inner) => value = *inner,
|
||||
other => return u32::try_from(other).ok(),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The portal's colour scheme: 1 prefers dark, 2 prefers light, 0 (or anything else) says nothing.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn decode(code: u32) -> Option<egui::Theme> {
|
||||
match code {
|
||||
1 => Some(egui::Theme::Dark),
|
||||
2 => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback for desktops without the settings portal. It runs once at start-up, by absolute path,
|
||||
/// and is never repeated.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn gtk_theme() -> Option<egui::Theme> {
|
||||
let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?;
|
||||
let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_gtk_scheme(s: &str) -> Option<egui::Theme> {
|
||||
match s.trim().trim_matches('\'') {
|
||||
"prefer-dark" => Some(egui::Theme::Dark),
|
||||
"prefer-light" => Some(egui::Theme::Light),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn code_of(theme: Option<egui::Theme>) -> u8 {
|
||||
match theme {
|
||||
Some(egui::Theme::Dark) => 1,
|
||||
Some(egui::Theme::Light) => 2,
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the colour scheme once, then follows the portal's `SettingChanged` signal. There is no
|
||||
/// polling: the worker sleeps in the signal iterator and wakes the UI only when the value changes.
|
||||
pub fn service() -> Option<effectcraft_ui_egui::SystemThemeFn> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::sync::{
|
||||
Arc, OnceLock,
|
||||
atomic::{AtomicU8, Ordering},
|
||||
};
|
||||
let value = Arc::new(AtomicU8::new(0));
|
||||
let wake: Arc<OnceLock<egui::Context>> = Arc::new(OnceLock::new());
|
||||
let (worker_value, worker_wake) = (Arc::clone(&value), Arc::clone(&wake));
|
||||
let (ready, wait) = std::sync::mpsc::channel();
|
||||
if std::thread::Builder::new()
|
||||
.name("appearance-portal".into())
|
||||
.spawn(move || {
|
||||
let proxy = portal_proxy();
|
||||
// Subscribe before reading so a change during startup stays queued.
|
||||
let signals = proxy.as_ref().and_then(|p| p.receive_signal("SettingChanged").ok());
|
||||
let initial = proxy.as_ref().and_then(portal_theme).or_else(gtk_theme);
|
||||
// Startup can finish after the UI's bounded wait: wake its first frame too.
|
||||
if worker_value.swap(code_of(initial), Ordering::Relaxed) != code_of(initial)
|
||||
&& let Some(ctx) = worker_wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
let _ = ready.send(());
|
||||
let Some(signals) = signals else { return };
|
||||
for message in signals {
|
||||
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
|
||||
continue;
|
||||
};
|
||||
if namespace != PORTAL_NAMESPACE || key != PORTAL_KEY {
|
||||
continue;
|
||||
}
|
||||
let code = code_of(portal_code(changed).and_then(decode));
|
||||
if worker_value.swap(code, Ordering::Relaxed) != code
|
||||
&& let Some(ctx) = worker_wake.get()
|
||||
{
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
// The first frame already has the answer when the portal replies quickly.
|
||||
let _ = wait.recv_timeout(std::time::Duration::from_millis(250));
|
||||
Some(Box::new(move |ctx| {
|
||||
let _ = wake.set(ctx.clone());
|
||||
decode(u32::from(value.load(Ordering::Relaxed)))
|
||||
}))
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn portal_and_gsettings_values_decode() {
|
||||
assert_eq!(super::decode(0), None);
|
||||
assert_eq!(super::decode(1), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::decode(2), Some(egui::Theme::Light));
|
||||
assert_eq!(super::decode(9), None);
|
||||
let nested = zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::Value(Box::new(zbus::zvariant::Value::U32(2)))));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(nested).unwrap()), Some(2));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::from(1u32)), Some(1));
|
||||
assert_eq!(super::portal_code(zbus::zvariant::OwnedValue::try_from(zbus::zvariant::Value::from("dark")).unwrap()), None);
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-light'\n"), Some(egui::Theme::Light));
|
||||
assert_eq!(super::parse_gtk_scheme("'prefer-dark'\n"), Some(egui::Theme::Dark));
|
||||
assert_eq!(super::parse_gtk_scheme("'default'\n"), None);
|
||||
assert_eq!(super::code_of(super::decode(2)), 2);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
// Built everywhere so its tests run on every platform; only Linux AppImages use it.
|
||||
mod appearance;
|
||||
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
|
||||
mod appimage;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -196,6 +197,7 @@ fn main() -> eframe::Result {
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.system_theme = appearance::service();
|
||||
app.hooks.audio_device = Some(Box::new(audio_out::open));
|
||||
app.hooks.audio_devices = Some(Box::new(audio_out::devices));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
|
||||
+106
-2
@@ -32,11 +32,28 @@ pub const UI_SCALES: &[(&str, &str)] = &[("75%", "75"), ("100%", "100"), ("125%"
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
pub const WINDOW_GRAPHICS: &[(&str, &str)] = &[("Automatic", "auto"), ("OpenGL (compatibility)", "gl")];
|
||||
|
||||
/// Settings ▸ Appearance ▸ Appearance Mode: (label, `appearance.appearanceMode` value). Auto
|
||||
/// follows the operating system's light or dark appearance.
|
||||
pub const APPEARANCE_MODES: &[(&str, &str)] = &[("Sync with System", "auto"), ("Dark", "dark"), ("Light", "light")];
|
||||
|
||||
/// The dark family of themes: (label, `appearance.darkTheme` value).
|
||||
pub const DARK_THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker")];
|
||||
|
||||
/// The light family of themes: (label, `appearance.lightTheme` value).
|
||||
pub const LIGHT_THEMES: &[(&str, &str)] = &[("Light", "light")];
|
||||
|
||||
/// Every theme (the legacy single-theme `appearance.theme`).
|
||||
pub const THEMES: &[(&str, &str)] = &[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")];
|
||||
|
||||
/// The settings whose value must be one of their choices.
|
||||
fn choices(key: &str) -> Option<&'static [(&'static str, &'static str)]> {
|
||||
match key {
|
||||
"general.language" => Some(LANGUAGES),
|
||||
"startup.windowGraphics" => Some(WINDOW_GRAPHICS),
|
||||
"appearance.appearanceMode" => Some(APPEARANCE_MODES),
|
||||
"appearance.darkTheme" => Some(DARK_THEMES),
|
||||
"appearance.lightTheme" => Some(LIGHT_THEMES),
|
||||
"appearance.theme" => Some(THEMES),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -140,8 +157,16 @@ page!(Previews {
|
||||
});
|
||||
|
||||
page!(Appearance {
|
||||
/// `dark`, `darker` or `light`.
|
||||
/// The single theme before appearance modes (`dark`, `darker` or `light`), kept for older
|
||||
/// settings files and automation: setting it picks its family's mode and theme. While the mode
|
||||
/// is Dark or Light it mirrors the theme in use.
|
||||
theme: String = "dark".into(),
|
||||
/// `auto` (follow the operating system), `dark` or `light`. New installs stay Dark.
|
||||
appearance_mode: String = "dark".into(),
|
||||
/// The theme used in dark appearance: `dark` or `darker`.
|
||||
dark_theme: String = "dark".into(),
|
||||
/// The theme used in light appearance: `light`.
|
||||
light_theme: String = "light".into(),
|
||||
/// 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.
|
||||
@@ -412,10 +437,61 @@ pub fn migrate(mut v: Value) -> Value {
|
||||
obj.insert("labels".into(), serde_json::to_value(labels).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
// Settings saved before appearance modes had one theme: keep showing it, as a fixed Dark or
|
||||
// Light mode, instead of switching established users to Auto.
|
||||
if let Some(a) = obj.get_mut("appearance").and_then(Value::as_object_mut)
|
||||
&& !a.contains_key("appearanceMode")
|
||||
&& let Some(theme) = a.get("theme").and_then(Value::as_str).map(str::to_string)
|
||||
&& let Some((mode, slot)) = theme_family(&theme)
|
||||
{
|
||||
a.insert("appearanceMode".into(), json!(mode));
|
||||
a.insert(slot.into(), json!(theme));
|
||||
}
|
||||
obj.insert("version".into(), json!(PREFS_VERSION));
|
||||
v
|
||||
}
|
||||
|
||||
/// The appearance mode and the per-mode setting a theme belongs to (`darker` → (`dark`,
|
||||
/// `darkTheme`)); `None` for an unknown theme.
|
||||
pub fn theme_family(theme: &str) -> Option<(&'static str, &'static str)> {
|
||||
if is_choice(DARK_THEMES, theme) {
|
||||
Some(("dark", "darkTheme"))
|
||||
} else if is_choice(LIGHT_THEMES, theme) {
|
||||
Some(("light", "lightTheme"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
impl Appearance {
|
||||
/// Show the legacy single `theme`: fix the mode to its family and remember it as that
|
||||
/// family's theme. An unknown theme changes nothing.
|
||||
pub fn select_theme(&mut self) {
|
||||
match theme_family(&self.theme) {
|
||||
Some((mode, "darkTheme")) => {
|
||||
self.appearance_mode = mode.into();
|
||||
self.dark_theme = self.theme.clone();
|
||||
}
|
||||
Some((mode, _)) => {
|
||||
self.appearance_mode = mode.into();
|
||||
self.light_theme = self.theme.clone();
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme to show: the light or dark family's choice by the mode; Auto follows `system_light`
|
||||
/// (`None` = the system said nothing: dark).
|
||||
pub fn resolved_theme(&self, system_light: Option<bool>) -> &str {
|
||||
let light = match self.appearance_mode.as_str() {
|
||||
"light" => true,
|
||||
"auto" => system_light == Some(true),
|
||||
_ => false,
|
||||
};
|
||||
if light { &self.light_theme } else { &self.dark_theme }
|
||||
}
|
||||
}
|
||||
|
||||
impl Prefs {
|
||||
/// Parse a settings document (migrating older layouts; unknown keys are kept, bad values
|
||||
/// fall back to defaults).
|
||||
@@ -463,6 +539,24 @@ impl Prefs {
|
||||
let a = &mut self.auto_save;
|
||||
a.interval_minutes = a.interval_minutes.clamp(1, 240);
|
||||
a.max_versions = a.max_versions.clamp(1, 99);
|
||||
let ap = &mut self.appearance;
|
||||
if !is_choice(APPEARANCE_MODES, &ap.appearance_mode) {
|
||||
ap.appearance_mode = Appearance::default().appearance_mode;
|
||||
}
|
||||
if !is_choice(DARK_THEMES, &ap.dark_theme) {
|
||||
ap.dark_theme = Appearance::default().dark_theme;
|
||||
}
|
||||
if !is_choice(LIGHT_THEMES, &ap.light_theme) {
|
||||
ap.light_theme = Appearance::default().light_theme;
|
||||
}
|
||||
// The legacy single theme mirrors the theme a fixed mode shows (Auto depends on the
|
||||
// system, so it keeps the last one).
|
||||
match ap.appearance_mode.as_str() {
|
||||
"dark" => ap.theme = ap.dark_theme.clone(),
|
||||
"light" => ap.theme = ap.light_theme.clone(),
|
||||
_ if !is_choice(THEMES, &ap.theme) => ap.theme = ap.dark_theme.clone(),
|
||||
_ => {}
|
||||
}
|
||||
self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0);
|
||||
self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200);
|
||||
let defaults = default_labels();
|
||||
@@ -502,12 +596,20 @@ impl Prefs {
|
||||
let all: Vec<&str> = c.iter().map(|(_, v)| *v).collect();
|
||||
return Err(format!("`{key}` expects one of {}", all.join(", ")));
|
||||
}
|
||||
// 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());
|
||||
let mut v = serde_json::to_value(&*self).map_err(|e| e.to_string())?;
|
||||
let ptr = format!("/{}", key.replace('.', "/"));
|
||||
let slot = v.pointer_mut(&ptr).ok_or_else(|| format!("unknown setting `{key}`"))?;
|
||||
let value = coerce(slot, value).ok_or_else(|| format!("`{key}` expects a {}", kind_name(slot)))?;
|
||||
*slot = value;
|
||||
let mut next: Prefs = serde_json::from_value(v).map_err(|e| format!("`{key}`: {e}"))?;
|
||||
let (a, b) = (&self.appearance, &next.appearance);
|
||||
let only_theme_changed = a.theme != b.theme && a.appearance_mode == b.appearance_mode && a.dark_theme == b.dark_theme && a.light_theme == b.light_theme;
|
||||
if legacy_theme || only_theme_changed {
|
||||
next.appearance.select_theme();
|
||||
}
|
||||
next.normalize();
|
||||
*self = next;
|
||||
Ok(())
|
||||
@@ -1037,7 +1139,9 @@ pub fn pages() -> Vec<Page> {
|
||||
id: "appearance",
|
||||
title: "Appearance",
|
||||
items: vec![
|
||||
s("appearance.theme", "Theme", Kind::Choice(&[("Dark", "dark"), ("Darker", "darker"), ("Light", "light")]), true),
|
||||
s("appearance.appearanceMode", "Appearance Mode", Kind::Choice(APPEARANCE_MODES), true),
|
||||
s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
|
||||
s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
|
||||
s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
|
||||
s("appearance.uiScale", "UI Scale", Kind::Choice(UI_SCALES), true),
|
||||
Section("Labels and Colors"),
|
||||
|
||||
@@ -109,6 +109,60 @@ fn ui_scale_is_a_percentage_from_75_to_200() {
|
||||
assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);
|
||||
}
|
||||
|
||||
/// Settings ▸ Appearance: new installs stay Dark (Sync with System is opt-in), and settings saved
|
||||
/// with a single theme load as a fixed mode showing that theme.
|
||||
#[test]
|
||||
fn appearance_defaults_and_legacy_theme_migrate() {
|
||||
let p = Prefs::default();
|
||||
assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light"));
|
||||
assert_eq!(p.appearance.resolved_theme(Some(true)), "dark");
|
||||
for (saved, mode, dark, light) in [("darker", "dark", "darker", "light"), ("light", "light", "dark", "light"), ("dark", "dark", "dark", "light")] {
|
||||
let p = Prefs::from_json(&json!({"version": 2, "appearance": {"theme": saved}}).to_string());
|
||||
assert_eq!(p.appearance.appearance_mode, mode, "{saved}");
|
||||
assert_eq!(p.appearance.dark_theme, dark, "{saved}");
|
||||
assert_eq!(p.appearance.light_theme, light, "{saved}");
|
||||
assert_eq!(p.appearance.theme, saved);
|
||||
}
|
||||
// The flat layout of the earliest builds migrates too.
|
||||
assert_eq!(Prefs::from_json(r#"{"theme": "light"}"#).appearance.appearance_mode, "light");
|
||||
// A file that already has a mode keeps it; garbage falls back to the defaults.
|
||||
let p = Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "dark", "appearanceMode": "auto", "darkTheme": "darker"}}"#);
|
||||
assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str()), ("auto", "darker"));
|
||||
let p = Prefs::from_json(r#"{"version": 2, "appearance": {"appearanceMode": "sepia", "darkTheme": "light", "lightTheme": 7}}"#);
|
||||
assert_eq!((p.appearance.appearance_mode.as_str(), p.appearance.dark_theme.as_str(), p.appearance.light_theme.as_str()), ("dark", "dark", "light"));
|
||||
}
|
||||
|
||||
/// The appearance settings take only their choices, survive a restart, and old clients that set
|
||||
/// the single theme still pick a visible theme without losing the other family's choice.
|
||||
#[test]
|
||||
fn appearance_choices_validate_and_legacy_theme_still_selects() {
|
||||
let mut s = Session::default();
|
||||
s.execute("prefs.set", json!({"values": {"appearance.appearanceMode": "auto", "appearance.darkTheme": "darker", "appearance.lightTheme": "light"}}))
|
||||
.unwrap();
|
||||
assert_eq!(s.prefs.appearance.appearance_mode, "auto");
|
||||
assert_eq!(s.prefs.appearance.dark_theme, "darker");
|
||||
assert_eq!(s.prefs.appearance.resolved_theme(Some(true)), "light");
|
||||
assert_eq!(s.prefs.appearance.resolved_theme(Some(false)), "darker");
|
||||
assert_eq!(s.prefs.appearance.resolved_theme(None), "darker", "no system answer: dark");
|
||||
assert_eq!(Prefs::from_json(&s.prefs.to_json()).appearance, s.prefs.appearance);
|
||||
for (key, bad) in
|
||||
[("appearance.darkTheme", "light"), ("appearance.lightTheme", "darker"), ("appearance.appearanceMode", "sepia"), ("appearance.theme", "neon")]
|
||||
{
|
||||
assert!(s.execute("prefs.set", json!({"key": key, "value": bad})).is_err(), "{key} = {bad}");
|
||||
}
|
||||
s.execute("prefs.set", json!({"key": "appearance.theme", "value": "light"})).unwrap();
|
||||
assert_eq!(s.prefs.appearance.appearance_mode, "light");
|
||||
assert_eq!(s.prefs.appearance.dark_theme, "darker");
|
||||
s.execute("prefs.set", json!({"key": "appearance", "value": {"theme": "dark"}})).unwrap();
|
||||
assert_eq!(s.prefs.appearance.appearance_mode, "dark");
|
||||
assert_eq!(s.prefs.appearance.dark_theme, "dark");
|
||||
// A fixed mode mirrors its theme in the single-theme key for old readers.
|
||||
s.execute("prefs.set", json!({"key": "appearance.appearanceMode", "value": "light"})).unwrap();
|
||||
assert_eq!(s.prefs.get("appearance.theme"), Some(json!("light")));
|
||||
s.prefs.reset(Some("appearance")).unwrap();
|
||||
assert_eq!(s.prefs.appearance, crate::prefs::Appearance::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_schema_key_exists_and_docs_list_the_todo_settings() {
|
||||
let p = Prefs::default();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The Tools bar: home, the tool slots, tool options, snapping, workspaces and the community
|
||||
//! buttons (Discord is always one click away).
|
||||
|
||||
use crate::i18n::tr;
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -198,6 +198,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
app.auto.add(&format!("header.{}", &id[4..]), r, tip);
|
||||
rx = r.min.x - 4.0;
|
||||
}
|
||||
// Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next.
|
||||
let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() {
|
||||
"auto" => (Icon::Monitor, tr("Sync with System")),
|
||||
"light" => (Icon::Sun, tr("Light")),
|
||||
_ => (Icon::Moon, tr("Dark")),
|
||||
};
|
||||
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() {
|
||||
app.cycle_appearance(ui.ctx());
|
||||
}
|
||||
app.auto.add("header.appearance", r, &tip);
|
||||
rx = r.min.x - 4.0;
|
||||
rx -= 10.0;
|
||||
p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator));
|
||||
rx -= 10.0;
|
||||
|
||||
@@ -88,6 +88,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
),
|
||||
("About EffectCraft", "关于 EffectCraft"),
|
||||
("About...", "关于..."),
|
||||
("Active", "使用中"),
|
||||
("Active Camera", "活动摄像机"),
|
||||
("Alpha", "Alpha"),
|
||||
("Adapt Feature On Every Frame", "每帧都适应特征"),
|
||||
@@ -110,6 +111,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("All six face cameras turn together; the scene is not changed.", "六个面摄像机一起旋转;场景不会改变。"),
|
||||
("Anchor:", "定位点:"),
|
||||
("Aperture", "光圈"),
|
||||
("Appearance Mode", "外观模式"),
|
||||
("Appearance Mode: {}", "外观模式:{}"),
|
||||
("Apply Dimensions:", "应用尺寸:"),
|
||||
("Apply Motion To:", "将运动应用于:"),
|
||||
("Arrange", "排列"),
|
||||
@@ -170,6 +173,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Continuous (Lock Outgoing to Incoming)", "连续(将出点锁定到入点)"),
|
||||
("Cue Point", "提示点"),
|
||||
("Current Render", "当前渲染"),
|
||||
("Dark", "深色"),
|
||||
("Dark Theme", "深色主题"),
|
||||
("Darker", "更深"),
|
||||
("Default Output:", "默认输出:"),
|
||||
("Default: {}", "默认:{}"),
|
||||
@@ -268,6 +273,8 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Leave all attributes in \"{}\"", "将所有属性保留在“{}”中"),
|
||||
("Licence: {}", "许可:{}"),
|
||||
("Ligatures", "连字"),
|
||||
("Light", "浅色"),
|
||||
("Light Theme", "浅色主题"),
|
||||
("Light Type", "灯光类型"),
|
||||
("Lighter", "更浅"),
|
||||
("Link Selected Property", "链接所选属性"),
|
||||
@@ -448,6 +455,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[
|
||||
("Stylistic Sets", "样式集"),
|
||||
("Subpixel Positioning", "次像素定位"),
|
||||
("Swap Paint Colors (X)", "交换绘画颜色(X)"),
|
||||
("Sync with System", "与系统同步"),
|
||||
("System Default", "系统默认"),
|
||||
("Table", "表格"),
|
||||
("Template name", "模板名称"),
|
||||
@@ -573,6 +581,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
),
|
||||
("About EffectCraft", "關於 EffectCraft"),
|
||||
("About...", "關於..."),
|
||||
("Active", "使用中"),
|
||||
("Active Camera", "活動攝影機"),
|
||||
("Alpha", "Alpha"),
|
||||
("Adapt Feature On Every Frame", "每個影格都適應特徵"),
|
||||
@@ -595,6 +604,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("All six face cameras turn together; the scene is not changed.", "六個面攝影機一起旋轉;場景不會改變。"),
|
||||
("Anchor:", "釘選點:"),
|
||||
("Aperture", "光圈"),
|
||||
("Appearance Mode", "外觀模式"),
|
||||
("Appearance Mode: {}", "外觀模式:{}"),
|
||||
("Apply Dimensions:", "應用尺寸:"),
|
||||
("Apply Motion To:", "將運動應用於:"),
|
||||
("Arrange", "排列"),
|
||||
@@ -655,6 +666,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Continuous (Lock Outgoing to Incoming)", "連續(將出點鎖定到入點)"),
|
||||
("Cue Point", "提示點"),
|
||||
("Current Render", "目前算圖"),
|
||||
("Dark", "深色"),
|
||||
("Dark Theme", "深色主題"),
|
||||
("Darker", "更深"),
|
||||
("Default Output:", "預設輸出:"),
|
||||
("Default: {}", "預設:{}"),
|
||||
@@ -753,6 +766,8 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Leave all attributes in \"{}\"", "將所有屬性保留在“{}”中"),
|
||||
("Licence: {}", "許可:{}"),
|
||||
("Ligatures", "連字"),
|
||||
("Light", "淺色"),
|
||||
("Light Theme", "淺色主題"),
|
||||
("Light Type", "燈光類型"),
|
||||
("Lighter", "更淺"),
|
||||
("Link Selected Property", "鏈接所選屬性"),
|
||||
@@ -933,6 +948,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[
|
||||
("Stylistic Sets", "樣式集"),
|
||||
("Subpixel Positioning", "次像素定位"),
|
||||
("Swap Paint Colors (X)", "交換繪畫顏色(X)"),
|
||||
("Sync with System", "與系統同步"),
|
||||
("System Default", "系統預設"),
|
||||
("Table", "表格"),
|
||||
("Template name", "範本名稱"),
|
||||
|
||||
@@ -112,6 +112,13 @@ pub enum Icon {
|
||||
Globe,
|
||||
Code,
|
||||
Sparkle,
|
||||
// appearance modes
|
||||
/// Sync with System: a display on a stand.
|
||||
Monitor,
|
||||
/// Light: a disc with eight rays.
|
||||
Sun,
|
||||
/// Dark: a crescent.
|
||||
Moon,
|
||||
}
|
||||
|
||||
pub struct Pen16<'a> {
|
||||
@@ -616,6 +623,24 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
|
||||
Sparkle => {
|
||||
pen.fill(&[(8.0, 1.5), (9.4, 6.6), (14.5, 8.0), (9.4, 9.4), (8.0, 14.5), (6.6, 9.4), (1.5, 8.0), (6.6, 6.6)]);
|
||||
}
|
||||
Monitor => {
|
||||
pen.rrect(1.8, 2.5, 14.2, 11.0, 1.2);
|
||||
pen.line(&[(8.0, 11.0), (8.0, 13.8)]);
|
||||
pen.line(&[(5.0, 13.8), (11.0, 13.8)]);
|
||||
}
|
||||
Sun => {
|
||||
pen.circle(8.0, 8.0, 2.8);
|
||||
for i in 0..8 {
|
||||
let a = std::f32::consts::FRAC_PI_4 * i as f32;
|
||||
let (c, s) = (a.cos(), a.sin());
|
||||
pen.line(&[(8.0 + 4.6 * c, 8.0 + 4.6 * s), (8.0 + 6.4 * c, 8.0 + 6.4 * s)]);
|
||||
}
|
||||
}
|
||||
Moon => {
|
||||
// The outer edge of a disc, and the inner edge of the disc that eclipses it.
|
||||
pen.arc(8.0, 8.0, 5.8, -20.0, 250.0);
|
||||
pen.arc(10.6, 2.4, 4.6, 52.0, 178.0);
|
||||
}
|
||||
PenAdd | PenDelete => {
|
||||
// A small pen nib with a plus or minus beside it.
|
||||
pen.closed(&[(6.0, 2.5), (9.0, 8.0), (6.0, 12.0), (3.0, 8.0)]);
|
||||
|
||||
+111
-10
@@ -92,9 +92,16 @@ pub enum Dialog {
|
||||
DeleteItems,
|
||||
}
|
||||
|
||||
/// The desktop's light or dark appearance where egui cannot see it (Wayland compositors that send
|
||||
/// winit no theme). `None` = the host does not know.
|
||||
pub type SystemThemeFn = Box<dyn Fn(&egui::Context) -> Option<egui::Theme>>;
|
||||
|
||||
/// Host hooks provided by the native app (file pickers etc.).
|
||||
#[derive(Default)]
|
||||
pub struct Hooks {
|
||||
/// The system appearance for Settings ▸ Appearance ▸ Sync with System (the desktop app reads
|
||||
/// the Linux desktop portal); without it, what egui reports.
|
||||
pub system_theme: Option<SystemThemeFn>,
|
||||
pub pick_files: Option<Box<dyn Fn(&[&str]) -> Vec<String>>>,
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
pub pick_open_project: Option<Box<dyn Fn() -> Option<String>>>,
|
||||
@@ -269,7 +276,8 @@ pub struct EffectcraftApp {
|
||||
/// Last reveal shortcut and when (double-press shortcuts such as LL).
|
||||
pub(crate) last_reveal: Option<(String, f64)>,
|
||||
/// Settings revision applied to the theme, tooltips and caches.
|
||||
applied_prefs: Option<u64>,
|
||||
/// The settings revision and system appearance (light?) last applied.
|
||||
applied_prefs: Option<(u64, Option<bool>)>,
|
||||
/// A previous run that didn't exit cleanly (shown by `Dialog::Recovery`).
|
||||
pub recovery: Option<effectcraft_engine::autosave::Recovery>,
|
||||
/// Docked groups laid out last frame: (active panel, group rect) — `~` maximizes the one
|
||||
@@ -367,13 +375,16 @@ impl EffectcraftApp {
|
||||
|
||||
/// Apply changed settings: theme and brightness, label colours, tool tips, preview caches.
|
||||
pub fn apply_prefs(&mut self, ctx: &egui::Context) {
|
||||
if self.applied_prefs == Some(self.session.prefs_revision) {
|
||||
// Sync with System re-applies when the system appearance changes (the host wakes the UI;
|
||||
// nothing polls).
|
||||
let system = if self.session.prefs.appearance.appearance_mode == "auto" { self.system_light(ctx) } else { None };
|
||||
if self.applied_prefs == Some((self.session.prefs_revision, system)) {
|
||||
return;
|
||||
}
|
||||
self.applied_prefs = Some(self.session.prefs_revision);
|
||||
self.applied_prefs = Some((self.session.prefs_revision, system));
|
||||
let p = &self.session.prefs;
|
||||
self.ui.theme = theme::ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
|
||||
self.tokens = Tokens::from_prefs(p);
|
||||
self.ui.theme = theme::ThemeKind::from_name(p.appearance.resolved_theme(system)).unwrap_or_default();
|
||||
self.tokens = Tokens::from_prefs(p, self.ui.theme);
|
||||
theme::apply_visuals(ctx, &self.tokens);
|
||||
let tips = p.general.show_tool_tips;
|
||||
ctx.all_styles_mut(|s| s.interaction.tooltip_delay = if tips { 0.5 } else { f32::INFINITY });
|
||||
@@ -399,13 +410,32 @@ impl EffectcraftApp {
|
||||
self
|
||||
}
|
||||
|
||||
/// Show theme `k`: the appearance mode becomes its family's (Dark or Light), and `k` that
|
||||
/// family's theme.
|
||||
pub fn set_theme(&mut self, ctx: &egui::Context, k: theme::ThemeKind) {
|
||||
let name = match k {
|
||||
theme::ThemeKind::Dark => "dark",
|
||||
theme::ThemeKind::Darker => "darker",
|
||||
theme::ThemeKind::Light => "light",
|
||||
if let Err(e) = self.set_pref("appearance.theme", json!(k.name())) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
self.apply_prefs(ctx);
|
||||
}
|
||||
|
||||
/// The system appearance: light (`Some(true)`), dark, or unknown. The host's answer (the Linux
|
||||
/// desktop portal) wins over egui's, which Wayland compositors often leave empty.
|
||||
pub fn system_light(&self, ctx: &egui::Context) -> Option<bool> {
|
||||
self.hooks.system_theme.as_ref().and_then(|read| read(ctx)).or_else(|| ctx.system_theme()).map(|t| t == egui::Theme::Light)
|
||||
}
|
||||
|
||||
/// The Tools bar's appearance button (`view.theme.toggle`): Sync with System, then Light, then
|
||||
/// Dark, then Sync with System again. The light and dark theme choices are kept.
|
||||
pub fn cycle_appearance(&mut self, ctx: &egui::Context) {
|
||||
let next = match self.session.prefs.appearance.appearance_mode.as_str() {
|
||||
"auto" => "light",
|
||||
"light" => "dark",
|
||||
_ => "auto",
|
||||
};
|
||||
let _ = self.set_pref("appearance.theme", json!(name));
|
||||
if let Err(e) = self.set_pref("appearance.appearanceMode", json!(next)) {
|
||||
self.ui.status = e;
|
||||
}
|
||||
self.apply_prefs(ctx);
|
||||
}
|
||||
|
||||
@@ -1747,3 +1777,74 @@ mod gpu_failure_tests {
|
||||
assert_eq!(app.session.render(comp, Tick::ZERO, RenderOpts::default()).data, before.data);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod appearance_tests {
|
||||
use super::*;
|
||||
use crate::theme::ThemeKind;
|
||||
|
||||
fn app() -> EffectcraftApp {
|
||||
EffectcraftApp::new(effectcraft_engine::Session::default())
|
||||
}
|
||||
|
||||
/// The Tools bar's appearance button: Sync with System, Light, Dark, then Sync with System
|
||||
/// again, showing the saved theme of each family without losing either choice.
|
||||
#[test]
|
||||
fn appearance_button_cycles_modes_without_losing_theme_choices() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.hooks.system_theme = Some(Box::new(|_| Some(egui::Theme::Dark)));
|
||||
app.set_pref("appearance.darkTheme", json!("darker")).unwrap();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Darker);
|
||||
for (mode, shown) in [("auto", ThemeKind::Darker), ("light", ThemeKind::Light), ("dark", ThemeKind::Darker), ("auto", ThemeKind::Darker)] {
|
||||
menus::invoke(&mut app, &ctx, "view.theme.toggle", json!({})).unwrap();
|
||||
assert_eq!(app.session.prefs.appearance.appearance_mode, mode);
|
||||
assert_eq!(app.ui.theme, shown, "{mode}");
|
||||
assert_eq!(app.tokens.kind, shown);
|
||||
assert_eq!(app.session.prefs.appearance.dark_theme, "darker");
|
||||
assert_eq!(app.session.prefs.appearance.light_theme, "light");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sync with System follows the host's answer (the Linux portal) when it changes, and falls
|
||||
/// back to Dark when nothing reports a system appearance.
|
||||
#[test]
|
||||
fn auto_follows_a_stubbed_system_theme() {
|
||||
use std::sync::{Arc, Mutex};
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
let system = Arc::new(Mutex::new(Some(egui::Theme::Light)));
|
||||
let read = Arc::clone(&system);
|
||||
app.hooks.system_theme = Some(Box::new(move |_| *read.lock().unwrap_or_else(std::sync::PoisonError::into_inner)));
|
||||
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Light);
|
||||
*system.lock().unwrap() = Some(egui::Theme::Dark);
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Dark, "a system change applies without a settings change");
|
||||
*system.lock().unwrap() = None;
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Dark);
|
||||
// A fixed mode ignores the system.
|
||||
*system.lock().unwrap() = Some(egui::Theme::Light);
|
||||
app.set_pref("appearance.appearanceMode", json!("dark")).unwrap();
|
||||
app.apply_prefs(&ctx);
|
||||
assert_eq!(app.ui.theme, ThemeKind::Dark);
|
||||
}
|
||||
|
||||
/// Picking a theme (`view.theme.*`, `ui.set {theme}`) fixes the mode to its family.
|
||||
#[test]
|
||||
fn picking_a_theme_fixes_its_mode() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = app();
|
||||
app.set_pref("appearance.appearanceMode", json!("auto")).unwrap();
|
||||
menus::invoke(&mut app, &ctx, "view.theme.light", json!({})).unwrap();
|
||||
assert_eq!(app.session.prefs.appearance.appearance_mode, "light");
|
||||
assert_eq!(app.ui.theme, ThemeKind::Light);
|
||||
menus::invoke(&mut app, &ctx, "view.theme.darker", json!({})).unwrap();
|
||||
assert_eq!(app.session.prefs.appearance.appearance_mode, "dark");
|
||||
assert_eq!(app.session.prefs.appearance.dark_theme, "darker");
|
||||
assert_eq!(app.ui.theme, ThemeKind::Darker);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
uic!("view.safeMargins", "Title/Action Safe", [], None),
|
||||
uic!("view.transparencyGrid", "Transparency Grid", [], None),
|
||||
uic!("view.fastPreviews", "Fast Previews", [], None),
|
||||
uic!("view.theme.toggle", "Next Appearance Mode", [], None),
|
||||
uic!("view.theme.dark", "Theme: Dark", [], None),
|
||||
uic!("view.theme.darker", "Theme: Darker", [], None),
|
||||
uic!("view.theme.light", "Theme: Light", [], None),
|
||||
@@ -471,6 +472,10 @@ pub fn invoke(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, params: V
|
||||
app.ui.viewer.res = Resolution::Auto;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if id == "view.theme.toggle" {
|
||||
app.cycle_appearance(ctx);
|
||||
return Ok(json!({"appearanceMode": app.session.prefs.appearance.appearance_mode}));
|
||||
}
|
||||
if let Some(th) = id.strip_prefix("view.theme.") {
|
||||
let k = crate::theme::ThemeKind::from_name(th).ok_or("unknown theme")?;
|
||||
app.set_theme(ctx, k);
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
//! `settings.cancel`, `settings.previous`, `settings.next`.
|
||||
|
||||
use crate::i18n::{tr, tr_args};
|
||||
use effectcraft_engine::prefs::{Item, Kind, Page, pages};
|
||||
use effectcraft_engine::prefs::{APPEARANCE_MODES, DARK_THEMES, Item, Kind, LIGHT_THEMES, Page, pages};
|
||||
use effectcraft_engine::segment::Task;
|
||||
use egui::{Color32, RichText, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
use crate::theme::{ThemeKind, Tokens};
|
||||
use crate::{Dialog, EffectcraftApp};
|
||||
|
||||
/// Open the dialog on a page, remembering the settings for Cancel.
|
||||
@@ -88,6 +88,10 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
ui.label(RichText::new(tr("No device list from this host; the system default output is used.")).color(t.text_dim));
|
||||
}
|
||||
}
|
||||
// Appearance Mode with the light and dark theme cards (the per-mode themes are
|
||||
// chosen on the cards).
|
||||
Item::Setting { key: "appearance.appearanceMode", .. } => appearance_ui(app, ui, cur, t, acts),
|
||||
Item::Setting { key: "appearance.lightTheme" | "appearance.darkTheme", .. } => {}
|
||||
Item::Setting { key, label, kind, live } => {
|
||||
let v = get(key);
|
||||
let id = format!("settings.{key}");
|
||||
@@ -201,6 +205,166 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
|
||||
}
|
||||
}
|
||||
|
||||
/// A small EffectCraft window painted with theme `kind`'s tokens: the Tools bar, the Project
|
||||
/// panel, the Composition panel, the Preview and Properties column and the Timeline with its
|
||||
/// layer bars.
|
||||
fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
|
||||
use egui::{Rect, pos2};
|
||||
let p = Tokens::for_kind(kind);
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, 118.0), egui::Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
painter.rect_filled(rect, 3.0, p.app_bg);
|
||||
let bar = Rect::from_min_size(rect.min, vec2(rect.width(), 13.0));
|
||||
painter.rect_filled(bar, 0.0, p.header_bg);
|
||||
// The brand mark, the tools (the Selection tool is active) and the Discord pill.
|
||||
painter.rect_filled(Rect::from_min_size(bar.min + vec2(4.0, 3.0), vec2(7.0, 7.0)), 2.0, p.accent);
|
||||
for i in 0..7 {
|
||||
let c = if i == 0 { p.accent } else { p.icon };
|
||||
painter.rect_filled(Rect::from_min_size(bar.min + vec2(16.0 + i as f32 * 8.0, 4.0), vec2(5.0, 5.0)), 1.0, c);
|
||||
}
|
||||
painter.rect_filled(
|
||||
Rect::from_min_max(pos2(bar.right() - 22.0, bar.top() + 3.0), pos2(bar.right() - 4.0, bar.bottom() - 3.0)),
|
||||
3.5,
|
||||
egui::Color32::from_rgb(0x58, 0x65, 0xf2),
|
||||
);
|
||||
let gap = 2.0;
|
||||
let body = Rect::from_min_max(pos2(rect.left() + gap, bar.bottom() + gap), pos2(rect.right() - gap, rect.bottom() - gap));
|
||||
let right_w = (body.width() * 0.22).round();
|
||||
let timeline_h = (body.height() * 0.42).round();
|
||||
let left_w = (body.width() * 0.2).round();
|
||||
let upper = Rect::from_min_max(body.min, pos2(body.right() - right_w - gap, body.bottom() - timeline_h - gap));
|
||||
let panel = |r: Rect, title_w: f32| {
|
||||
painter.rect_filled(r, 2.0, p.panel_bg);
|
||||
painter.rect_filled(Rect::from_min_size(r.min + vec2(4.0, 3.0), vec2(title_w, 2.0)), 1.0, p.tab_text_active);
|
||||
};
|
||||
// Project panel: rows with label swatches.
|
||||
let project = Rect::from_min_max(upper.min, pos2(upper.left() + left_w, upper.bottom()));
|
||||
panel(project, 14.0);
|
||||
for (i, label) in [5usize, 5, 2, 2].into_iter().enumerate() {
|
||||
let y = project.top() + 10.0 + i as f32 * 6.0;
|
||||
painter.rect_filled(Rect::from_min_size(pos2(project.left() + 4.0, y), vec2(3.0, 3.0)), 0.5, p.labels.get(label).copied().unwrap_or(p.icon));
|
||||
painter.rect_filled(Rect::from_min_size(pos2(project.left() + 9.0, y + 0.5), vec2(project.width() * 0.45, 2.0)), 1.0, p.text_dim);
|
||||
}
|
||||
// Composition panel: the pasteboard and the comp frame with a title.
|
||||
let comp = Rect::from_min_max(pos2(project.right() + gap, upper.top()), upper.max);
|
||||
panel(comp, 22.0);
|
||||
let paste = Rect::from_min_max(comp.min + vec2(2.0, 8.0), comp.max - vec2(2.0, 2.0));
|
||||
painter.rect_filled(paste, 0.0, p.pasteboard);
|
||||
let frame_h = (paste.height() - 6.0).max(4.0);
|
||||
let frame = Rect::from_center_size(paste.center(), vec2((frame_h * 16.0 / 9.0).min(paste.width() - 6.0), frame_h));
|
||||
painter.rect_filled(frame, 0.0, egui::Color32::from_rgb(0x14, 0x1c, 0x4a));
|
||||
painter.circle_stroke(frame.center(), frame.height() * 0.36, egui::Stroke::new(0.8, egui::Color32::from_rgb(0x4a, 0x7c, 0xe8)));
|
||||
painter.rect_filled(Rect::from_center_size(frame.center(), vec2(frame.width() * 0.5, 2.5)), 1.0, egui::Color32::WHITE);
|
||||
// Preview and Properties.
|
||||
let column = Rect::from_min_max(pos2(body.right() - right_w, body.top()), body.max);
|
||||
let preview = Rect::from_min_max(column.min, pos2(column.right(), column.top() + column.height() * 0.3));
|
||||
panel(preview, 12.0);
|
||||
painter.rect_filled(Rect::from_min_size(preview.left_bottom() + vec2(4.0, -5.0), vec2(preview.width() * 0.25, 1.5)), 0.5, p.cache_green);
|
||||
let props = Rect::from_min_max(pos2(column.left(), preview.bottom() + gap), column.max);
|
||||
panel(props, 18.0);
|
||||
for i in 0..5 {
|
||||
let y = props.top() + 10.0 + i as f32 * 6.0;
|
||||
painter.rect_filled(Rect::from_min_size(pos2(props.left() + 4.0, y), vec2(props.width() * 0.35, 2.0)), 1.0, p.text_dim);
|
||||
painter.rect_filled(Rect::from_min_size(pos2(props.left() + props.width() * 0.55, y), vec2(props.width() * 0.25, 2.0)), 1.0, p.hot_text);
|
||||
}
|
||||
// Timeline: the layer list and the time graph, with coloured layer bars and the time marker.
|
||||
let timeline = Rect::from_min_max(pos2(body.left(), upper.bottom() + gap), pos2(upper.right(), body.bottom()));
|
||||
panel(timeline, 18.0);
|
||||
painter.rect_filled(Rect::from_min_size(timeline.min + vec2(4.0, 8.0), vec2(14.0, 3.0)), 1.0, p.timecode);
|
||||
let graph = Rect::from_min_max(pos2(timeline.left() + timeline.width() * 0.38, timeline.top() + 8.0), timeline.max - vec2(2.0, 2.0));
|
||||
painter.rect_filled(graph, 0.0, p.tl_bg);
|
||||
painter.rect_filled(Rect::from_min_size(graph.min, vec2(graph.width(), 4.0)), 0.0, p.tl_ruler_bg);
|
||||
for (i, label) in [1usize, 1, 8, 8, 1].into_iter().enumerate() {
|
||||
let y = graph.top() + 7.0 + i as f32 * 5.5;
|
||||
if y + 3.5 > graph.bottom() {
|
||||
break;
|
||||
}
|
||||
let swatch = p.labels.get(label).copied().unwrap_or(p.icon);
|
||||
let row_l = timeline.left() + 4.0;
|
||||
if i == 1 {
|
||||
painter.rect_filled(Rect::from_min_max(pos2(timeline.left(), y - 1.0), pos2(graph.left(), y + 4.5)), 0.0, p.row_selected);
|
||||
}
|
||||
painter.rect_filled(Rect::from_min_size(pos2(row_l, y), vec2(3.0, 3.0)), 0.5, swatch);
|
||||
painter.rect_filled(Rect::from_min_size(pos2(row_l + 6.0, y + 0.5), vec2(graph.left() - row_l - 14.0, 2.0)), 1.0, p.text_dim);
|
||||
let bar = Rect::from_min_max(pos2(graph.left() + 1.0, y), pos2(graph.right() - 1.0, y + 3.5));
|
||||
painter.rect_filled(bar, 1.0, swatch.gamma_multiply(0.8));
|
||||
}
|
||||
let cti = graph.left() + graph.width() * 0.3;
|
||||
painter.line_segment([pos2(cti, graph.top()), pos2(cti, graph.bottom())], egui::Stroke::new(1.0, p.cti));
|
||||
}
|
||||
|
||||
/// One theme family's card: its (translated) name, Active when the appearance shows it now, a preview of the
|
||||
/// chosen theme and a radio button per theme.
|
||||
fn theme_card(
|
||||
app: &mut EffectcraftApp,
|
||||
ui: &mut egui::Ui,
|
||||
t: &Tokens,
|
||||
key: &str,
|
||||
title: &str,
|
||||
active: bool,
|
||||
cur: &str,
|
||||
options: &[(&str, &str)],
|
||||
acts: &mut Vec<Act>,
|
||||
) {
|
||||
egui::Frame::new()
|
||||
.fill(t.field_bg)
|
||||
.stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border }))
|
||||
.corner_radius(6.0)
|
||||
.inner_margin(egui::Margin::same(8))
|
||||
.show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(title).font(Tokens::semibold(12.5)).color(t.text));
|
||||
if active {
|
||||
ui.label(RichText::new(tr("Active")).color(t.accent).small());
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let kind = ThemeKind::from_name(cur).unwrap_or(if key.ends_with("lightTheme") { ThemeKind::Light } else { ThemeKind::Dark });
|
||||
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)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Settings ▸ Appearance: Appearance Mode (Sync with System, Dark, Light) above the light and dark
|
||||
/// theme cards.
|
||||
fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &Tokens, acts: &mut Vec<Act>) {
|
||||
let get = |key: &str| cur.pointer(&format!("/appearance/{key}")).and_then(Value::as_str).unwrap_or_default().to_string();
|
||||
let (mode, light, dark) = (get("appearanceMode"), get("lightTheme"), get("darkTheme"));
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(250.0, 18.0), egui::Label::new(format!("{}:", tr("Appearance Mode"))).truncate());
|
||||
let shown = APPEARANCE_MODES.iter().find(|(_, v)| *v == mode).map_or(mode.as_str(), |(l, _)| *l);
|
||||
let r = egui::ComboBox::from_id_salt(("settings", "appearance.appearanceMode"))
|
||||
.selected_text(tr(shown))
|
||||
.width(220.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for (l, v) in APPEARANCE_MODES {
|
||||
if ui.selectable_label(*v == mode, tr(l)).clicked() {
|
||||
acts.push(Act::Set("appearance.appearanceMode".into(), json!(v)));
|
||||
}
|
||||
}
|
||||
})
|
||||
.response;
|
||||
reg(app, "settings.appearance.appearanceMode", &r, "Appearance Mode");
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
let system_light = app.system_light(ui.ctx());
|
||||
let light_active = mode == "light" || (mode == "auto" && system_light == Some(true));
|
||||
ui.columns(2, |cols| {
|
||||
let [l, d] = cols else { return };
|
||||
theme_card(app, l, t, "appearance.lightTheme", tr("Light Theme"), light_active, &light, LIGHT_THEMES, acts);
|
||||
theme_card(app, d, t, "appearance.darkTheme", tr("Dark Theme"), !light_active, &dark, DARK_THEMES, acts);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// Bytes as "12.3 MB".
|
||||
pub fn human_bytes(b: u64) -> String {
|
||||
let units = ["bytes", "KB", "MB", "GB", "TB"];
|
||||
|
||||
@@ -26,6 +26,19 @@ impl ThemeKind {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings value (`appearance.theme`, `appearance.darkTheme`, `appearance.lightTheme`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
ThemeKind::Dark => "dark",
|
||||
ThemeKind::Darker => "darker",
|
||||
ThemeKind::Light => "light",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_light(self) -> bool {
|
||||
self == ThemeKind::Light
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -213,8 +226,9 @@ impl Tokens {
|
||||
}
|
||||
|
||||
/// Theme tokens for the current settings: theme, UI brightness and label colours.
|
||||
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs) -> Tokens {
|
||||
let kind = ThemeKind::from_name(&p.appearance.theme).unwrap_or_default();
|
||||
/// The tokens for theme `kind` (resolved from the appearance mode) with the rest of the
|
||||
/// Appearance settings: brightness, label colours, gradients.
|
||||
pub fn from_prefs(p: &effectcraft_engine::prefs::Prefs, kind: ThemeKind) -> Tokens {
|
||||
let mut t = Tokens::for_kind(kind).with_brightness(p.appearance.brightness as f32);
|
||||
for (i, l) in effectcraft_color::Label::ALL.iter().enumerate() {
|
||||
let [r, g, b] = p.label_rgb(*l);
|
||||
@@ -387,7 +401,11 @@ fn is_chinese(language: &str) -> bool {
|
||||
}
|
||||
|
||||
pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
||||
let mut v = if t.kind == ThemeKind::Light { Visuals::light() } else { Visuals::dark() };
|
||||
let light = t.kind.is_light();
|
||||
// egui keeps one style per light/dark theme and by default picks it by the system appearance;
|
||||
// pin it to ours so a fixed Dark or Light mode isn't swapped for egui's defaults.
|
||||
ctx.set_theme(if light { egui::ThemePreference::Light } else { egui::ThemePreference::Dark });
|
||||
let mut v = if light { Visuals::light() } else { Visuals::dark() };
|
||||
v.panel_fill = t.panel_bg;
|
||||
v.window_fill = t.panel_bg;
|
||||
v.extreme_bg_color = t.field_bg;
|
||||
|
||||
+4
-1
@@ -54,7 +54,10 @@ Commands (CLI, MCP, control channel):
|
||||
without input or edits, the viewer renders the work area into the RAM preview in the
|
||||
background, from the current time on, until it is cached or the budget is full
|
||||
- `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects)
|
||||
- `appearance.theme`: Theme
|
||||
- `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.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
|
||||
display's own scaling), applied as soon as it is chosen
|
||||
|
||||
Reference in new issue
Block a user