//! Settings, described rather than built. //! //! //! //! # The shape //! //! - `GET /settings` — Appearance. //! - `GET /settings/{section}` — one section. //! - `POST /settings/config/{key}` — write one config key, under `value`. //! //! The section is an address rather than module state, per decision 2. Unlike //! the projects filters and the weekly review's week it needs no carrying: a //! write answers with the section it happened in, and the handler knows which //! that is from the key. //! //! Appearance, Notifications and Planning & Review are `user_config` keys: rows //! in a table this app owns, declared as a closed set in //! [`crate::config_key::CONFIG`]. So the whole of this screen's writing is one //! route, `POST /settings/config/{key}`, refused by the same `ensure_known` the //! Tauri command is refused by. A screen that is a key/value editor should read //! as one. //! //! Email, Sync, Sharing and About are sections of their own; see [`email`], //! [`sync`], [`sharing`] and [`about`]. Import & Export is a screen of its own //! at `/data`; see [`Section::at`]. //! //! # A host fact belongs on `AppState` //! //! A route handler is `fn(&AppState, Request)`, so a section reading the host //! rather than the app has nothing to read. The general answer is not quasi's //! to give: `S` is the app's own state, and a host fact a described screen //! needs is a host fact the app resolves at startup and holds. The theme search //! path is //! [`AppState::theme_dirs`](crate::state::AppState::theme_dirs); the version //! and the platform are held the same way. //! //! What does not yield to it is a fact that is only true at the moment it is //! asked, such as whether biometry is enrolled. That is filed. A save dialog is //! not one of them: the route answers with the file and the host puts it //! somewhere, so the control never asks. //! //! The admin half of sharing stays host-bound. The member half is here, because //! being added to a group takes no write from the person being added. // Handlers take their request by value because `quasi_router::Handler` is a // plain `fn(&S, Request)` pointer, so the signature is the router's and not a // choice made here. Same allow, for the same reason, as quasi-axum's tests. #![allow(clippy::needless_pass_by_value)] use std::collections::HashMap; use quasi_declare::declare; use quasi_notifs::pane; use quasi_router::layout::{Contrast, ThemeVariant}; use quasi_router::screen::{Choice, ThemeChoice}; use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Slot}; use crate::notifs::NOTIFS; use crate::commands::{all_config, write_config}; use crate::state::AppState; pub(crate) mod about; pub(crate) mod email; pub(crate) mod sharing; pub(crate) mod sync; #[cfg(test)] mod tests; /// The region a section's contents answer at. /// /// Named once because several things aim at it: the screen that builds it, and /// every section that answers itself back after a write. const SECTION_REGION: &str = "settings-content"; /// A section of the screen: its address, its heading, and the keys it writes. struct Section { /// The last path segment, and what the sidebar sends. slug: &'static str, /// What the section is called. title: &'static str, /// Where the row goes, when that is not a section of this screen. /// /// `None` for the ordinary case, which is `/settings/{slug}` and a pane /// this module draws. /// /// `Some` is a row that navigates away, and Import & Export is the one: it /// is [`super::data`], a whole screen at `/data`. Serving `/data` under /// `/settings/data` instead would move a finished screen for the sake of /// the sidebar's shape. /// /// What it costs: the sidebar belongs to this screen, so following such a /// row leaves it behind and `/data` draws its own band and no nav. /// /// Two consequences hold wherever this is read. A row that leaves is never /// [`Row::current`], because this screen is not showing it. And /// [`section_of`] does not answer for one, so `/settings/data` is a 404 /// rather than Appearance drawn under somebody else's name. at: Option<&'static str>, } /// The sections that are described, in the sidebar's order. /// /// A section that cannot be drawn is absent rather than disabled: a control /// that is drawn and does nothing is worse than a control that is not drawn. const SECTIONS: [Section; 8] = [ Section { slug: "appearance", title: "Appearance", at: None, }, Section { slug: "notifications", title: "Notifications", at: None, }, Section { slug: "planning", title: "Planning & Review", at: None, }, // See `email`: the account is app data; only the OAuth handshake is // host-bound. Section { slug: "email", title: "Email", at: None, }, // Described in full as `super::data` and not a section of this screen; see // `Section::at`. Section { slug: "data", title: "Import & Export", at: Some("/data"), }, // Host facts, held on `AppState` at startup. See `about`. Section { slug: "about", title: "About", at: None, }, // Local per command, whatever the file's await count suggests; see `sync`. Section { slug: "sync", title: "Sync", at: None, }, // Reads the local group directory synckit writes down. See `sharing`. Section { slug: "sharing", title: "Sharing", at: None, }, ]; /// What the app falls back to when a key has never been written. /// /// Stated once here rather than at each read. The JS states each of these /// inline at its own call site — `|| 'system'`, `|| '15'`, `|| '9'` — which is /// how `event_lead_minutes` came to have its default written in three places. fn default_for(key: &str) -> &'static str { match key { "theme" => "system", "event_lead_minutes" => "15", "work_start_hour" => "9", "work_end_hour" => "17", // Both nudges are on unless someone turned them off, which is what the // JS's `!== 'disabled'` says the long way round. _ => "enabled", } } /// The value a key currently holds. fn value_of<'a>(config: &'a HashMap, key: &str) -> &'a str { config.get(key).map_or_else( || { // Borrowed from a `'static`, which outlives `'a`. default_for(key) }, String::as_str, ) } declare! { /// A select over a fixed set of values, holding the one in force. /// /// It writes as soon as it changes and stands on its own: [`Field::writes`] /// on a field in a panel, which is what finding `14612ed8` was closed for /// and what this screen is the first consumer of. The JS says the same /// thing with `data-change` on a bare `