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