Skip to main content

max / quasi

11.7 KB · 358 lines History Blame Raw
1 //! The settings pane a declaration generates.
2 //!
3 //! Nodes, not HTML. A section per [`category`](crate::Kind::category), a toggle
4 //! per kind, a control per knob, each carrying the kind's one-line summary as
5 //! its hint. Because it is described, the terminal and egui hosts get the same
6 //! pane for free, which is the entire reason this lives in quasi rather than in
7 //! an app.
8 //!
9 //! # A pane per app is the failure, not the feature
10 //!
11 //! Adding a kind adds its settings. An app that wants a bespoke arrangement is
12 //! an app the generator failed, and the fix is a member here rather than a
13 //! hand-built pane there -- the same rule the description vocabulary lives
14 //! under everywhere else.
15 //!
16 //! # Every control writes on its own
17 //!
18 //! There is no submit button and there should not be. A settings pane is the
19 //! case [`Field::writes`] was added for: changing the control *is* the write,
20 //! and goingson reached 13 of these through 109 lines of its own event plumbing
21 //! before the description could say it. Each generated field carries the write
22 //! route it was handed, and sends its value under the generated key -- so the
23 //! handler reads a name it never had to be told, and the pane needs no map from
24 //! control to key.
25 //!
26 //! ```
27 //! use quasi_notifs::{Kind, Registry, config::Unset, pane};
28 //! use quasi_router::Action;
29 //!
30 //! static KINDS: &[Kind] = &[Kind::new(
31 //! "snooze-expiry",
32 //! "Snoozed items resurface",
33 //! "When something you snoozed comes back.",
34 //! "Reminders",
35 //! )
36 //! .shipping_on()];
37 //! static NOTIFS: Registry = Registry::new(KINDS);
38 //!
39 //! let pane = pane::pane(&NOTIFS, &Unset, &Action::post("/settings/notifications"));
40 //! assert_eq!(pane.id, "notifications");
41 //! ```
42
43 use crate::{Kind, Registry, Setting, config::Settings};
44 use quasi_router::{
45 Action, Choice, Field, Node, Slot,
46 layout::{FieldKind, Heading},
47 };
48
49 /// The region the whole pane sits in.
50 pub const PANE: &str = "notifications";
51
52 /// The described settings pane for every declared kind.
53 ///
54 /// One [`Slot`] the app drops into whatever screen it wants, containing a
55 /// group per category in declaration order. `route` is the route every control
56 /// calls; each sends its value under its own generated key, so one route
57 /// answers the whole pane.
58 #[must_use]
59 pub fn pane(registry: &Registry, settings: &impl Settings, route: &Action) -> Slot {
60 let mut pane = Slot::group(PANE);
61 for category in registry.categories() {
62 pane = pane.with(Node::Region(section(registry, category, settings, route)));
63 }
64 pane
65 }
66
67 /// One category's group: its heading, then its kinds.
68 #[must_use]
69 pub fn section(
70 registry: &Registry,
71 category: &str,
72 settings: &impl Settings,
73 route: &Action,
74 ) -> Slot {
75 let mut group = Slot::group(format!("{PANE}-{}", slug(category))).with(Node::Heading {
76 level: Heading::Section,
77 text: category.to_string(),
78 });
79 for kind in registry.kinds().iter().filter(|k| k.category == category) {
80 for field in fields(registry, kind, settings, route) {
81 group = group.with(Node::Field(Box::new(field)));
82 }
83 }
84 group
85 }
86
87 /// One kind's controls: its on/off, then one per knob.
88 ///
89 /// The knobs are offered whether or not the kind is on. Hiding them would make
90 /// the pane answer a question the reader did not ask -- what a kind *would* do
91 /// is worth reading before turning it on -- and a renderer that wants to dim
92 /// them still can.
93 #[must_use]
94 pub fn fields(
95 registry: &Registry,
96 kind: &Kind,
97 settings: &impl Settings,
98 route: &Action,
99 ) -> Vec<Field> {
100 let mut fields = Vec::with_capacity(1 + kind.options.len());
101
102 let generated = kind.enabled_config();
103 let mut toggle = Field::new(FieldKind::Checkbox, generated.key, kind.title)
104 .hint(kind.summary)
105 .writes(route.clone());
106 if registry.is_on(kind.id, settings) {
107 // A checkbox is here by presence, the way HTML submits one.
108 toggle = toggle.value("true");
109 }
110 fields.push(toggle);
111
112 for knob in kind.options {
113 let generated = kind.knob_config(knob);
114 let value = registry
115 .value(kind.id, Some(knob.id), settings)
116 .map(|v| v.text())
117 .unwrap_or_default();
118 let field = match knob.value {
119 Setting::Toggle(_) => {
120 let field = Field::new(FieldKind::Checkbox, generated.key, knob.label);
121 if value == "true" {
122 field.value("true")
123 } else {
124 field
125 }
126 }
127 // A lead time and a threshold are both typed numbers with a rule,
128 // rather than bounded drags: `Setting` carries no extent, and
129 // `FieldKind::Range` owes its bounds. A knob that wants a slider is
130 // a `Setting` variant that carries two ends, and nothing has asked.
131 Setting::Seconds(_) | Setting::Count(_) => {
132 Field::new(FieldKind::Number, generated.key, knob.label).value(value)
133 }
134 Setting::Choice { options, .. } => Field::select(
135 generated.key,
136 knob.label,
137 options.iter().map(|o| Choice::plain(*o)).collect(),
138 )
139 .value(value),
140 };
141 fields.push(field.writes(route.clone()));
142 }
143
144 fields
145 }
146
147 /// A category name as a region-id fragment.
148 ///
149 /// Region ids have to be stable and unique within a screen, and a category is
150 /// prose an app wrote for a human. Lowercase, and anything that is not a letter
151 /// or a digit becomes a hyphen.
152 fn slug(category: &str) -> String {
153 let mut out = String::with_capacity(category.len());
154 for c in category.chars() {
155 if c.is_ascii_alphanumeric() {
156 out.push(c.to_ascii_lowercase());
157 } else if !out.ends_with('-') {
158 out.push('-');
159 }
160 }
161 out.trim_matches('-').to_string()
162 }
163
164 #[cfg(test)]
165 mod tests {
166 use super::*;
167 use crate::{Knob, Registry, config::Unset};
168 use std::collections::HashMap;
169
170 static KINDS: &[Kind] = &[
171 Kind::new(
172 "snooze-expiry",
173 "Snoozed items resurface",
174 "When something you snoozed comes back.",
175 "Reminders",
176 )
177 .shipping_on(),
178 Kind::new(
179 "event-reminder",
180 "Event reminders",
181 "Before an event starts.",
182 "Reminders",
183 )
184 .shipping_on()
185 .with(&[
186 Knob::new("lead", "How long before", Setting::Seconds(900)),
187 Knob::new(
188 "sound",
189 "Sound",
190 Setting::Choice {
191 options: &["chime", "silent"],
192 default: "chime",
193 },
194 ),
195 ]),
196 Kind::new(
197 "digest",
198 "Daily digest",
199 "One summary of the day.",
200 "Daily summaries",
201 ),
202 ];
203
204 static NOTIFS: Registry = Registry::new(KINDS);
205
206 fn route() -> Action {
207 Action::post("/settings/notifications")
208 }
209
210 fn stored(pairs: &[(&str, &str)]) -> impl Settings {
211 let map: HashMap<String, String> = pairs
212 .iter()
213 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
214 .collect();
215 move |key: &str| map.get(key).cloned()
216 }
217
218 fn field_names(slot: &Slot) -> Vec<String> {
219 slot.body
220 .iter()
221 .filter_map(|ranked| match &ranked.node {
222 Node::Field(field) => Some(field.name.clone()),
223 Node::Region(inner) => Some(field_names(inner).join(" ")),
224 _ => None,
225 })
226 .filter(|s| !s.is_empty())
227 .collect()
228 }
229
230 #[test]
231 fn a_kind_arrives_in_the_pane_because_it_was_declared() {
232 // The whole claim: adding a kind adds its settings, and no app wrote
233 // any of this down twice.
234 let pane = pane(&NOTIFS, &Unset, &route());
235 let names = field_names(&pane).join(" ");
236 assert!(names.contains("snooze-expiry.enabled"));
237 assert!(names.contains("event-reminder.enabled"));
238 assert!(names.contains("event-reminder.lead"));
239 assert!(names.contains("event-reminder.sound"));
240 assert!(names.contains("digest.enabled"));
241 }
242
243 #[test]
244 fn a_category_is_a_group_and_they_keep_declaration_order() {
245 let pane = pane(&NOTIFS, &Unset, &route());
246 let ids: Vec<&str> = pane
247 .body
248 .iter()
249 .filter_map(|ranked| match &ranked.node {
250 Node::Region(slot) => Some(slot.id.as_str()),
251 _ => None,
252 })
253 .collect();
254 assert_eq!(
255 ids,
256 vec!["notifications-reminders", "notifications-daily-summaries"]
257 );
258 }
259
260 #[test]
261 fn every_control_carries_the_write_and_needs_no_submit() {
262 // `Field::writes`: changing the control is the write. Without it an
263 // app hand-rolls a dispatcher, which is what this replaces.
264 let fields = fields(
265 &NOTIFS,
266 NOTIFS.kind("event-reminder").unwrap(),
267 &Unset,
268 &route(),
269 );
270 assert_eq!(fields.len(), 3);
271 for field in &fields {
272 assert_eq!(field.writes, Some(route()));
273 }
274 }
275
276 #[test]
277 fn a_control_sends_its_value_under_the_generated_key() {
278 // So the handler reads a name nobody had to tell it, and the pane needs
279 // no map from control to key.
280 let fields = fields(
281 &NOTIFS,
282 NOTIFS.kind("event-reminder").unwrap(),
283 &Unset,
284 &route(),
285 );
286 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
287 assert_eq!(
288 names,
289 vec![
290 "event-reminder.enabled",
291 "event-reminder.lead",
292 "event-reminder.sound"
293 ]
294 );
295 }
296
297 #[test]
298 fn a_knob_becomes_the_control_its_type_asks_for() {
299 let fields = fields(
300 &NOTIFS,
301 NOTIFS.kind("event-reminder").unwrap(),
302 &Unset,
303 &route(),
304 );
305 assert_eq!(fields[0].kind, FieldKind::Checkbox);
306 assert_eq!(fields[1].kind, FieldKind::Number);
307 assert_eq!(fields[2].kind, FieldKind::Select);
308 assert_eq!(fields[2].options.len(), 2);
309 }
310
311 #[test]
312 fn the_control_shows_what_is_stored_and_the_default_when_nothing_is() {
313 let unset = fields(
314 &NOTIFS,
315 NOTIFS.kind("event-reminder").unwrap(),
316 &Unset,
317 &route(),
318 );
319 assert_eq!(unset[1].value.as_deref(), Some("900"));
320 assert_eq!(unset[0].value.as_deref(), Some("true"));
321
322 let settings = stored(&[
323 ("event-reminder.lead", "300"),
324 ("event-reminder.enabled", "false"),
325 ]);
326 let set = fields(
327 &NOTIFS,
328 NOTIFS.kind("event-reminder").unwrap(),
329 &settings,
330 &route(),
331 );
332 assert_eq!(set[1].value.as_deref(), Some("300"));
333 // A checkbox is here by presence: off means no value at all.
334 assert_eq!(set[0].value, None);
335 }
336
337 #[test]
338 fn the_toggle_carries_the_kinds_summary_so_the_pane_says_what_it_is_for() {
339 let fields = fields(
340 &NOTIFS,
341 NOTIFS.kind("snooze-expiry").unwrap(),
342 &Unset,
343 &route(),
344 );
345 assert_eq!(
346 fields[0].hint.as_deref(),
347 Some("When something you snoozed comes back.")
348 );
349 }
350
351 #[test]
352 fn a_category_name_becomes_a_stable_region_id() {
353 assert_eq!(slug("Daily summaries"), "daily-summaries");
354 assert_eq!(slug("Reminders & alerts"), "reminders-alerts");
355 assert_eq!(slug(" Odd "), "odd");
356 }
357 }
358