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}