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 serde::{Deserialize, Serialize};
27
28mod tag;
29pub use tag::{SessionTags, TAG_MAX, Tag};
30
31/// Which language a person reads the interface in.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "snake_case")]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub enum Lang {
36    En,
37    Zh,
38}
39
40/// Which palette the pages are drawn in. `System` is the absence of an
41/// opinion, resolved where the page is drawn rather than in the
42/// stylesheet: the light palette is declared once, and a second
43/// declaration of it inside a `prefers-color-scheme` block would be a
44/// second authority for the same rungs.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48pub enum Lighting {
49    System,
50    Dark,
51    Light,
52}
53
54/// Where one of the two typefaces comes from.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
58pub enum Face {
59    /// The family this build ships.
60    Geist,
61    /// Whatever this machine calls its interface font.
62    System,
63    /// The stack the person wrote out.
64    Custom,
65}
66
67/// How much air the spacing steps carry. Two named postures rather than
68/// a coefficient, because the coefficient is the stylesheet's to
69/// choose.
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
71#[serde(rename_all = "snake_case")]
72#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
73pub enum Density {
74    Comfortable,
75    Compact,
76}
77
78/// Whether colour carries meaning on these pages, or only contrast
79/// does.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
83pub enum Chroma {
84    Full,
85    Off,
86}
87
88/// Whether the pages animate. `System` is the absence of an opinion,
89/// which is what the stylesheet's `prefers-reduced-motion` block reads.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
93pub enum Motion {
94    System,
95    On,
96    Off,
97}
98
99/// Whether the edge layer's small surfaces are drawn as glass. `On`
100/// still yields to a machine that asks for less transparency, which
101/// only some engines report: that gap is why the switch exists.
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 Glass {
106    On,
107    Off,
108}
109
110/// How much of the world layer the page draws: none of it, whole panels
111/// beside the conversation, or all of it as the workspace with the
112/// conversation as a band along its foot. Three layouts rather than
113/// three points on one slider, so three names.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
117pub enum Tier {
118    Zen,
119    Blend,
120    Panorama,
121}
122
123/// The smallest and the largest body size a person may ask for.
124///
125/// The floor is the smallest size the colour gate holds its contrast
126/// tiers at, and the ceiling is where a line of body text stops being
127/// body text. Stated here because both the form that offers the box
128/// and the layer that writes the file have to agree, and a pair of
129/// numbers typed twice is a pair that can be typed differently.
130pub const BODY_PX_MIN: u32 = 12;
131/// See [`BODY_PX_MIN`].
132pub const BODY_PX_MAX: u32 = 20;
133
134/// How the pages look.
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
136#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
137pub struct Appearance {
138    pub lighting: Lighting,
139    pub sans: Face,
140    pub mono: Face,
141    /// The families this person wrote out, used when the matching
142    /// [`Face`] is [`Face::Custom`] and empty otherwise.
143    pub sans_stack: String,
144    /// See `sans_stack`.
145    pub mono_stack: String,
146    /// The size of a line of body text in pixels, between
147    /// [`BODY_PX_MIN`] and [`BODY_PX_MAX`]. Absent means the person
148    /// stated no size and the stylesheet's own is drawn — which is a
149    /// different statement from any number this build could pick for
150    /// them.
151    pub body_px: Option<u32>,
152    pub density: Density,
153    pub chroma: Chroma,
154    pub motion: Motion,
155    /// Absent means the person stated no opinion and the page draws
156    /// its own posture.
157    #[serde(default)]
158    pub glass: Option<Glass>,
159    /// The world layer's opacity in the blend tier, in percent. Absent
160    /// means the stylesheet's own is drawn. The domain the slider
161    /// offers is the page's to state, because the page alone draws it;
162    /// a value outside it is read back as absent there.
163    #[serde(default)]
164    pub blend_percent: Option<u32>,
165}
166
167/// One chord the person rebound: the action, and the chord as the
168/// keymap spells it.
169///
170/// An action left at the chord this build ships has no entry, so the
171/// list says what was changed rather than what the keymap holds.
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
174pub struct Chord {
175    pub action: String,
176    pub spelled: String,
177}
178
179/// Everything one person settled about their own copy of the city.
180///
181/// Whole rather than a dozen readings, because that is the record the
182/// file holds and because a screen that changes two of these at once
183/// must not be able to write one and drop the other.
184///
185/// **This is the grammar of `[ui]` in the person's own file as well as
186/// the shape of the answer.** The file is read through this type and
187/// written by serialising it, so a key the file may hold and a field
188/// the answer states are one declaration. A section that states only
189/// some of them is read with the rest at the postures
190/// [`PreferencesAnswer::default`] gives, and a key no field claims is
191/// refused where it is written rather than ignored into a setting that
192/// never takes effect.
193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
194#[serde(default, deny_unknown_fields)]
195#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
196pub struct PreferencesAnswer {
197    /// The language this person reads the interface in, and nothing
198    /// when they have never said.
199    ///
200    /// Absent rather than a language this build picked for them: the
201    /// browser's own tag is the only evidence anybody has before the
202    /// first choice, and a stated `en` here would overrule it on every
203    /// machine whose owner simply never opened the setting.
204    pub lang: Option<Lang>,
205    /// Whether this person has walked through the welcome once. The
206    /// city decides whether setting up is *needed*; this only decides
207    /// whether somebody who skipped it is asked again.
208    pub welcomed: bool,
209    /// Whether the panel beside a conversation is open.
210    pub panel: bool,
211    /// The tier this person last chose, and nothing when they never
212    /// chose one: the tier a first visit opens in is the page's posture,
213    /// and a stated tier here would overrule it on every machine whose
214    /// owner never pressed the key.
215    pub tier: Option<Tier>,
216    pub appearance: Appearance,
217    /// Which proxy rule an endpoint attached from now on starts with.
218    /// An endpoint already attached keeps the rule the city recorded
219    /// for it.
220    pub proxying: kernel::Proxying,
221    /// The chords this person rebound, action order.
222    pub chords: Vec<Chord>,
223    /// The tags this person gave sessions, in session order
224    /// (`crates/wire/spec/Preference.lean` §8-84).
225    pub tags: Vec<SessionTags>,
226}
227
228/// What a person who has settled nothing is answered.
229///
230/// The postures this build draws before anybody chooses, stated once
231/// for the file's reader and the answer's reader together. `panel` is
232/// the one that is not its type's own default: the panel beside a
233/// conversation is open until somebody closes it, so an unwritten file
234/// has to say `true` where `welcomed` says `false`.
235impl Default for PreferencesAnswer {
236    fn default() -> Self {
237        PreferencesAnswer {
238            lang: None,
239            welcomed: false,
240            panel: true,
241            tier: None,
242            appearance: Appearance::default(),
243            proxying: kernel::Proxying::ExceptLocal,
244            chords: Vec::new(),
245            tags: Vec::new(),
246        }
247    }
248}
249
250impl Default for Appearance {
251    fn default() -> Self {
252        Appearance {
253            lighting: Lighting::System,
254            sans: Face::Geist,
255            mono: Face::Geist,
256            sans_stack: String::new(),
257            mono_stack: String::new(),
258            body_px: None,
259            density: Density::Comfortable,
260            chroma: Chroma::Full,
261            motion: Motion::System,
262            glass: None,
263            blend_percent: None,
264        }
265    }
266}
267
268impl PreferencesAnswer {
269    /// Lands one named change on this record.
270    ///
271    /// Here rather than at the file: what a patch means is the patch's
272    /// own rule, and a second application of it — in the store, in a
273    /// test, in whatever keeps these next — would be a second answer
274    /// to what `Chord("")` does.
275    pub fn apply(&mut self, patch: PreferencePatch) {
276        match patch {
277            PreferencePatch::Lang(lang) => self.lang = Some(lang),
278            PreferencePatch::Welcomed(welcomed) => self.welcomed = welcomed,
279            PreferencePatch::Panel(panel) => self.panel = panel,
280            PreferencePatch::Tier(tier) => self.tier = Some(tier),
281            PreferencePatch::Appearance(appearance) => self.appearance = appearance,
282            PreferencePatch::Proxying(proxying) => self.proxying = proxying,
283            // Not a fact of this record: it lives in the file's `[core]`
284            // section, which `accounting::person` writes beside `[ui]`.
285            PreferencePatch::CorePriority(_) => {}
286            PreferencePatch::Tags(next) => tag::retagged(&mut self.tags, next),
287            PreferencePatch::Chord(chord) => {
288                self.chords.retain(|held| held.action != chord.action);
289                // An action returned to the chord this build ships has
290                // no entry, so the list states what was changed rather
291                // than what the keymap holds.
292                if !chord.spelled.is_empty() {
293                    self.chords.push(chord);
294                    // Action order, so two machines that rebound the
295                    // same chords in a different order hold the same
296                    // file.
297                    self.chords.sort_by(|one, two| one.action.cmp(&two.action));
298                }
299            }
300        }
301    }
302}
303
304/// One named change to [`PreferencesAnswer`].
305///
306/// A closed set of named changes rather than a whole record, so two
307/// screens settling different facts cannot overwrite each other, and so
308/// a frame that names a fact this build does not keep is refused at the
309/// boundary rather than merged.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "snake_case")]
312#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
313pub enum PreferencePatch {
314    Lang(Lang),
315    Welcomed(bool),
316    Panel(bool),
317    Tier(Tier),
318    Appearance(Appearance),
319    Proxying(kernel::Proxying),
320    /// Rebind one action, or return it to the chord this build ships by
321    /// sending an empty `spelled`.
322    Chord(Chord),
323    /// Whether the core's threads stand above normal. Lands in the
324    /// file's `[core]` section, not in `[ui]` (`crates/wire/spec/Command/Step.lean` §8-61).
325    CorePriority(CorePriority),
326    /// Replace one session's tags with these; an empty set removes them
327    /// (`crates/wire/spec/Preference.lean` §8-84).
328    Tags(SessionTags),
329}
330
331/// Whether the core's threads stand above normal (`crates/sprawling/Spec.lean`
332/// §8-93): the setting a person turns off. Spelled here once, for the
333/// frame and for the `[core] priority` key the person's file holds.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(rename_all = "snake_case")]
336#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
337pub enum CorePriority {
338    Raised,
339    Normal,
340}
341
342#[cfg(test)]
343#[allow(clippy::unwrap_used, clippy::expect_used, reason = "test code")]
344mod tests {
345    use super::*;
346
347    /// A patch names one fact. The test that matters is that the tag is
348    /// the fact's name, because that tag is what the person's file and
349    /// the client's call site are both written against.
350    #[test]
351    fn a_patch_travels_under_the_name_of_the_fact_it_changes() {
352        let text = serde_json::to_string(&PreferencePatch::Lang(Lang::Zh)).unwrap();
353        assert_eq!(text, r#"{"lang":"zh"}"#);
354        let back: PreferencePatch = serde_json::from_str(&text).unwrap();
355        assert_eq!(back, PreferencePatch::Lang(Lang::Zh));
356    }
357
358    /// No silent repair: a word no build offers is not turned into the
359    /// posture the client ships with.
360    #[test]
361    fn a_word_no_build_offers_is_refused_rather_than_repaired() {
362        assert!(serde_json::from_str::<Lighting>("\"sepia\"").is_err());
363        assert!(serde_json::from_str::<PreferencePatch>(r#"{"lang":"fr"}"#).is_err());
364    }
365
366    /// Rebinding one action twice leaves one entry, and returning it
367    /// to the shipped chord leaves none: the list states what was
368    /// changed, so an action that appears twice would make the keymap
369    /// depend on which entry it read last.
370    #[test]
371    fn an_action_is_bound_once_and_unbinding_it_removes_the_entry() {
372        let mut held = PreferencesAnswer::default();
373        let bind = |spelled: &str| {
374            PreferencePatch::Chord(Chord {
375                action: "go.city".to_owned(),
376                spelled: spelled.to_owned(),
377            })
378        };
379        held.apply(bind("accel+2"));
380        held.apply(bind("accel+3"));
381        assert_eq!(held.chords.len(), 1);
382        assert_eq!(
383            held.chords.first().map(|held| held.spelled.as_str()),
384            Some("accel+3")
385        );
386        held.apply(bind(""));
387        assert!(held.chords.is_empty());
388    }
389
390    /// A section that states one fact is read with the rest at the
391    /// postures this build draws, and the panel is the one whose
392    /// absence does not mean `false`.
393    #[test]
394    fn a_section_that_states_one_fact_leaves_the_others_at_their_posture() {
395        let held: PreferencesAnswer = serde_json::from_str(r#"{"welcomed":true}"#).unwrap();
396        assert!(held.welcomed);
397        assert!(held.panel);
398        assert_eq!(held.lang, None);
399        assert_eq!(held.appearance, Appearance::default());
400    }
401
402    /// A section written before the page's glass, blend opacity and
403    /// tier reached the city still reads, with the three absent: every
404    /// person's file predates them.
405    #[test]
406    fn an_appearance_written_before_glass_and_blend_still_reads() {
407        let held: PreferencesAnswer = serde_json::from_str(
408            r#"{"appearance":{"lighting":"dark","sans":"geist","mono":"geist","sans_stack":"","mono_stack":"","body_px":null,"density":"compact","chroma":"full","motion":"system"}}"#,
409        )
410        .unwrap();
411        assert_eq!(
412            (
413                held.tier,
414                held.appearance.glass,
415                held.appearance.blend_percent
416            ),
417            (None, None, None)
418        );
419        let mut chosen = held;
420        chosen.apply(PreferencePatch::Tier(Tier::Panorama));
421        assert_eq!(chosen.tier, Some(Tier::Panorama));
422    }
423
424    /// A key this build does not read is refused where it is written.
425    /// Ignoring it produces the one state nobody can diagnose: the
426    /// setting is in the file, and nothing happens.
427    #[test]
428    fn a_key_no_field_claims_is_refused() {
429        assert!(serde_json::from_str::<PreferencesAnswer>(r#"{"welcomd":true}"#).is_err());
430    }
431}