Skip to main content

blockworx_web/
prefs.rs

1//! What the page remembers about how it is drawn, and who it attributes its
2//! work to.
3//!
4//! Four choices, kept in `localStorage` as one JSON blob — and, beside them
5//! under a key of its own, the library's list of documents to offer. Every
6//! access goes through [`Store`], which answers the defaults where the
7//! browser refuses — a private window, blocked site data, an origin with no
8//! storage at all — so a shell that cannot remember still runs.
9//!
10//! The light/dark end is read **once** for both halves of the page: the class
11//! the chrome varies on and the [`Palette`] the kernel is told come from this
12//! one answer, so the bar and the ground cannot disagree.
13
14use blockworx_paint::{FontChoice, Luminance, Mode, Palette, Scheme};
15use blockworx_store::record::Identity;
16use blockworx_store::storage::DocumentRef;
17use serde::{Deserialize, Serialize};
18use web_sys::{Storage, Window};
19
20use crate::mode::Prefers;
21
22/// Where the blob is filed.
23const KEY: &str = "blockworx.preferences";
24
25/// Where the library's recent list is filed, beside it.
26const RECENT: &str = "blockworx.recent";
27
28/// The persisted preferences. `#[serde(default)]` so a blob written by an
29/// older build loads with the rest filled in, and an unknown key is left
30/// behind rather than failing the load.
31#[derive(Clone, PartialEq, Eq, Debug, Default, Serialize, Deserialize)]
32#[serde(default)]
33pub struct Preferences {
34    /// The base16 scheme the *diagram* is painted in. The chrome is Tailwind
35    /// and takes nothing from it.
36    pub scheme: Scheme,
37    pub mode: Mode,
38    pub font: FontChoice,
39    /// The attribution name, overriding what the host says this user is
40    /// called. `None` is "ask the host".
41    pub profile: Option<String>,
42}
43
44impl Preferences {
45    /// Which end of the light/dark axis the page is drawn at.
46    #[must_use]
47    pub fn luminance(&self, prefers: Prefers) -> Luminance {
48        crate::mode::luminance(self.mode, prefers)
49    }
50
51    /// The palette the diagram is painted in, which the shell tells the
52    /// kernel by `Action::SetPalette`.
53    #[must_use]
54    pub fn palette(&self, prefers: Prefers) -> Palette {
55        self.scheme.palette(self.luminance(prefers))
56    }
57
58    /// Who this session's commits are attributed to. A blank profile is not a
59    /// name — it would attribute a document to nobody while looking like it
60    /// had an author.
61    #[must_use]
62    pub fn identity(&self) -> Identity {
63        match self.profile.as_deref().map(str::trim) {
64            Some(name) if !name.is_empty() => Identity::new(name),
65            _ => Identity::from_environment(),
66        }
67    }
68}
69
70/// The origin's store, or nothing where the browser will not open one.
71#[derive(Clone, Default)]
72pub struct Store(Option<Storage>);
73
74impl Store {
75    /// The store `window` offers. Every failure — no store, a refusal, an
76    /// exception thrown by the accessor itself — is the same nothing.
77    #[must_use]
78    pub fn of(window: Option<&Window>) -> Self {
79        Self(window.and_then(|window| window.local_storage().ok().flatten()))
80    }
81
82    /// What is remembered, or the defaults.
83    #[must_use]
84    pub fn read(&self) -> Preferences {
85        self.0
86            .as_ref()
87            .and_then(|store| store.get_item(KEY).ok().flatten())
88            .and_then(|blob| serde_json::from_str(&blob).ok())
89            .unwrap_or_default()
90    }
91
92    /// Remember `prefs`, if this browser will. A refusal is not worth a
93    /// notice: the preference still stands for as long as the tab is open.
94    pub fn write(&self, prefs: &Preferences) {
95        self.keeps(KEY, prefs, "the preferences");
96    }
97
98    /// The documents the library offers to reopen, newest first.
99    #[must_use]
100    pub fn recent(&self) -> Vec<DocumentRef> {
101        self.0
102            .as_ref()
103            .and_then(|store| store.get_item(RECENT).ok().flatten())
104            .and_then(|blob| serde_json::from_str(&blob).ok())
105            .unwrap_or_default()
106    }
107
108    pub fn remembers(&self, recent: &[DocumentRef]) {
109        self.keeps(RECENT, recent, "the recent list");
110    }
111
112    fn keeps(&self, key: &str, what: &(impl Serialize + ?Sized), called: &str) {
113        let (Some(store), Ok(blob)) = (self.0.as_ref(), serde_json::to_string(what)) else {
114            return;
115        };
116        if let Err(refused) = store.set_item(key, &blob) {
117            tracing::debug!("the store would not keep {called}: {refused:?}");
118        }
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn a_blocked_store_answers_the_defaults() {
128        let nothing = Store::default();
129        assert_eq!(nothing.read(), Preferences::default());
130        // And writing to it is not an error, it is a nothing.
131        nothing.write(&Preferences {
132            mode: Mode::Light,
133            ..Preferences::default()
134        });
135        assert_eq!(nothing.read(), Preferences::default());
136        nothing.remembers(&[DocumentRef::new("engine.bwx")]);
137        assert!(nothing.recent().is_empty());
138    }
139
140    #[test]
141    fn preferences_round_trip_through_json() {
142        let prefs = Preferences {
143            scheme: Scheme::RosePine,
144            mode: Mode::Light,
145            font: FontChoice::Monospace,
146            profile: Some("Ada Lovelace".to_owned()),
147        };
148        let blob = serde_json::to_string(&prefs).expect("the preferences serialize");
149        assert_eq!(
150            serde_json::from_str::<Preferences>(&blob).expect("and read back"),
151            prefs,
152        );
153    }
154
155    /// A blob from a build that knew fewer choices still loads.
156    #[test]
157    fn a_partial_blob_fills_the_rest_in() {
158        let loaded: Preferences =
159            serde_json::from_str(r#"{"mode":"Light"}"#).expect("a partial blob loads");
160        assert_eq!(loaded.mode, Mode::Light);
161        assert_eq!(loaded.scheme, Scheme::default());
162        assert_eq!(loaded.font, FontChoice::default());
163    }
164
165    #[test]
166    fn a_blank_profile_is_not_a_name() {
167        let blank = Preferences {
168            profile: Some("   ".to_owned()),
169            ..Preferences::default()
170        };
171        assert_eq!(blank.identity(), Identity::from_environment());
172        let named = Preferences {
173            profile: Some("Ada Lovelace".to_owned()),
174            ..Preferences::default()
175        };
176        assert_eq!(named.identity().name, "Ada Lovelace");
177    }
178
179    /// The two halves of the page read the axis once: the class the chrome
180    /// varies on and the palette the diagram is painted in are one answer.
181    #[test]
182    fn the_chrome_and_the_diagram_agree_on_the_axis() {
183        for mode in Mode::ALL {
184            for prefers in [Prefers::Dark, Prefers::Light, Prefers::Unstated] {
185                let prefs = Preferences {
186                    mode,
187                    ..Preferences::default()
188                };
189                assert_eq!(
190                    prefs.palette(prefers).is_dark(),
191                    prefs.luminance(prefers).is_dark(),
192                );
193            }
194        }
195    }
196}