//! The settings pane a declaration generates. //! //! Nodes, not HTML. A section per [`category`](crate::Kind::category), a toggle //! per kind, a control per knob, each carrying the kind's one-line summary as //! its hint. Because it is described, the terminal and egui hosts get the same //! pane for free, which is the entire reason this lives in quasi rather than in //! an app. //! //! # A pane per app is the failure, not the feature //! //! Adding a kind adds its settings. An app that wants a bespoke arrangement is //! an app the generator failed, and the fix is a member here rather than a //! hand-built pane there -- the same rule the description vocabulary lives //! under everywhere else. //! //! # Every control writes on its own //! //! There is no submit button and there should not be. A settings pane is the //! case [`Field::writes`] was added for: changing the control *is* the write, //! and goingson reached 13 of these through 109 lines of its own event plumbing //! before the description could say it. Each generated field carries the write //! route it was handed, and sends its value under the generated key -- so the //! handler reads a name it never had to be told, and the pane needs no map from //! control to key. //! //! ``` //! use quasi_notifs::{Kind, Registry, config::Unset, pane}; //! use quasi_router::Action; //! //! static KINDS: &[Kind] = &[Kind::new( //! "snooze-expiry", //! "Snoozed items resurface", //! "When something you snoozed comes back.", //! "Reminders", //! ) //! .shipping_on()]; //! static NOTIFS: Registry = Registry::new(KINDS); //! //! let pane = pane::pane(&NOTIFS, &Unset, &Action::post("/settings/notifications")); //! assert_eq!(pane.id, "notifications"); //! ``` use crate::{Kind, Registry, Setting, config::Settings}; use quasi_router::{ Action, Choice, Field, Node, Slot, layout::{FieldKind, Heading}, }; /// The region the whole pane sits in. pub const PANE: &str = "notifications"; /// The described settings pane for every declared kind. /// /// One [`Slot`] the app drops into whatever screen it wants, containing a /// group per category in declaration order. `route` is the route every control /// calls; each sends its value under its own generated key, so one route /// answers the whole pane. #[must_use] pub fn pane(registry: &Registry, settings: &impl Settings, route: &Action) -> Slot { let mut pane = Slot::group(PANE); for category in registry.categories() { pane = pane.with(Node::Region(section(registry, category, settings, route))); } pane } /// One category's group: its heading, then its kinds. #[must_use] pub fn section( registry: &Registry, category: &str, settings: &impl Settings, route: &Action, ) -> Slot { let mut group = Slot::group(format!("{PANE}-{}", slug(category))).with(Node::Heading { level: Heading::Section, text: category.to_string(), }); for kind in registry.kinds().iter().filter(|k| k.category == category) { for field in fields(registry, kind, settings, route) { group = group.with(Node::Field(Box::new(field))); } } group } /// One kind's controls: its on/off, then one per knob. /// /// The knobs are offered whether or not the kind is on. Hiding them would make /// the pane answer a question the reader did not ask -- what a kind *would* do /// is worth reading before turning it on -- and a renderer that wants to dim /// them still can. #[must_use] pub fn fields( registry: &Registry, kind: &Kind, settings: &impl Settings, route: &Action, ) -> Vec { let mut fields = Vec::with_capacity(1 + kind.options.len()); let generated = kind.enabled_config(); let mut toggle = Field::new(FieldKind::Checkbox, generated.key, kind.title) .hint(kind.summary) .writes(route.clone()); if registry.is_on(kind.id, settings) { // A checkbox is here by presence, the way HTML submits one. toggle = toggle.value("true"); } fields.push(toggle); for knob in kind.options { let generated = kind.knob_config(knob); let value = registry .value(kind.id, Some(knob.id), settings) .map(|v| v.text()) .unwrap_or_default(); let field = match knob.value { Setting::Toggle(_) => { let field = Field::new(FieldKind::Checkbox, generated.key, knob.label); if value == "true" { field.value("true") } else { field } } // A lead time and a threshold are both typed numbers with a rule, // rather than bounded drags: `Setting` carries no extent, and // `FieldKind::Range` owes its bounds. A knob that wants a slider is // a `Setting` variant that carries two ends, and nothing has asked. Setting::Seconds(_) | Setting::Count(_) => { Field::new(FieldKind::Number, generated.key, knob.label).value(value) } Setting::Choice { options, .. } => Field::select( generated.key, knob.label, options.iter().map(|o| Choice::plain(*o)).collect(), ) .value(value), }; fields.push(field.writes(route.clone())); } fields } /// A category name as a region-id fragment. /// /// Region ids have to be stable and unique within a screen, and a category is /// prose an app wrote for a human. Lowercase, and anything that is not a letter /// or a digit becomes a hyphen. fn slug(category: &str) -> String { let mut out = String::with_capacity(category.len()); for c in category.chars() { if c.is_ascii_alphanumeric() { out.push(c.to_ascii_lowercase()); } else if !out.ends_with('-') { out.push('-'); } } out.trim_matches('-').to_string() } #[cfg(test)] mod tests { use super::*; use crate::{Knob, Registry, config::Unset}; use std::collections::HashMap; static KINDS: &[Kind] = &[ Kind::new( "snooze-expiry", "Snoozed items resurface", "When something you snoozed comes back.", "Reminders", ) .shipping_on(), Kind::new( "event-reminder", "Event reminders", "Before an event starts.", "Reminders", ) .shipping_on() .with(&[ Knob::new("lead", "How long before", Setting::Seconds(900)), Knob::new( "sound", "Sound", Setting::Choice { options: &["chime", "silent"], default: "chime", }, ), ]), Kind::new( "digest", "Daily digest", "One summary of the day.", "Daily summaries", ), ]; static NOTIFS: Registry = Registry::new(KINDS); fn route() -> Action { Action::post("/settings/notifications") } fn stored(pairs: &[(&str, &str)]) -> impl Settings { let map: HashMap = pairs .iter() .map(|(k, v)| ((*k).to_string(), (*v).to_string())) .collect(); move |key: &str| map.get(key).cloned() } fn field_names(slot: &Slot) -> Vec { slot.body .iter() .filter_map(|ranked| match &ranked.node { Node::Field(field) => Some(field.name.clone()), Node::Region(inner) => Some(field_names(inner).join(" ")), _ => None, }) .filter(|s| !s.is_empty()) .collect() } #[test] fn a_kind_arrives_in_the_pane_because_it_was_declared() { // The whole claim: adding a kind adds its settings, and no app wrote // any of this down twice. let pane = pane(&NOTIFS, &Unset, &route()); let names = field_names(&pane).join(" "); assert!(names.contains("snooze-expiry.enabled")); assert!(names.contains("event-reminder.enabled")); assert!(names.contains("event-reminder.lead")); assert!(names.contains("event-reminder.sound")); assert!(names.contains("digest.enabled")); } #[test] fn a_category_is_a_group_and_they_keep_declaration_order() { let pane = pane(&NOTIFS, &Unset, &route()); let ids: Vec<&str> = pane .body .iter() .filter_map(|ranked| match &ranked.node { Node::Region(slot) => Some(slot.id.as_str()), _ => None, }) .collect(); assert_eq!( ids, vec!["notifications-reminders", "notifications-daily-summaries"] ); } #[test] fn every_control_carries_the_write_and_needs_no_submit() { // `Field::writes`: changing the control is the write. Without it an // app hand-rolls a dispatcher, which is what this replaces. let fields = fields( &NOTIFS, NOTIFS.kind("event-reminder").unwrap(), &Unset, &route(), ); assert_eq!(fields.len(), 3); for field in &fields { assert_eq!(field.writes, Some(route())); } } #[test] fn a_control_sends_its_value_under_the_generated_key() { // So the handler reads a name nobody had to tell it, and the pane needs // no map from control to key. let fields = fields( &NOTIFS, NOTIFS.kind("event-reminder").unwrap(), &Unset, &route(), ); let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); assert_eq!( names, vec![ "event-reminder.enabled", "event-reminder.lead", "event-reminder.sound" ] ); } #[test] fn a_knob_becomes_the_control_its_type_asks_for() { let fields = fields( &NOTIFS, NOTIFS.kind("event-reminder").unwrap(), &Unset, &route(), ); assert_eq!(fields[0].kind, FieldKind::Checkbox); assert_eq!(fields[1].kind, FieldKind::Number); assert_eq!(fields[2].kind, FieldKind::Select); assert_eq!(fields[2].options.len(), 2); } #[test] fn the_control_shows_what_is_stored_and_the_default_when_nothing_is() { let unset = fields( &NOTIFS, NOTIFS.kind("event-reminder").unwrap(), &Unset, &route(), ); assert_eq!(unset[1].value.as_deref(), Some("900")); assert_eq!(unset[0].value.as_deref(), Some("true")); let settings = stored(&[ ("event-reminder.lead", "300"), ("event-reminder.enabled", "false"), ]); let set = fields( &NOTIFS, NOTIFS.kind("event-reminder").unwrap(), &settings, &route(), ); assert_eq!(set[1].value.as_deref(), Some("300")); // A checkbox is here by presence: off means no value at all. assert_eq!(set[0].value, None); } #[test] fn the_toggle_carries_the_kinds_summary_so_the_pane_says_what_it_is_for() { let fields = fields( &NOTIFS, NOTIFS.kind("snooze-expiry").unwrap(), &Unset, &route(), ); assert_eq!( fields[0].hint.as_deref(), Some("When something you snoozed comes back.") ); } #[test] fn a_category_name_becomes_a_stable_region_id() { assert_eq!(slug("Daily summaries"), "daily-summaries"); assert_eq!(slug("Reminders & alerts"), "reminders-alerts"); assert_eq!(slug(" Odd "), "odd"); } }