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}