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