mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 21:48:20 -06:00
Merge pull request #519 from luniiya/feat/system-appearance
This commit is contained in:
+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();
|
||||
|
||||
Reference in New Issue
Block a user