Skip to main content

blockworx/
preferences.rs

1//! User-facing appearance preferences: colour scheme, light/dark mode and UI
2//! font. The choices themselves are [`blockworx_paint`]'s; what is here is how
3//! they are persisted (the eframe storage DB, applied live each frame — see
4//! [`crate::app`]) and who a session attributes its commits to. The menu UI
5//! that edits them lives in [`crate::preferences_menu`].
6
7use serde::{Deserialize, Serialize};
8
9use blockworx_paint::{FontChoice, Mode, Palette, Scheme};
10
11/// The persisted preferences. `#[serde(default)]` lets a stored blob
12/// from an older build (missing a field) load, filling the rest from [`Default`].
13/// A stored blob may carry a `widget_size` from the retired zoom preference,
14/// which this build has no field for; serde drops an unknown key, so it is
15/// left behind rather than migrated.
16#[derive(Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
17#[serde(default)]
18pub struct Preferences {
19    #[serde(alias = "scheme")]
20    pub theme: Scheme,
21    pub mode: Mode,
22    pub font: FontChoice,
23    /// The attribution identity, overriding what the environment says this
24    /// user is called. `None` — the only value anything writes today — means
25    /// "ask the environment"; the field exists so that a profile editor needs
26    /// no format migration when it arrives.
27    pub author_name: Option<String>,
28}
29
30impl Preferences {
31    /// The palette to actually display: the selected theme in the light/dark
32    /// variant chosen by [`Mode`] (and, for `System`, the OS preference).
33    pub fn palette(&self, system_dark: Option<bool>) -> Palette {
34        self.theme.palette(self.mode.is_dark(system_dark).into())
35    }
36
37    /// Who this session's commits are attributed to: the stored profile name
38    /// if one has been set, and the environment's otherwise.
39    pub fn identity(&self) -> blockworx_store::record::Identity {
40        match self.author_name.as_deref().map(str::trim) {
41            Some(name) if !name.is_empty() => blockworx_store::record::Identity::new(name),
42            _ => blockworx_store::record::Identity::from_environment(),
43        }
44    }
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn palette_picks_variant_from_mode_and_os() {
53        let prefs = Preferences {
54            theme: Scheme::Ayu,
55            mode: Mode::System,
56            ..Default::default()
57        };
58        assert!(!prefs.palette(Some(false)).is_dark());
59        assert!(prefs.palette(Some(true)).is_dark());
60    }
61
62    #[test]
63    fn preferences_round_trip_through_json() {
64        let prefs = Preferences {
65            theme: Scheme::RosePine,
66            mode: Mode::Light,
67            font: FontChoice::Monospace,
68            author_name: Some("Ada Lovelace".to_owned()),
69        };
70        let json = serde_json::to_string(&prefs).unwrap();
71        assert_eq!(serde_json::from_str::<Preferences>(&json).unwrap(), prefs);
72    }
73
74    /// The stored profile name overrides the environment, and a blank one is
75    /// not a name — it would attribute a decades-lived document to nobody
76    /// while looking like it had an author.
77    #[test]
78    fn the_stored_profile_name_overrides_the_environment() {
79        let named = Preferences {
80            author_name: Some("Ada Lovelace".to_owned()),
81            ..Default::default()
82        };
83        assert_eq!(named.identity().name, "Ada Lovelace");
84
85        let blank = Preferences {
86            author_name: Some("   ".to_owned()),
87            ..Default::default()
88        };
89        assert_eq!(
90            blank.identity(),
91            Preferences::default().identity(),
92            "a blank profile name falls back to the environment",
93        );
94    }
95
96    #[test]
97    fn partial_stored_blob_fills_missing_fields_from_default() {
98        // A blob from an older build missing later fields still loads, and the
99        // `theme` field is read from the stored `scheme` key via the serde alias.
100        let prefs: Preferences = serde_json::from_str(r#"{"scheme":"TokyoNight"}"#).unwrap();
101        assert_eq!(prefs.theme, Scheme::TokyoNight);
102        assert_eq!(prefs.mode, Mode::default());
103        assert_eq!(prefs.font, FontChoice::default());
104    }
105
106    /// A session whose blob carries the retired zoom preference opens on
107    /// everything else it kept rather than refusing to load at all.
108    #[test]
109    fn a_stored_widget_size_is_dropped_and_the_rest_of_the_blob_still_loads() {
110        let prefs: Preferences =
111            serde_json::from_str(r#"{"scheme":"Ayu","widget_size":"Large","font":"Formal"}"#)
112                .expect("a blob carrying the retired zoom must still load");
113        assert_eq!(prefs.theme, Scheme::Ayu);
114        assert_eq!(prefs.font, FontChoice::Formal);
115        assert!(
116            !serde_json::to_string(&prefs)
117                .unwrap()
118                .contains("widget_size"),
119            "the retired zoom was written back out",
120        );
121    }
122}