Skip to main content

wire/
preference.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! What one person settled about their own copy of the city: the
7//! language they read, how the pages look, and the chords they rebound.
8//!
9//! **These are the person's layer, not the city's.** They live in
10//! `~/.sprawling/config.toml` beside the components that are already
11//! tied to this machine, they change nothing a run can observe, and
12//! they are answered whole by [`Query::Preferences`](crate::Query) and
13//! changed one named fact at a time by
14//! [`Command::PutPreferences`](crate::Command).
15//!
16//! **Every value set a selector offers is declared once, here.** A
17//! browser holding its own list of the words it would accept would keep
18//! an option the city stopped offering pickable until somebody noticed;
19//! the client reads these enums out of the generated schema, so an
20//! option it can draw is an option this build can load.
21//!
22//! One named change per fact rather than one whole-record write: two
23//! screens that each settle one thing must not be able to overwrite
24//! each other's field on the way past.
25
26use std::collections::BTreeMap;
27
28use serde::{Deserialize, Serialize};
29
30mod core;
31pub use core::{CorePlacement, CorePreferences, CorePriority};
32
33mod body;
34pub use body::{BODY_PX_MIN, BodyPx};
35
36mod tag;
37pub use tag::{SessionTags, TAG_MAX, Tag};
38
39/// Which language a person reads the interface in.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "snake_case")]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43pub enum Lang {
44    En,
45    Zh,
46}
47
48/// Which palette the pages are drawn in. `System` is the absence of an
49/// opinion, resolved where the page is drawn rather than in the
50/// stylesheet: the light palette is declared once, and a second
51/// declaration of it inside a `prefers-color-scheme` block would be a
52/// second authority for the same rungs.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
56pub enum Lighting {
57    System,
58    Dark,
59    Light,
60}
61
62/// Where one of the two typefaces comes from.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
66pub enum Face {
67    /// The family this build ships.
68    Geist,
69    /// Whatever this machine calls its interface font.
70    System,
71    /// The stack the person wrote out.
72    Custom,
73}
74
75/// Which face reading text is drawn in: the messages of a conversation,
76/// documents and reports. Controls, labels, figures and code keep the
77/// interface's faces whichever this says
78/// (`crates/wire/spec/Preference.lean` D52).
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
80#[serde(rename_all = "snake_case")]
81#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
82pub enum ReadingFace {
83    /// The face the interface is drawn in, whichever [`Face`] that is.
84    Interface,
85    /// Libron, the serif face this build ships for reading.
86    Libron,
87}
88
89/// How much air the spacing steps carry. Two named postures rather than
90/// a coefficient, because the coefficient is the stylesheet's to
91/// choose.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "snake_case")]
94#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
95pub enum Density {
96    Comfortable,
97    Compact,
98}
99
100/// Whether colour carries meaning on these pages, or only contrast
101/// does.
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
103#[serde(rename_all = "snake_case")]
104#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105pub enum Chroma {
106    Full,
107    Off,
108}
109
110/// Whether the pages animate. `System` is the absence of an opinion,
111/// which is what the stylesheet's `prefers-reduced-motion` block reads.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
115pub enum Motion {
116    System,
117    On,
118    Off,
119}
120
121/// Whether the edge layer's small surfaces are drawn as glass. `On`
122/// still yields to a machine that asks for less transparency, which
123/// only some engines report: that gap is why the switch exists.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub enum Glass {
128    On,
129    Off,
130}
131
132/// How much of the world layer the page draws: none of it, whole panels
133/// beside the conversation, or all of it as the workspace with the
134/// conversation as a band along its foot. Three layouts rather than
135/// three points on one slider, so three names.
136#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
137#[serde(rename_all = "snake_case")]
138#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
139pub enum Tier {
140    Zen,
141    Blend,
142    Panorama,
143}
144
145/// How the pages look.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
148pub struct Appearance {
149    pub lighting: Lighting,
150    pub sans: Face,
151    pub mono: Face,
152    /// The families this person wrote out, used when the matching
153    /// [`Face`] is [`Face::Custom`] and empty otherwise.
154    pub sans_stack: String,
155    /// See `sans_stack`.
156    pub mono_stack: String,
157    /// Absent means the person stated no opinion and reading text is
158    /// drawn in the interface's face.
159    #[serde(default)]
160    pub reading: Option<ReadingFace>,
161    /// The size of a line of body text in pixels, at least
162    /// [`BODY_PX_MIN`]. Absent means the person
163    /// stated no size and the stylesheet's own is drawn — which is a
164    /// different statement from any number this build could pick for
165    /// them.
166    pub body_px: Option<BodyPx>,
167    pub density: Density,
168    pub chroma: Chroma,
169    pub motion: Motion,
170    /// Absent means the person stated no opinion and the page draws
171    /// its own posture.
172    #[serde(default)]
173    pub glass: Option<Glass>,
174    /// The world layer's opacity in the blend tier, in percent. Absent
175    /// means the stylesheet's own is drawn. The domain the slider
176    /// offers is the page's to state, because the page alone draws it;
177    /// a value outside it is read back as absent there.
178    #[serde(default)]
179    pub blend_percent: Option<u32>,
180}
181
182/// One chord the person rebound: the action, and the chord as the
183/// keymap spells it.
184///
185/// An action left at the chord this build ships has no entry, so the
186/// list says what was changed rather than what the keymap holds.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
189pub struct Chord {
190    pub action: String,
191    pub spelled: String,
192}
193
194/// Everything one person settled about their own copy of the city.
195///
196/// Whole rather than a dozen readings, because that is the record the
197/// file holds and because a screen that changes two of these at once
198/// must not be able to write one and drop the other.
199///
200/// **This is the grammar of `[ui]` in the person's own file as well as
201/// the shape of the answer.** The file is read through this type and
202/// written by serialising it, so a key the file may hold and a field
203/// the answer states are one declaration. A section that states only
204/// some of them is read with the rest at the postures
205/// [`PreferencesAnswer::default`] gives, and a key no field claims is
206/// refused where it is written rather than ignored into a setting that
207/// never takes effect.
208#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
211pub struct PreferencesAnswer {
212    /// The language this person reads the interface in, and nothing
213    /// when they have never said.
214    ///
215    /// Absent rather than a language this build picked for them: the
216    /// browser's own tag is the only evidence anybody has before the
217    /// first choice, and a stated `en` here would overrule it on every
218    /// machine whose owner simply never opened the setting.
219    pub lang: Option<Lang>,
220    /// Performance settings read from `[core]`, never stored in `[ui]`.
221    pub core: CorePreferences,
222    /// Whether this person has walked through the welcome once. The
223    /// city decides whether setting up is *needed*; this only decides
224    /// whether somebody who skipped it is asked again.
225    pub welcomed: bool,
226    /// Whether the panel beside a conversation is open.
227    pub panel: bool,
228    /// The tier this person last chose, and nothing when they never
229    /// chose one: the tier a first visit opens in is the page's posture,
230    /// and a stated tier here would overrule it on every machine whose
231    /// owner never pressed the key.
232    pub tier: Option<Tier>,
233    pub appearance: Appearance,
234    /// Which proxy rule an endpoint attached from now on starts with.
235    /// An endpoint already attached keeps the rule the city recorded
236    /// for it.
237    pub proxying: kernel::Proxying,
238    /// The chords this person rebound, action order.
239    pub chords: Vec<Chord>,
240    /// The tags this person gave sessions, in session order
241    /// (`crates/wire/spec/Preference.lean` §8-84).
242    pub tags: Vec<SessionTags>,
243    /// The person's colours laid over the built-in theme
244    /// (`crates/wire/spec/Preference.lean` D29).
245    pub theme: ThemeOverride,
246}
247
248/// Colours laid over the theme the page ships with. The empty value is
249/// the built-in theme.
250///
251/// The values are CSS, and the city does not parse them: the page owns
252/// both the theme and the stylesheet that reads it, and checks the keys
253/// against the `@theme` block and the legibility of the result.
254#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(default, deny_unknown_fields)]
256#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
257pub struct ThemeOverride {
258    /// A CSS variable of the `@theme` block in `client/src/theme.css`,
259    /// mapped to the CSS colour that replaces it.
260    pub tokens: BTreeMap<String, String>,
261    /// A whole stylesheet the person wrote, laid after the tokens.
262    pub css: Option<String>,
263}
264
265/// What a person who has settled nothing is answered.
266///
267/// The postures this build draws before anybody chooses, stated once
268/// for the file's reader and the answer's reader together. `panel` is
269/// the one that is not its type's own default: the panel beside a
270/// conversation is open until somebody closes it, so an unwritten file
271/// has to say `true` where `welcomed` says `false`.
272impl Default for PreferencesAnswer {
273    fn default() -> Self {
274        PreferencesAnswer {
275            lang: None,
276            core: CorePreferences::default(),
277            welcomed: false,
278            panel: true,
279            tier: None,
280            appearance: Appearance::default(),
281            proxying: kernel::Proxying::ExceptLocal,
282            chords: Vec::new(),
283            tags: Vec::new(),
284            theme: ThemeOverride::default(),
285        }
286    }
287}
288
289impl Default for Appearance {
290    fn default() -> Self {
291        Appearance {
292            lighting: Lighting::System,
293            sans: Face::Geist,
294            mono: Face::Geist,
295            sans_stack: String::new(),
296            mono_stack: String::new(),
297            reading: None,
298            body_px: None,
299            density: Density::Comfortable,
300            chroma: Chroma::Full,
301            motion: Motion::System,
302            glass: None,
303            blend_percent: None,
304        }
305    }
306}
307
308impl PreferencesAnswer {
309    /// Lands one named change on this record.
310    ///
311    /// Here rather than at the file: what a patch means is the patch's
312    /// own rule, and a second application of it — in the store, in a
313    /// test, in whatever keeps these next — would be a second answer
314    /// to what `Chord("")` does.
315    pub fn apply(&mut self, patch: PreferencePatch) {
316        match patch {
317            PreferencePatch::Lang(lang) => self.lang = Some(lang),
318            PreferencePatch::Welcomed(welcomed) => self.welcomed = welcomed,
319            PreferencePatch::Panel(panel) => self.panel = panel,
320            PreferencePatch::Tier(tier) => self.tier = Some(tier),
321            PreferencePatch::Appearance(appearance) => self.appearance = appearance,
322            PreferencePatch::Proxying(proxying) => self.proxying = proxying,
323            // The writer stores these separately from `[ui]`.
324            PreferencePatch::CorePriority(priority) => self.core.priority = priority,
325            PreferencePatch::CorePlacement(placement) => self.core.placement = placement,
326            PreferencePatch::RunMemory(memory) => self.core.memory_bytes = memory,
327            PreferencePatch::Tags(next) => tag::retagged(&mut self.tags, next),
328            PreferencePatch::Theme(theme) => self.theme = theme,
329            PreferencePatch::Chord(chord) => {
330                self.chords.retain(|held| held.action != chord.action);
331                // An empty spelling returns the action to the chord this
332                // build ships, which `Chord` keeps no entry for.
333                if !chord.spelled.is_empty() {
334                    self.chords.push(chord);
335                    // Action order, so two machines that rebound the
336                    // same chords in a different order hold the same
337                    // file.
338                    self.chords.sort_by(|one, two| one.action.cmp(&two.action));
339                }
340            }
341        }
342    }
343}
344
345/// One named change to [`PreferencesAnswer`].
346///
347/// A closed set of named changes rather than a whole record, so two
348/// screens settling different facts cannot overwrite each other, and so
349/// a frame that names a fact this build does not keep is refused at the
350/// boundary rather than merged.
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352#[serde(rename_all = "snake_case")]
353#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
354pub enum PreferencePatch {
355    Lang(Lang),
356    Welcomed(bool),
357    Panel(bool),
358    Tier(Tier),
359    Appearance(Appearance),
360    Proxying(kernel::Proxying),
361    /// Rebind one action, or return it to the chord this build ships by
362    /// sending an empty `spelled`.
363    Chord(Chord),
364    /// Whether the core's threads stand above normal. Lands in the
365    /// file's `[core]` section, not in `[ui]` (`crates/wire/spec/Command/Step.lean` §8-61).
366    CorePriority(CorePriority),
367    CorePlacement(CorePlacement),
368    RunMemory(Option<std::num::NonZeroU64>),
369    /// Replace one session's tags with these; an empty set removes them
370    /// (`crates/wire/spec/Preference.lean` §8-84).
371    Tags(SessionTags),
372    /// Replace the colour override; the empty value returns to the
373    /// built-in theme (`crates/wire/spec/Preference.lean` D29).
374    Theme(ThemeOverride),
375}
376
377#[cfg(test)]
378#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
379mod tests {
380    use super::*;
381
382    /// A patch names one fact. The test that matters is that the tag is
383    /// the fact's name, because that tag is what the person's file and
384    /// the client's call site are both written against.
385    #[test]
386    fn a_patch_travels_under_the_name_of_the_fact_it_changes() {
387        let text = serde_json::to_string(&PreferencePatch::Lang(Lang::Zh)).unwrap();
388        assert_eq!(text, r#"{"lang":"zh"}"#);
389        let back: PreferencePatch = serde_json::from_str(&text).unwrap();
390        assert_eq!(back, PreferencePatch::Lang(Lang::Zh));
391    }
392
393    /// No silent repair: a word no build offers is not turned into the
394    /// posture the client ships with.
395    #[test]
396    fn a_word_no_build_offers_is_refused_rather_than_repaired() {
397        assert!(serde_json::from_str::<Lighting>("\"sepia\"").is_err());
398        assert!(serde_json::from_str::<PreferencePatch>(r#"{"lang":"fr"}"#).is_err());
399    }
400
401    /// Rebinding one action twice leaves one entry, and returning it
402    /// to the shipped chord leaves none: the list states what was
403    /// changed, so an action that appears twice would make the keymap
404    /// depend on which entry it read last.
405    #[test]
406    fn an_action_is_bound_once_and_unbinding_it_removes_the_entry() {
407        let mut held = PreferencesAnswer::default();
408        let bind = |spelled: &str| {
409            PreferencePatch::Chord(Chord {
410                action: "go.city".to_owned(),
411                spelled: spelled.to_owned(),
412            })
413        };
414        held.apply(bind("accel+2"));
415        held.apply(bind("accel+3"));
416        assert_eq!(held.chords.len(), 1);
417        assert_eq!(
418            held.chords.first().map(|held| held.spelled.as_str()),
419            Some("accel+3")
420        );
421        held.apply(bind(""));
422        assert!(held.chords.is_empty());
423    }
424
425    /// A section that states one fact is read with the rest at the
426    /// postures this build draws, and the panel is the one whose
427    /// absence does not mean `false`.
428    #[test]
429    fn a_section_that_states_one_fact_leaves_the_others_at_their_posture() {
430        let held: PreferencesAnswer = serde_json::from_str(r#"{"welcomed":true}"#).unwrap();
431        assert!(held.welcomed);
432        assert!(held.panel);
433        assert_eq!(held.lang, None);
434        assert_eq!(held.appearance, Appearance::default());
435    }
436
437    /// A section written before the page's glass, blend opacity and
438    /// tier reached the city still reads, with the three absent: every
439    /// person's file predates them.
440    #[test]
441    fn an_appearance_written_before_glass_and_blend_still_reads() {
442        let held: PreferencesAnswer = serde_json::from_str(
443            r#"{"appearance":{"lighting":"dark","sans":"geist","mono":"geist","sans_stack":"","mono_stack":"","body_px":null,"density":"compact","chroma":"full","motion":"system"}}"#,
444        )
445        .unwrap();
446        assert_eq!(
447            (
448                held.tier,
449                held.appearance.glass,
450                held.appearance.blend_percent
451            ),
452            (None, None, None)
453        );
454        let mut chosen = held;
455        chosen.apply(PreferencePatch::Tier(Tier::Panorama));
456        assert_eq!(chosen.tier, Some(Tier::Panorama));
457    }
458
459    /// The body size has a floor and no ceiling (`crates/wire/spec/Preference.lean`
460    /// D51): a size under the floor is refused where it is read, so the
461    /// person's file and a frame meet the rule the page's box states,
462    /// and any size above it reads back as the number it was.
463    #[test]
464    fn a_body_size_under_the_floor_is_refused_and_any_size_above_it_reads() {
465        let appearance = |px: u32| {
466            serde_json::json!({ "appearance": {
467                "lighting": "dark", "sans": "geist", "mono": "geist",
468                "sans_stack": "", "mono_stack": "", "body_px": px,
469                "density": "compact", "chroma": "full", "motion": "system",
470            }})
471        };
472        assert!(serde_json::from_value::<PreferencesAnswer>(appearance(11)).is_err());
473        for px in [12, 24, 96] {
474            let held: PreferencesAnswer = serde_json::from_value(appearance(px)).unwrap();
475            assert_eq!(
476                serde_json::to_value(&held)
477                    .unwrap()
478                    .pointer("/appearance/body_px"),
479                Some(&serde_json::json!(px))
480            );
481        }
482    }
483
484    /// The reading face is the person's to state and absent until they
485    /// do (`crates/wire/spec/Preference.lean` D52): a section that names
486    /// it reads back the face it named, and one written before the
487    /// field existed reads back as never stated.
488    #[test]
489    fn the_reading_face_reads_back_as_stated_and_absent_as_never_said() {
490        let appearance = |reading: Option<&str>| {
491            let mut stated = serde_json::json!({
492                "lighting": "dark", "sans": "geist", "mono": "geist",
493                "sans_stack": "", "mono_stack": "", "body_px": null,
494                "density": "compact", "chroma": "full", "motion": "system",
495            });
496            if let (Some(face), Some(fields)) = (reading, stated.as_object_mut()) {
497                fields.insert("reading".to_owned(), serde_json::json!(face));
498            }
499            serde_json::json!({ "appearance": stated })
500        };
501        let read_back = |reading: Option<&str>| {
502            let held: PreferencesAnswer = serde_json::from_value(appearance(reading)).unwrap();
503            serde_json::to_value(&held)
504                .unwrap()
505                .pointer("/appearance/reading")
506                .cloned()
507        };
508        assert_eq!(
509            [
510                read_back(Some("libron")),
511                read_back(Some("interface")),
512                read_back(None)
513            ],
514            [
515                Some(serde_json::json!("libron")),
516                Some(serde_json::json!("interface")),
517                Some(serde_json::Value::Null),
518            ]
519        );
520        assert!(serde_json::from_value::<PreferencesAnswer>(appearance(Some("georgia"))).is_err());
521    }
522
523    /// A key this build does not read is refused where it is written.
524    /// Ignoring it produces the one state nobody can diagnose: the
525    /// setting is in the file, and nothing happens.
526    #[test]
527    fn a_key_no_field_claims_is_refused() {
528        assert!(serde_json::from_str::<PreferencesAnswer>(r#"{"welcomd":true}"#).is_err());
529    }
530}