Skip to main content

max / audiofiles

15.0 KB · 367 lines History Blame Raw
1 //! The settings panel, described rather than built.
2 //!
3 //! The first audiofiles screen to go through `quasi`, behind an off-by-default
4 //! feature so the shipped panel in `ui::settings_panel` stays exactly as it is
5 //! while this one is proved. Same arrangement goingson's port programme uses.
6 //!
7 //! # What is describable here, and what is not
8 //!
9 //! The panel has nine sections and **four of them are describable**. That ratio
10 //! is not a disappointment; it is the same result goingson's settings port got
11 //! (five of eight not describable) and for the same cause, which is worth
12 //! stating precisely because it is easy to misread as a gap in the vocabulary:
13 //!
14 //! **A handler is `fn(&S, Request)`.** It is sync, it holds only what the app
15 //! put in `S`, and it cannot open a window. So a section whose subject is the
16 //! *host* rather than the app's own data does not come through:
17 //!
18 //! | Section | Described | Why not |
19 //! |---|---|---|
20 //! | Appearance | yes | a select over themes the host resolved at startup |
21 //! | Preview | yes | two booleans in `user_config` |
22 //! | Forge | yes | one boolean in `user_config` |
23 //! | Display | yes | five booleans, a number and a control |
24 //! | Storage | **no** | library paths, reachability, relocation: the filesystem |
25 //! | Advanced | **half** | export yes as of quasi 0.50.0; import still a host dialog |
26 //! | License | **no** | a key exchanged with a server |
27 //! | Trash | **no** | filesystem sizes and a destructive sweep over them |
28 //! | Classifier | **no** | its own model state, and bespoke |
29 //!
30 //! Storage and Trash are the honest kind of "no": they are about files on a
31 //! disk, and a description that named them would be describing this host's
32 //! filesystem.
33 //!
34 //! **Advanced was the interesting one, and half of it is answered.** This port
35 //! filed "a control that asks the host where to put something and then acts has
36 //! no vocabulary" as the second consumer of goingson's finding. Max ruled it on
37 //! 2026-08-21 (`67881a88`) and the answer was that the premise was wrong: there
38 //! is no picker in it. `Outcome::File` hands back a name, a kind and the bytes,
39 //! and **where they land is the host's** -- a save dialog here, the working
40 //! directory on a terminal, a download in a browser. Export Current is described
41 //! now and is this host's first consumer of the member; `panel::hand_over` is
42 //! the host half.
43 //!
44 //! Import Theme stays out, and it is a different gap: `FieldKind::File` says
45 //! what may be picked, and nothing carries the picked file's *bytes* to a sync
46 //! route on this host. That is host plumbing rather than vocabulary.
47 //!
48 //! # The finding this port adds
49 //!
50 //! **A set of choices cannot be grouped.** `draw_appearance_section` builds its
51 //! theme picker as four groups (Dark, Light, High Contrast, plus Follow the
52 //! system) and badges each theme with a contrast tier. [`Choice`] is a value and
53 //! a label, so the described version puts the variant in the label and loses the
54 //! structure. That is goingson's own settings finding, and audiofiles is its
55 //! **second consumer** with a stronger case: goingson grouped four `<optgroup>`s
56 //! in a webview, and this one groups *and* sorts within each group by measured
57 //! contrast.
58 //!
59 //! # One write route for the whole screen
60 //!
61 //! Every control here writes a `user_config` key, and the key set is closed by
62 //! [`ConfigKey`] with `from_key` refusing an undeclared one. So one route serves
63 //! all of them, exactly as goingson's `POST /settings/config/{key}` does, and
64 //! the screen carries no second list of what it is willing to name.
65
66 use audiofiles_core::config_key::ConfigKey;
67 use quasi_router::layout::FieldKind;
68 use quasi_router::{
69 Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen,
70 Slot,
71 };
72
73 use super::Panels;
74
75 /// The region the whole screen answers into.
76 const BODY: &str = "settings-body";
77
78 /// The columns the file list can show, as described names against the flags the
79 /// stored `column_config` blob carries.
80 ///
81 /// A described field per column, against one opaque key. That split is the
82 /// second half of this port's findings: the description names five booleans
83 /// because five is what the user sees, and storage keeps them in one JSON value
84 /// because that is what `ColumnConfig` already was. The route below is what
85 /// reconciles the two, and it is the right place for it — a description that
86 /// named the blob would be describing a storage format.
87 const COLUMNS: &[(&str, &str)] = &[
88 ("column.bpm", "BPM"),
89 ("column.key", "Key"),
90 ("column.duration", "Duration"),
91 ("column.peak_db", "Peak dB"),
92 ("column.tags", "Tags"),
93 ];
94
95 /// Register this screen's routes.
96 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
97 router
98 .get("/settings", index)
99 .post("/settings/config/{key}", write)
100 .post("/settings/columns/reset", reset_columns)
101 .post("/settings/theme/export", export_theme)
102 }
103
104 /// `GET /settings`
105 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
106 Ok(screen(state)?.into())
107 }
108
109 /// `POST /settings/config/{key}`
110 ///
111 /// One route for every control on the screen. An undeclared key is a
112 /// `NotFound` rather than an internal error: the address is reachable by
113 /// typing, and `ConfigKey::from_key` is the same refusal the rest of the app
114 /// makes.
115 fn write(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
116 let name = request.captures.require("key")?;
117 let value = request.payload.get(name).unwrap_or_default();
118
119 if let Some((key, stored)) = column_write(state, name, value) {
120 set(state, key, &stored)?;
121 return Ok(screen(state)?.into());
122 }
123
124 let key = ConfigKey::from_key(name).ok_or_else(|| RouteError::not_found("no such setting"))?;
125 set(state, key, value)?;
126 Ok(screen(state)?.into())
127 }
128
129 /// `POST /settings/columns/reset`
130 fn reset_columns(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
131 set(state, ConfigKey::ColumnConfig, "")?;
132 Ok(Response::from(screen(state)?).toast(
133 quasi_router::layout::Tone::Success,
134 "Columns restored to defaults.",
135 ))
136 }
137
138 /// The whole screen.
139 fn screen(state: &Panels<'_>) -> Result<Screen, RouteError> {
140 let mut body = Slot::new(BODY, RegionKind::Pane)
141 .with(Node::page("Settings"))
142 .with(Node::section("Appearance"))
143 .with(appearance(state))
144 .with(Node::section("Preview"))
145 .with(toggle(state, ConfigKey::PreviewLoop, "Loop playback")?)
146 .with(toggle(
147 state,
148 ConfigKey::PreviewAutoplay,
149 "Auto-play on navigate",
150 )?)
151 .with(Node::section("Forge"))
152 .with(toggle(
153 state,
154 ConfigKey::ForgeAutoTrimOvershoot,
155 "Auto-trim resample overshoot",
156 )?)
157 .with(Node::section("Display"));
158
159 let stored = get(state, ConfigKey::ColumnConfig)?.unwrap_or_default();
160 for (name, label) in COLUMNS {
161 body = body.with(Node::Field(Box::new(
162 Field::new(FieldKind::Checkbox, *name, *label)
163 .value(if column_shown(&stored, name) {
164 "on"
165 } else {
166 ""
167 })
168 .changes(Action::post(format!("/settings/config/{name}"))),
169 )));
170 }
171
172 body = body
173 .with(Node::Act(
174 Act::new("Reset columns", Action::post("/settings/columns/reset"))
175 .confirm("Restore column visibility, sort and row density to defaults?"),
176 ))
177 .with(Node::Field(Box::new(row_height(state)?)));
178
179 // Advanced, half of it. See the header: Export Current is describable as of
180 // quasi 0.50.0 and Import Theme is not, so the section is what the
181 // vocabulary can say rather than all-or-nothing.
182 if let Some(active) = active_theme(state) {
183 body = body
184 .with(Node::section("Advanced"))
185 .with(Node::text(format!(
186 "The theme showing is {}. Exporting writes {}.toml wherever you choose.",
187 active.name, active.id
188 )))
189 .with(Node::Act(Act::new(
190 "Export current theme",
191 Action::post("/settings/theme/export"),
192 )));
193 }
194
195 Ok(Screen::sidebar_content("Settings").with(body))
196 }
197
198 /// The theme showing, as the host resolved it.
199 ///
200 /// Matched on the stored id rather than on anything the renderer knows, and
201 /// `None` when nothing is stored or the stored id names a theme that is gone --
202 /// in which case there is nothing to export and the section does not appear.
203 fn active_theme<'a>(state: &'a Panels<'_>) -> Option<&'a super::ThemeChoice> {
204 let chosen = state.config.get(ConfigKey::Theme).ok().flatten()?;
205 state
206 .themes
207 .iter()
208 .find(|theme| theme.id == chosen && theme.source.is_some())
209 }
210
211 /// `POST /settings/theme/export`
212 ///
213 /// **First consumer of `Outcome::File` on this host** (`67881a88`, ruled
214 /// 2026-08-21: the route answers with the file and the host puts it somewhere).
215 ///
216 /// This is the shape the port's own header called the interesting "no": a
217 /// control that asks the host where to put something and then acts had no
218 /// vocabulary, and the answer turned out not to be a picker at all. The route
219 /// hands over bytes and a suggested name; where they land is the host's. So the
220 /// description never names a path, and the same act reads correctly on a
221 /// terminal and in a browser.
222 fn export_theme(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
223 let active = active_theme(state).ok_or_else(|| RouteError::not_found("no theme to export"))?;
224 let source = active
225 .source
226 .clone()
227 .ok_or_else(|| RouteError::not_found("that theme has no source to export"))?;
228 Ok(Response::file(
229 format!("{}.toml", active.id),
230 quasi_router::Accepted::suffix(".toml"),
231 source.into_bytes(),
232 ))
233 }
234
235 /// The theme picker.
236 ///
237 /// A `Field` and not a `Node::Select`, which is worth saying because the wrong
238 /// one is the obvious one: `Selector` is `Segmented | Toggle | Tabs`, a strip of
239 /// a handful of choices, and a theme picker is thirty-odd options that has to
240 /// collapse. That is a dropdown, which is `FieldKind::Select`. The vocabulary
241 /// draws the line at how many there are and whether they fold away, not at what
242 /// the thing means.
243 ///
244 /// Flat, and the finding in the module header is why: the shipped picker groups
245 /// by variant and sorts by contrast tier within each group, and `Choice` carries
246 /// a value and a label. The variant goes in the label so the fact survives; the
247 /// structure does not.
248 fn appearance(state: &Panels<'_>) -> Node {
249 let chosen = state.config.get(ConfigKey::Theme).ok().flatten();
250 let options = state
251 .themes
252 .iter()
253 .map(|theme| {
254 Choice::new(
255 theme.id.clone(),
256 format!("{} ({})", theme.name, theme.variant),
257 )
258 })
259 .collect();
260 let mut field = Field::select(ConfigKey::Theme.as_str(), "Theme", options)
261 .changes(Action::post("/settings/config/theme"));
262 field.value = chosen;
263 Node::Field(Box::new(field))
264 }
265
266 /// A boolean setting as a checkbox that writes when it changes.
267 ///
268 /// `Field::changes` rather than a form: this screen has no submit and never
269 /// should, which is the shape `14612ed8` was recounted for. Thirteen of
270 /// goingson's nineteen change-sites were standalone controls, and every control
271 /// here is one.
272 fn toggle(state: &Panels<'_>, key: ConfigKey, label: &str) -> Result<Node, RouteError> {
273 let on = get(state, key)?.is_some_and(|value| value == "1" || value == "true");
274 Ok(Node::Field(Box::new(
275 Field::new(FieldKind::Checkbox, key.as_str(), label)
276 .value(if on { "on" } else { "" })
277 .changes(Action::post(format!("/settings/config/{}", key.as_str()))),
278 )))
279 }
280
281 /// Row density, as a bounded number rather than a slider.
282 ///
283 /// The description says what the value may be and not what it looks like:
284 /// `Field::min` and `max` are the bounds the shipped slider draws as a track,
285 /// and a renderer with no slider draws a number that still cannot go out of
286 /// range. Naming the widget would have been the description choosing a control.
287 fn row_height(state: &Panels<'_>) -> Result<Field, RouteError> {
288 let current = get(state, ConfigKey::RowHeight)?.unwrap_or_else(|| "24".to_owned());
289 Ok(Field::new(
290 FieldKind::Number,
291 ConfigKey::RowHeight.as_str(),
292 "Row height",
293 )
294 .value(current)
295 .hint("Between 20 and 32 pixels.")
296 .changes(Action::post(format!(
297 "/settings/config/{}",
298 ConfigKey::RowHeight.as_str()
299 ))))
300 }
301
302 /// Whether a column is shown, read out of the stored blob.
303 ///
304 /// Absent means shown, which is what the app's own default does: a fresh
305 /// install with no `column_config` row shows every column.
306 fn column_shown(stored: &str, name: &str) -> bool {
307 let Some(flag) = name.strip_prefix("column.") else {
308 return true;
309 };
310 // The stored blob is JSON written by `ColumnConfig`. Read by looking for the
311 // flag rather than by parsing: this route reconciles a described name with a
312 // storage format it does not own, and taking a JSON dependency here to read
313 // one boolean would put the format's shape in the description layer.
314 match stored.find(&format!("\"show_{flag}\"")) {
315 Some(at) => !stored[at..].starts_with(&format!("\"show_{flag}\":false")),
316 None => true,
317 }
318 }
319
320 /// A described column name as the key and value the store wants.
321 ///
322 /// `None` when the name is not a column, which is what sends the caller down the
323 /// ordinary `ConfigKey` path.
324 fn column_write(state: &Panels<'_>, name: &str, value: &str) -> Option<(ConfigKey, String)> {
325 let flag = name.strip_prefix("column.")?;
326 let stored = state
327 .config
328 .get(ConfigKey::ColumnConfig)
329 .ok()
330 .flatten()
331 .unwrap_or_default();
332 let on = !value.is_empty();
333 let merged = merge_column(&stored, flag, on);
334 Some((ConfigKey::ColumnConfig, merged))
335 }
336
337 /// Set one flag in the stored column blob, leaving the rest alone.
338 fn merge_column(stored: &str, flag: &str, on: bool) -> String {
339 let key = format!("\"show_{flag}\"");
340 let replacement = format!("{key}:{on}");
341 match stored.find(&key) {
342 Some(at) => {
343 let rest = &stored[at..];
344 let end = rest
345 .find(',')
346 .or_else(|| rest.find('}'))
347 .unwrap_or(rest.len());
348 format!("{}{replacement}{}", &stored[..at], &rest[end..])
349 }
350 None if stored.trim().is_empty() => format!("{{{replacement}}}"),
351 None => {
352 let trimmed = stored.trim_end().trim_end_matches('}');
353 format!("{trimmed},{replacement}}}")
354 }
355 }
356 }
357
358 /// Read a key, reporting a store failure as this app's own.
359 fn get(state: &Panels<'_>, key: ConfigKey) -> Result<Option<String>, RouteError> {
360 state.config.get(key).map_err(RouteError::internal)
361 }
362
363 /// Write a key.
364 fn set(state: &Panels<'_>, key: ConfigKey, value: &str) -> Result<(), RouteError> {
365 state.config.set(key, value).map_err(RouteError::internal)
366 }
367