//! The settings panel, described rather than built. //! //! The first audiofiles screen to go through `quasi`, behind an off-by-default //! feature so the shipped panel in `ui::settings_panel` stays exactly as it is //! while this one is proved. Same arrangement goingson's port programme uses. //! //! # What is describable here, and what is not //! //! The panel has nine sections and **four of them are describable**. That ratio //! is not a disappointment; it is the same result goingson's settings port got //! (five of eight not describable) and for the same cause, which is worth //! stating precisely because it is easy to misread as a gap in the vocabulary: //! //! **A handler is `fn(&S, Request)`.** It is sync, it holds only what the app //! put in `S`, and it cannot open a window. So a section whose subject is the //! *host* rather than the app's own data does not come through: //! //! | Section | Described | Why not | //! |---|---|---| //! | Appearance | yes | a select over themes the host resolved at startup | //! | Preview | yes | two booleans in `user_config` | //! | Forge | yes | one boolean in `user_config` | //! | Display | yes | five booleans, a number and a control | //! | Storage | **no** | library paths, reachability, relocation: the filesystem | //! | Advanced | **half** | export yes as of quasi 0.50.0; import still a host dialog | //! | License | **no** | a key exchanged with a server | //! | Trash | **no** | filesystem sizes and a destructive sweep over them | //! | Classifier | **no** | its own model state, and bespoke | //! //! Storage and Trash are the honest kind of "no": they are about files on a //! disk, and a description that named them would be describing this host's //! filesystem. //! //! **Advanced was the interesting one, and half of it is answered.** This port //! filed "a control that asks the host where to put something and then acts has //! no vocabulary" as the second consumer of goingson's finding. Max ruled it on //! 2026-08-21 (`67881a88`) and the answer was that the premise was wrong: there //! is no picker in it. `Outcome::File` hands back a name, a kind and the bytes, //! and **where they land is the host's** -- a save dialog here, the working //! directory on a terminal, a download in a browser. Export Current is described //! now and is this host's first consumer of the member; `panel::hand_over` is //! the host half. //! //! Import Theme stays out, and it is a different gap: `FieldKind::File` says //! what may be picked, and nothing carries the picked file's *bytes* to a sync //! route on this host. That is host plumbing rather than vocabulary. //! //! # The finding this port adds //! //! **A set of choices cannot be grouped.** `draw_appearance_section` builds its //! theme picker as four groups (Dark, Light, High Contrast, plus Follow the //! system) and badges each theme with a contrast tier. [`Choice`] is a value and //! a label, so the described version puts the variant in the label and loses the //! structure. That is goingson's own settings finding, and audiofiles is its //! **second consumer** with a stronger case: goingson grouped four ``s //! in a webview, and this one groups *and* sorts within each group by measured //! contrast. //! //! # One write route for the whole screen //! //! Every control here writes a `user_config` key, and the key set is closed by //! [`ConfigKey`] with `from_key` refusing an undeclared one. So one route serves //! all of them, exactly as goingson's `POST /settings/config/{key}` does, and //! the screen carries no second list of what it is willing to name. use audiofiles_core::config_key::ConfigKey; use quasi_router::layout::FieldKind; use quasi_router::{ Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, }; use super::Panels; /// The region the whole screen answers into. const BODY: &str = "settings-body"; /// The columns the file list can show, as described names against the flags the /// stored `column_config` blob carries. /// /// A described field per column, against one opaque key. That split is the /// second half of this port's findings: the description names five booleans /// because five is what the user sees, and storage keeps them in one JSON value /// because that is what `ColumnConfig` already was. The route below is what /// reconciles the two, and it is the right place for it — a description that /// named the blob would be describing a storage format. const COLUMNS: &[(&str, &str)] = &[ ("column.bpm", "BPM"), ("column.key", "Key"), ("column.duration", "Duration"), ("column.peak_db", "Peak dB"), ("column.tags", "Tags"), ]; /// Register this screen's routes. pub fn routes(router: Router>) -> Router> { router .get("/settings", index) .post("/settings/config/{key}", write) .post("/settings/columns/reset", reset_columns) .post("/settings/theme/export", export_theme) } /// `GET /settings` fn index(state: &Panels<'_>, _request: Request) -> Result { Ok(screen(state)?.into()) } /// `POST /settings/config/{key}` /// /// One route for every control on the screen. An undeclared key is a /// `NotFound` rather than an internal error: the address is reachable by /// typing, and `ConfigKey::from_key` is the same refusal the rest of the app /// makes. fn write(state: &Panels<'_>, request: Request) -> Result { let name = request.captures.require("key")?; let value = request.payload.get(name).unwrap_or_default(); if let Some((key, stored)) = column_write(state, name, value) { set(state, key, &stored)?; return Ok(screen(state)?.into()); } let key = ConfigKey::from_key(name).ok_or_else(|| RouteError::not_found("no such setting"))?; set(state, key, value)?; Ok(screen(state)?.into()) } /// `POST /settings/columns/reset` fn reset_columns(state: &Panels<'_>, _request: Request) -> Result { set(state, ConfigKey::ColumnConfig, "")?; Ok(Response::from(screen(state)?).toast( quasi_router::layout::Tone::Success, "Columns restored to defaults.", )) } /// The whole screen. fn screen(state: &Panels<'_>) -> Result { let mut body = Slot::new(BODY, RegionKind::Pane) .with(Node::page("Settings")) .with(Node::section("Appearance")) .with(appearance(state)) .with(Node::section("Preview")) .with(toggle(state, ConfigKey::PreviewLoop, "Loop playback")?) .with(toggle( state, ConfigKey::PreviewAutoplay, "Auto-play on navigate", )?) .with(Node::section("Forge")) .with(toggle( state, ConfigKey::ForgeAutoTrimOvershoot, "Auto-trim resample overshoot", )?) .with(Node::section("Display")); let stored = get(state, ConfigKey::ColumnConfig)?.unwrap_or_default(); for (name, label) in COLUMNS { body = body.with(Node::Field(Box::new( Field::new(FieldKind::Checkbox, *name, *label) .value(if column_shown(&stored, name) { "on" } else { "" }) .changes(Action::post(format!("/settings/config/{name}"))), ))); } body = body .with(Node::Act( Act::new("Reset columns", Action::post("/settings/columns/reset")) .confirm("Restore column visibility, sort and row density to defaults?"), )) .with(Node::Field(Box::new(row_height(state)?))); // Advanced, half of it. See the header: Export Current is describable as of // quasi 0.50.0 and Import Theme is not, so the section is what the // vocabulary can say rather than all-or-nothing. if let Some(active) = active_theme(state) { body = body .with(Node::section("Advanced")) .with(Node::text(format!( "The theme showing is {}. Exporting writes {}.toml wherever you choose.", active.name, active.id ))) .with(Node::Act(Act::new( "Export current theme", Action::post("/settings/theme/export"), ))); } Ok(Screen::sidebar_content("Settings").with(body)) } /// The theme showing, as the host resolved it. /// /// Matched on the stored id rather than on anything the renderer knows, and /// `None` when nothing is stored or the stored id names a theme that is gone -- /// in which case there is nothing to export and the section does not appear. fn active_theme<'a>(state: &'a Panels<'_>) -> Option<&'a super::ThemeChoice> { let chosen = state.config.get(ConfigKey::Theme).ok().flatten()?; state .themes .iter() .find(|theme| theme.id == chosen && theme.source.is_some()) } /// `POST /settings/theme/export` /// /// **First consumer of `Outcome::File` on this host** (`67881a88`, ruled /// 2026-08-21: the route answers with the file and the host puts it somewhere). /// /// This is the shape the port's own header called the interesting "no": a /// control that asks the host where to put something and then acts had no /// vocabulary, and the answer turned out not to be a picker at all. The route /// hands over bytes and a suggested name; where they land is the host's. So the /// description never names a path, and the same act reads correctly on a /// terminal and in a browser. fn export_theme(state: &Panels<'_>, _request: Request) -> Result { let active = active_theme(state).ok_or_else(|| RouteError::not_found("no theme to export"))?; let source = active .source .clone() .ok_or_else(|| RouteError::not_found("that theme has no source to export"))?; Ok(Response::file( format!("{}.toml", active.id), quasi_router::Accepted::suffix(".toml"), source.into_bytes(), )) } /// The theme picker. /// /// A `Field` and not a `Node::Select`, which is worth saying because the wrong /// one is the obvious one: `Selector` is `Segmented | Toggle | Tabs`, a strip of /// a handful of choices, and a theme picker is thirty-odd options that has to /// collapse. That is a dropdown, which is `FieldKind::Select`. The vocabulary /// draws the line at how many there are and whether they fold away, not at what /// the thing means. /// /// Flat, and the finding in the module header is why: the shipped picker groups /// by variant and sorts by contrast tier within each group, and `Choice` carries /// a value and a label. The variant goes in the label so the fact survives; the /// structure does not. fn appearance(state: &Panels<'_>) -> Node { let chosen = state.config.get(ConfigKey::Theme).ok().flatten(); let options = state .themes .iter() .map(|theme| { Choice::new( theme.id.clone(), format!("{} ({})", theme.name, theme.variant), ) }) .collect(); let mut field = Field::select(ConfigKey::Theme.as_str(), "Theme", options) .changes(Action::post("/settings/config/theme")); field.value = chosen; Node::Field(Box::new(field)) } /// A boolean setting as a checkbox that writes when it changes. /// /// `Field::changes` rather than a form: this screen has no submit and never /// should, which is the shape `14612ed8` was recounted for. Thirteen of /// goingson's nineteen change-sites were standalone controls, and every control /// here is one. fn toggle(state: &Panels<'_>, key: ConfigKey, label: &str) -> Result { let on = get(state, key)?.is_some_and(|value| value == "1" || value == "true"); Ok(Node::Field(Box::new( Field::new(FieldKind::Checkbox, key.as_str(), label) .value(if on { "on" } else { "" }) .changes(Action::post(format!("/settings/config/{}", key.as_str()))), ))) } /// Row density, as a bounded number rather than a slider. /// /// The description says what the value may be and not what it looks like: /// `Field::min` and `max` are the bounds the shipped slider draws as a track, /// and a renderer with no slider draws a number that still cannot go out of /// range. Naming the widget would have been the description choosing a control. fn row_height(state: &Panels<'_>) -> Result { let current = get(state, ConfigKey::RowHeight)?.unwrap_or_else(|| "24".to_owned()); Ok(Field::new( FieldKind::Number, ConfigKey::RowHeight.as_str(), "Row height", ) .value(current) .hint("Between 20 and 32 pixels.") .changes(Action::post(format!( "/settings/config/{}", ConfigKey::RowHeight.as_str() )))) } /// Whether a column is shown, read out of the stored blob. /// /// Absent means shown, which is what the app's own default does: a fresh /// install with no `column_config` row shows every column. fn column_shown(stored: &str, name: &str) -> bool { let Some(flag) = name.strip_prefix("column.") else { return true; }; // The stored blob is JSON written by `ColumnConfig`. Read by looking for the // flag rather than by parsing: this route reconciles a described name with a // storage format it does not own, and taking a JSON dependency here to read // one boolean would put the format's shape in the description layer. match stored.find(&format!("\"show_{flag}\"")) { Some(at) => !stored[at..].starts_with(&format!("\"show_{flag}\":false")), None => true, } } /// A described column name as the key and value the store wants. /// /// `None` when the name is not a column, which is what sends the caller down the /// ordinary `ConfigKey` path. fn column_write(state: &Panels<'_>, name: &str, value: &str) -> Option<(ConfigKey, String)> { let flag = name.strip_prefix("column.")?; let stored = state .config .get(ConfigKey::ColumnConfig) .ok() .flatten() .unwrap_or_default(); let on = !value.is_empty(); let merged = merge_column(&stored, flag, on); Some((ConfigKey::ColumnConfig, merged)) } /// Set one flag in the stored column blob, leaving the rest alone. fn merge_column(stored: &str, flag: &str, on: bool) -> String { let key = format!("\"show_{flag}\""); let replacement = format!("{key}:{on}"); match stored.find(&key) { Some(at) => { let rest = &stored[at..]; let end = rest .find(',') .or_else(|| rest.find('}')) .unwrap_or(rest.len()); format!("{}{replacement}{}", &stored[..at], &rest[end..]) } None if stored.trim().is_empty() => format!("{{{replacement}}}"), None => { let trimmed = stored.trim_end().trim_end_matches('}'); format!("{trimmed},{replacement}}}") } } } /// Read a key, reporting a store failure as this app's own. fn get(state: &Panels<'_>, key: ConfigKey) -> Result, RouteError> { state.config.get(key).map_err(RouteError::internal) } /// Write a key. fn set(state: &Panels<'_>, key: ConfigKey, value: &str) -> Result<(), RouteError> { state.config.set(key, value).map_err(RouteError::internal) }