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 action returned to the chord this build ships has
332                // no entry, so the list states what was changed rather
333                // than what the keymap holds.
334                if !chord.spelled.is_empty() {
335                    self.chords.push(chord);
336                    // Action order, so two machines that rebound the
337                    // same chords in a different order hold the same
338                    // file.
339                    self.chords.sort_by(|one, two| one.action.cmp(&two.action));
340                }
341            }
342        }
343    }
344}
345
346/// One named change to [`PreferencesAnswer`].
347///
348/// A closed set of named changes rather than a whole record, so two
349/// screens settling different facts cannot overwrite each other, and so
350/// a frame that names a fact this build does not keep is refused at the
351/// boundary rather than merged.
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353#[serde(rename_all = "snake_case")]
354#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
355pub enum PreferencePatch {
356    Lang(Lang),
357    Welcomed(bool),
358    Panel(bool),
359    Tier(Tier),
360    Appearance(Appearance),
361    Proxying(kernel::Proxying),
362    /// Rebind one action, or return it to the chord this build ships by
363    /// sending an empty `spelled`.
364    Chord(Chord),
365    /// Whether the core's threads stand above normal. Lands in the
366    /// file's `[core]` section, not in `[ui]` (`crates/wire/spec/Command/Step.lean` §8-61).
367    CorePriority(CorePriority),
368    CorePlacement(CorePlacement),
369    RunMemory(Option<std::num::NonZeroU64>),
370    /// Replace one session's tags with these; an empty set removes them
371    /// (`crates/wire/spec/Preference.lean` §8-84).
372    Tags(SessionTags),
373    /// Replace the colour override; the empty value returns to the
374    /// built-in theme (`crates/wire/spec/Preference.lean` D29).
375    Theme(ThemeOverride),
376}
377
378#[cfg(test)]
379#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
380mod tests {
381    use super::*;
382
383    /// A patch names one fact. The test that matters is that the tag is
384    /// the fact's name, because that tag is what the person's file and
385    /// the client's call site are both written against.
386    #[test]
387    fn a_patch_travels_under_the_name_of_the_fact_it_changes() {
388        let text = serde_json::to_string(&PreferencePatch::Lang(Lang::Zh)).unwrap();
389        assert_eq!(text, r#"{"lang":"zh"}"#);
390        let back: PreferencePatch = serde_json::from_str(&text).unwrap();
391        assert_eq!(back, PreferencePatch::Lang(Lang::Zh));
392    }
393
394    /// No silent repair: a word no build offers is not turned into the
395    /// posture the client ships with.
396    #[test]
397    fn a_word_no_build_offers_is_refused_rather_than_repaired() {
398        assert!(serde_json::from_str::<Lighting>("\"sepia\"").is_err());
399        assert!(serde_json::from_str::<PreferencePatch>(r#"{"lang":"fr"}"#).is_err());
400    }
401
402    /// Rebinding one action twice leaves one entry, and returning it
403    /// to the shipped chord leaves none: the list states what was
404    /// changed, so an action that appears twice would make the keymap
405    /// depend on which entry it read last.
406    #[test]
407    fn an_action_is_bound_once_and_unbinding_it_removes_the_entry() {
408        let mut held = PreferencesAnswer::default();
409        let bind = |spelled: &str| {
410            PreferencePatch::Chord(Chord {
411                action: "go.city".to_owned(),
412                spelled: spelled.to_owned(),
413            })
414        };
415        held.apply(bind("accel+2"));
416        held.apply(bind("accel+3"));
417        assert_eq!(held.chords.len(), 1);
418        assert_eq!(
419            held.chords.first().map(|held| held.spelled.as_str()),
420            Some("accel+3")
421        );
422        held.apply(bind(""));
423        assert!(held.chords.is_empty());
424    }
425
426    /// A section that states one fact is read with the rest at the
427    /// postures this build draws, and the panel is the one whose
428    /// absence does not mean `false`.
429    #[test]
430    fn a_section_that_states_one_fact_leaves_the_others_at_their_posture() {
431        let held: PreferencesAnswer = serde_json::from_str(r#"{"welcomed":true}"#).unwrap();
432        assert!(held.welcomed);
433        assert!(held.panel);
434        assert_eq!(held.lang, None);
435        assert_eq!(held.appearance, Appearance::default());
436    }
437
438    /// A section written before the page's glass, blend opacity and
439    /// tier reached the city still reads, with the three absent: every
440    /// person's file predates them.
441    #[test]
442    fn an_appearance_written_before_glass_and_blend_still_reads() {
443        let held: PreferencesAnswer = serde_json::from_str(
444            r#"{"appearance":{"lighting":"dark","sans":"geist","mono":"geist","sans_stack":"","mono_stack":"","body_px":null,"density":"compact","chroma":"full","motion":"system"}}"#,
445        )
446        .unwrap();
447        assert_eq!(
448            (
449                held.tier,
450                held.appearance.glass,
451                held.appearance.blend_percent
452            ),
453            (None, None, None)
454        );
455        let mut chosen = held;
456        chosen.apply(PreferencePatch::Tier(Tier::Panorama));
457        assert_eq!(chosen.tier, Some(Tier::Panorama));
458    }
459
460    /// The body size has a floor and no ceiling (`crates/wire/spec/Preference.lean`
461    /// D51): a size under the floor is refused where it is read, so the
462    /// person's file and a frame meet the rule the page's box states,
463    /// and any size above it reads back as the number it was.
464    #[test]
465    fn a_body_size_under_the_floor_is_refused_and_any_size_above_it_reads() {
466        let appearance = |px: u32| {
467            serde_json::json!({ "appearance": {
468                "lighting": "dark", "sans": "geist", "mono": "geist",
469                "sans_stack": "", "mono_stack": "", "body_px": px,
470                "density": "compact", "chroma": "full", "motion": "system",
471            }})
472        };
473        assert!(serde_json::from_value::<PreferencesAnswer>(appearance(11)).is_err());
474        for px in [12, 24, 96] {
475            let held: PreferencesAnswer = serde_json::from_value(appearance(px)).unwrap();
476            assert_eq!(
477                serde_json::to_value(&held)
478                    .unwrap()
479                    .pointer("/appearance/body_px"),
480                Some(&serde_json::json!(px))
481            );
482        }
483    }
484
485    /// The reading face is the person's to state and absent until they
486    /// do (`crates/wire/spec/Preference.lean` D52): a section that names
487    /// it reads back the face it named, and one written before the
488    /// field existed reads back as never stated.
489    #[test]
490    fn the_reading_face_reads_back_as_stated_and_absent_as_never_said() {
491        let appearance = |reading: Option<&str>| {
492            let mut stated = serde_json::json!({
493                "lighting": "dark", "sans": "geist", "mono": "geist",
494                "sans_stack": "", "mono_stack": "", "body_px": null,
495                "density": "compact", "chroma": "full", "motion": "system",
496            });
497            if let (Some(face), Some(fields)) = (reading, stated.as_object_mut()) {
498                fields.insert("reading".to_owned(), serde_json::json!(face));
499            }
500            serde_json::json!({ "appearance": stated })
501        };
502        let read_back = |reading: Option<&str>| {
503            let held: PreferencesAnswer = serde_json::from_value(appearance(reading)).unwrap();
504            serde_json::to_value(&held)
505                .unwrap()
506                .pointer("/appearance/reading")
507                .cloned()
508        };
509        assert_eq!(
510            [
511                read_back(Some("libron")),
512                read_back(Some("interface")),
513                read_back(None)
514            ],
515            [
516                Some(serde_json::json!("libron")),
517                Some(serde_json::json!("interface")),
518                Some(serde_json::Value::Null),
519            ]
520        );
521        assert!(serde_json::from_value::<PreferencesAnswer>(appearance(Some("georgia"))).is_err());
522    }
523
524    /// A key this build does not read is refused where it is written.
525    /// Ignoring it produces the one state nobody can diagnose: the
526    /// setting is in the file, and nothing happens.
527    #[test]
528    fn a_key_no_field_claims_is_refused() {
529        assert!(serde_json::from_str::<PreferencesAnswer>(r#"{"welcomd":true}"#).is_err());
530    }
531}