Skip to main content

max / audiofiles

Declare the settings screen and its four shapes The read was the work, as it has been in every wave since 11. Three of the four shapes were `-> Result<_>`, which is a read that has not been hoisted rather than a refusal, and `Settings` is the hoist: one fallible pass over the config store and four total descriptions after it. `is_on` replaces the boolean check each toggle was making for itself. The storage section had to move first. It took the settings body and handed it back, and so did `maintenance` and `add_library` inside it, so the screen could not splice it once the screen was a declaration. All three now answer with nodes. They stay hand-written; a later wave declares them. Two things the conversion found: COLUMNS was a slice of pairs and is now a slice of `Column`. Same reshaping the two MNW consts took, for the same reason: a description names what it draws and `.1` is not a name. The five column checkboxes were their own loop and are `toggle` calls now. A column's described name is the key it writes, so the only thing that ever told them apart was the reconciliation the write route does, which was never the description's. The picker's stored id is placed by a loop over its Option, so nothing stored means no value rather than an empty one, which is what the assignment to `field.value` was doing by hand. That read is now fallible with the rest: a config store that cannot answer makes the screen an error rather than drawing a picker with nothing chosen. `screen` is the declaration and `showing` is the read in front of it, which is the split goingson's settings already uses.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 20:46 UTC
Signed with PGP, not checked
Commit: 5bec7cfde6c6daed1cebf8570d3e2f0515357593
Parent: 9cafb5b
5 files changed, +317 insertions, -224 deletions
@@ -136,7 +136,7 @@
136 136
137 137 /// The settings window again, which is what every act here answers with.
138 138 fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
139 - Ok(super::settings::screen(state)?.into())
139 + Ok(super::settings::showing(state)?.into())
140 140 }
141 141
142 142 declare! {
@@ -89,7 +89,7 @@
89 89
90 90 /// The settings window again, which is what both acts answer with.
91 91 fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
92 - Ok(super::settings::screen(state)?.into())
92 + Ok(super::settings::showing(state)?.into())
93 93 }
94 94
95 95 /// The licence, as the section draws it.
@@ -65,11 +65,10 @@
65 65 //! the screen carries no second list of what it is willing to name.
66 66
67 67 use audiofiles_core::config_key::ConfigKey;
68 - use quasi_router::layout::FieldKind;
68 +
69 + use quasi_declare::declare;
69 70 use quasi_router::layout::{Contrast, ThemeVariant};
70 - use quasi_router::{
71 - Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot,
72 - };
71 + use quasi_router::{Action, Node, Request, Response, RouteError, Router, Screen};
73 72 // Aliased: this module's `super::ThemeChoice` is the host's own resolved theme,
74 73 // and the router's is how a description names one. Both are in scope here
75 74 // because this function is exactly the seam between them.
@@ -80,6 +79,17 @@
80 79 /// The region the whole screen answers into.
81 80 const BODY: &str = "settings-body";
82 81
82 + /// One column of the file list, as the description names it.
83 + ///
84 + /// A struct rather than a pair, for the reason MNW's two reshaped consts give:
85 + /// a description names what it draws and `.1` is not a name.
86 + struct Column {
87 + /// The stored flag, which is also the key its checkbox writes to.
88 + key: &'static str,
89 + /// What the checkbox reads.
90 + label: &'static str,
91 + }
92 +
83 93 /// The columns the file list can show, as described names against the flags the
84 94 /// stored `column_config` blob carries.
85 95 ///
@@ -87,14 +97,29 @@
87 97 /// second half of this port's findings: the description names five booleans
88 98 /// because five is what the user sees, and storage keeps them in one JSON value
89 99 /// because that is what `ColumnConfig` already was. The route below is what
90 - /// reconciles the two, and it is the right place for it — a description that
100 + /// reconciles the two, and it is the right place for it: a description that
91 101 /// named the blob would be describing a storage format.
92 - const COLUMNS: &[(&str, &str)] = &[
93 - ("column.bpm", "BPM"),
94 - ("column.key", "Key"),
95 - ("column.duration", "Duration"),
96 - ("column.peak_db", "Peak dB"),
97 - ("column.tags", "Tags"),
102 + const COLUMNS: [Column; 5] = [
103 + Column {
104 + key: "column.bpm",
105 + label: "BPM",
106 + },
107 + Column {
108 + key: "column.key",
109 + label: "Key",
110 + },
111 + Column {
112 + key: "column.duration",
113 + label: "Duration",
114 + },
115 + Column {
116 + key: "column.peak_db",
117 + label: "Peak dB",
118 + },
119 + Column {
120 + key: "column.tags",
121 + label: "Tags",
122 + },
98 123 ];
99 124
100 125 /// Register this screen's routes.
@@ -108,7 +133,7 @@
108 133
109 134 /// `GET /settings`
110 135 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
111 - Ok(screen(state)?.into())
136 + Ok(showing(state)?.into())
112 137 }
113 138
114 139 /// `POST /settings/config/{key}`
@@ -123,111 +148,200 @@
123 148
124 149 if let Some((key, stored)) = column_write(state, name, value) {
125 150 set(state, key, &stored)?;
126 - return Ok(screen(state)?.into());
151 + return Ok(showing(state)?.into());
127 152 }
128 153
129 154 let key = ConfigKey::from_key(name).ok_or_else(|| RouteError::not_found("no such setting"))?;
130 155 set(state, key, value)?;
131 - Ok(screen(state)?.into())
156 + Ok(showing(state)?.into())
132 157 }
133 158
134 159 /// `POST /settings/columns/reset`
135 160 fn reset_columns(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
136 161 set(state, ConfigKey::ColumnConfig, "")?;
137 - Ok(Response::from(screen(state)?).toast(
162 + Ok(Response::from(showing(state)?).toast(
138 163 quasi_router::layout::Tone::Success,
139 164 "Columns restored to defaults.",
140 165 ))
141 166 }
142 167
143 - /// The whole screen.
168 + /// Everything the screen draws that has to be read, read once.
169 + ///
170 + /// Three of this module's four shapes were `-> Result<_>`, which is a read that
171 + /// has not been hoisted rather than a refusal. This is the hoist: one fallible
172 + /// pass over the config store, and four total descriptions after it.
173 + pub(super) struct Settings {
174 + /// The theme picker's entries, in `makeover::order_theme_options`' order.
175 + themes: Vec<DescribedTheme>,
176 + /// The theme id stored, when one is. Absent stays absent: the picker
177 + /// carries no value at all rather than an empty one.
178 + chosen: Option<String>,
179 + /// Loop playback.
180 + preview_loop: bool,
181 + /// Auto-play on navigate.
182 + preview_autoplay: bool,
183 + /// Auto-trim resample overshoot.
184 + forge_auto_trim: bool,
185 + /// The file list's columns, and whether each is showing.
186 + columns: Vec<Showing>,
187 + /// Row density, as the stored string.
188 + row_height: String,
189 + /// The theme showing, when it is one there is something to export from.
190 + exportable: Option<Exportable>,
191 + }
192 +
193 + /// One column checkbox, with the answer the stored blob gave.
194 + struct Showing {
195 + /// The stored flag, which is also the key the checkbox writes to.
196 + key: &'static str,
197 + /// What the checkbox reads.
198 + label: &'static str,
199 + /// Whether it is showing today.
200 + on: bool,
201 + }
202 +
203 + /// The theme showing, in the two words the Advanced sentence uses.
204 + struct Exportable {
205 + /// What it is called.
206 + name: String,
207 + /// Its id, which is also the filename the export writes.
208 + id: String,
209 + }
210 +
211 + /// The whole screen's read, in one fallible pass.
212 + fn read(state: &Panels<'_>) -> Result<Settings, RouteError> {
213 + let stored = get(state, ConfigKey::ColumnConfig)?.unwrap_or_default();
214 + Ok(Settings {
215 + themes: state
216 + .themes
217 + .iter()
218 + .map(|theme| {
219 + DescribedTheme::new(
220 + theme.id.clone(),
221 + theme.name.clone(),
222 + variant_of(theme.variant),
223 + tier_of(theme.contrast),
224 + )
225 + })
226 + .collect(),
227 + chosen: get(state, ConfigKey::Theme)?,
228 + preview_loop: is_on(state, ConfigKey::PreviewLoop)?,
229 + preview_autoplay: is_on(state, ConfigKey::PreviewAutoplay)?,
230 + forge_auto_trim: is_on(state, ConfigKey::ForgeAutoTrimOvershoot)?,
231 + columns: COLUMNS
232 + .iter()
233 + .map(|column| Showing {
234 + key: column.key,
235 + label: column.label,
236 + on: column_shown(&stored, column.key),
237 + })
238 + .collect(),
239 + row_height: get(state, ConfigKey::RowHeight)?.unwrap_or_else(|| "24".to_owned()),
240 + exportable: active_theme(state).map(|theme| Exportable {
241 + name: theme.name.clone(),
242 + id: theme.id.clone(),
243 + }),
244 + })
245 + }
246 +
247 + /// A stored boolean, in the two spellings the store has written over the years.
248 + fn is_on(state: &Panels<'_>, key: ConfigKey) -> Result<bool, RouteError> {
249 + Ok(get(state, key)?.is_some_and(|value| value == "1" || value == "true"))
250 + }
251 +
252 + /// The whole screen, read and then described.
144 253 ///
145 254 /// Reachable from [`storage`](super::storage), whose acts all answer with the
146 - /// settings window again: the Storage section is served from its own module and
147 - /// is spliced in below, so the two halves of one screen live where their routes
148 - /// do.
149 - pub(super) fn screen(state: &Panels<'_>) -> Result<Screen, RouteError> {
150 - let mut body = Slot::new(BODY, RegionKind::Pane)
151 - .with(Node::page("Settings"))
152 - // The way off. quasicoherent `33c27e81`: this screen replaces the
153 - // shell's, so without a described way back the only exit was a host
154 - // affordance -- an Escape this app read itself, which a pointer could
155 - // not reach and a touch gesture had nothing to bind to. `Action::back`
156 - // is the described fact, and every host answers it from its own
157 - // history.
158 - .with(Node::Act(Act::new("Close", Action::back())))
159 - .with(Node::section("Appearance"))
160 - .with(appearance(state))
161 - .with(Node::section("Preview"))
162 - .with(toggle(state, ConfigKey::PreviewLoop, "Loop playback")?)
163 - .with(toggle(
164 - state,
165 - ConfigKey::PreviewAutoplay,
166 - "Auto-play on navigate",
167 - )?)
168 - .with(Node::section("Forge"))
169 - .with(toggle(
170 - state,
171 - ConfigKey::ForgeAutoTrimOvershoot,
172 - "Auto-trim resample overshoot",
173 - )?)
174 - .with(Node::section("Display"));
255 + /// settings window again: four of its sections are served from their own
256 + /// modules and are spliced in here, so the halves of one screen live where
257 + /// their routes do.
258 + pub(super) fn showing(state: &Panels<'_>) -> Result<Screen, RouteError> {
259 + Ok(screen(
260 + &read(state)?,
261 + super::storage::section(state),
262 + super::trash::section(&super::trash::read(state)),
263 + super::licence::section(&super::licence::read(state)),
264 + super::advanced::section(state.advanced.mirror().as_ref()),
265 + ))
266 + }
175 267
176 - let stored = get(state, ConfigKey::ColumnConfig)?.unwrap_or_default();
177 - for (name, label) in COLUMNS {
178 - body = body.with(Node::Field(Box::new(
179 - Field::new(FieldKind::Checkbox, *name, *label)
180 - .value(if column_shown(&stored, name) {
181 - "on"
182 - } else {
183 - ""
184 - })
185 - .writes(Action::post(format!("/settings/config/{name}"))),
186 - )));
268 + declare! {
269 + /// The settings window.
270 + ///
271 + /// The four spliced sections arrive already drawn rather than being reached
272 + /// from here, because each is its own module's description and each owns
273 + /// the routes its acts address.
274 + pub(super) shape screen(
275 + settings: &Settings,
276 + storage: Vec<Node>,
277 + trash: Vec<Node>,
278 + licence: Vec<Node>,
279 + advanced: Vec<Node>,
280 + ) -> Screen;
281 +
282 + screen sidebar_content "Settings" {
283 + region BODY as Pane {
284 + page "Settings";
285 +
286 + // The way off. quasicoherent `33c27e81`: this screen replaces the
287 + // shell's, so without a described way back the only exit was a host
288 + // affordance -- an Escape this app read itself, which a pointer
289 + // could not reach and a touch gesture had nothing to bind to.
290 + // `Action::back` is the described fact, and every host answers it
291 + // from its own history.
292 + act "Close" to back;
293 +
294 + section "Appearance";
295 + include appearance(settings);
296 +
297 + section "Preview";
298 + include toggle(ConfigKey::PreviewLoop.as_str(), "Loop playback", settings.preview_loop);
299 + include toggle(
300 + ConfigKey::PreviewAutoplay.as_str(),
301 + "Auto-play on navigate",
302 + settings.preview_autoplay
303 + );
304 +
305 + section "Forge";
306 + include toggle(
307 + ConfigKey::ForgeAutoTrimOvershoot.as_str(),
308 + "Auto-trim resample overshoot",
309 + settings.forge_auto_trim
310 + );
311 +
312 + section "Display";
313 + for column in settings.columns.iter() {
314 + include toggle(column.key, column.label, column.on);
315 + }
316 + act "Reset columns" to post "/settings/columns/reset" {
317 + confirm "Restore column visibility, sort and row density to defaults?";
318 + }
319 + include row_height(&settings.row_height);
320 +
321 + extend storage;
322 + extend trash;
323 + extend licence;
324 +
325 + // The classifier's door. Max ruled it a window of its own; what
326 + // stays here is one act, so the thing is findable from Settings.
327 + // See `super::classifier`'s header.
328 + section "Tagging";
329 + text "Rules, auto-tagging, clustering and folder tags, with the \
330 + classifiers you have imported.";
331 + act "Tag classifier..." to get "/classifier";
332 +
333 + // Advanced. The heading is unconditional and the export is not: a
334 + // theme that is gone has nothing to export, and the section still
335 + // has an import and a mirror under it.
336 + section "Advanced";
337 + for theme in settings.exportable.iter() {
338 + text "The theme showing is {theme.name}. Exporting writes \
339 + {theme.id}.toml wherever you choose.";
340 + act "Export current theme" to post "/settings/theme/export";
341 + }
342 + extend advanced;
343 + }
187 344 }
188 -
189 - body = body
190 - .with(Node::Act(
191 - Act::new("Reset columns", Action::post("/settings/columns/reset"))
192 - .confirm("Restore column visibility, sort and row density to defaults?"),
193 - ))
194 - .with(Node::Field(Box::new(row_height(state)?)));
195 -
196 - body = super::storage::section(body, state);
197 - body = body.extend(super::trash::section(&super::trash::read(state)));
198 - body = body.extend(super::licence::section(&super::licence::read(state)));
199 -
200 - // The classifier's door. Max ruled it a window of its own; what stays here
201 - // is one act, so the thing is findable from Settings.
202 - // See `super::classifier`'s header.
203 - body = body
204 - .with(Node::section("Tagging"))
205 - .with(Node::text(
206 - "Rules, auto-tagging, clustering and folder tags, with the classifiers you have imported.",
207 - ))
208 - .with(Node::Act(Act::new(
209 - "Tag classifier...",
210 - Action::get("/classifier"),
211 - )));
212 -
213 - // Advanced. The heading is unconditional and
214 - // the export is not: a theme that is gone has nothing to export, and the
215 - // section still has an import and a mirror under it.
216 - body = body.with(Node::section("Advanced"));
217 - if let Some(active) = active_theme(state) {
218 - body = body
219 - .with(Node::text(format!(
220 - "The theme showing is {}. Exporting writes {}.toml wherever you choose.",
221 - active.name, active.id
222 - )))
223 - .with(Node::Act(Act::new(
224 - "Export current theme",
225 - Action::post("/settings/theme/export"),
226 - )));
227 - }
228 - body = body.extend(super::advanced::section(state.advanced.mirror().as_ref()));
229 -
230 - Ok(Screen::sidebar_content("Settings").with(body))
231 345 }
232 346
233 347 /// The theme showing, as the host resolved it.
@@ -249,10 +363,9 @@
249 363 /// answers with the file and the host puts it somewhere).
250 364 ///
251 365 /// A control that asks the host where to put something and then acts is not a
252 - /// picker. The route
253 - /// hands over bytes and a suggested name; where they land is the host's. So the
254 - /// description never names a path, and the same act reads correctly on a
255 - /// terminal and in a browser.
366 + /// picker. The route hands over bytes and a suggested name; where they land is
367 + /// the host's. So the description never names a path, and the same act reads
368 + /// correctly on a terminal and in a browser.
256 369 fn export_theme(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
257 370 let active = active_theme(state).ok_or_else(|| RouteError::not_found("no theme to export"))?;
258 371 let source = active
@@ -266,47 +379,27 @@
266 379 ))
267 380 }
268 381
269 - /// The theme picker.
270 - ///
271 - /// `FieldKind::Theme` and not `FieldKind::Select`, as of makeover-layout
272 - /// 0.38.0. Thirty-odd options that have to collapse and carry a swatch each is
273 - /// not the dropdown a plain choice gets.
274 - ///
275 - /// # The finding in the module header is closed
276 - ///
277 - /// It said a set of choices cannot be grouped, and recorded this app as the
278 - /// **second consumer** of goingson's finding with the stronger case: the
279 - /// shipped egui picker grouped by variant *and* sorted by contrast tier within
280 - /// each group, and `Choice` is a value and a label, so the described version
281 - /// flattened it and lost the tier.
282 - ///
283 - /// The answer is not a group on the choice. Grouping recurs at exactly one live
284 - /// site across the tree, so the thing that
285 - /// recurs is this picker rather than option lists that group. `Field::theme`
286 - /// takes entries carrying their variant and their measured tier as values, and
287 - /// both facts come back.
288 - ///
289 - /// Nothing here groups and nothing here sorts. `panel::themes` hands the list
290 - /// over in `makeover::order_theme_options`' order, which is the order the
291 - /// renderer reads the groups out of.
292 - fn appearance(state: &Panels<'_>) -> Node {
293 - let chosen = state.config.get(ConfigKey::Theme).ok().flatten();
294 - let themes = state
295 - .themes
296 - .iter()
297 - .map(|theme| {
298 - DescribedTheme::new(
299 - theme.id.clone(),
300 - theme.name.clone(),
301 - variant_of(theme.variant),
302 - tier_of(theme.contrast),
303 - )
304 - })
305 - .collect();
306 - let mut field = Field::theme(ConfigKey::Theme.as_str(), "Theme", themes)
307 - .writes(Action::post("/settings/config/theme"));
308 - field.value = chosen;
309 - Node::Field(Box::new(field))
382 + declare! {
383 + /// The theme picker.
384 + ///
385 + /// One member, `themes`, and its second site in the tree: goingson's
386 + /// `settings::theme_field` is the first and is the model. The stored id is
387 + /// placed by a loop over its `Option`, so a picker with nothing stored
388 + /// carries no value rather than an empty one, which is what the assignment
389 + /// this replaces was doing by hand.
390 + ///
391 + /// Nothing here groups and nothing here sorts. `panel::themes` hands the
392 + /// list over in `makeover::order_theme_options`' order, which is the order
393 + /// the renderer reads the groups out of.
394 + shape appearance(settings: &Settings) -> Field;
395 +
396 + field Theme ConfigKey::Theme.as_str() "Theme" {
397 + themes settings.themes.clone();
398 + for chosen in settings.chosen.iter() {
399 + value chosen;
400 + }
401 + writes Action::post("/settings/config/theme");
402 + }
310 403 }
311 404
312 405 /// `makeover`'s variant as the description layer's own.
@@ -335,40 +428,46 @@
335 428 }
336 429 }
337 430
338 - /// A boolean setting as a checkbox that writes when it changes.
339 - ///
340 - /// `Field::writes` rather than a form: this screen has no submit and never
341 - /// should, which is the shape `14612ed8` was recounted for. Thirteen of
342 - /// goingson's nineteen change-sites were standalone controls, and every control
343 - /// here is one.
344 - fn toggle(state: &Panels<'_>, key: ConfigKey, label: &str) -> Result<Node, RouteError> {
345 - let on = get(state, key)?.is_some_and(|value| value == "1" || value == "true");
346 - Ok(Node::Field(Box::new(
347 - Field::new(FieldKind::Checkbox, key.as_str(), label)
348 - .value(if on { "on" } else { "" })
349 - .writes(Action::post(format!("/settings/config/{}", key.as_str()))),
350 - )))
431 + declare! {
432 + /// A boolean setting as a checkbox that writes when it changes.
433 + ///
434 + /// `Field::writes` rather than a form: this screen has no submit and never
435 + /// should, which is the shape `14612ed8` was recounted for. Thirteen of
436 + /// goingson's nineteen change-sites were standalone controls, and every
437 + /// control here is one.
438 + ///
439 + /// The five column checkboxes are this too. A column's described name is
440 + /// the key it writes, so the only thing that told them apart was the
441 + /// reconciliation the write route does, and that was never the
442 + /// description's.
443 + shape toggle(key: &str, label: &str, on: bool) -> Field;
444 +
445 + let value = given on {
446 + true -> "on",
447 + otherwise -> "",
448 + };
449 +
450 + field Checkbox key label {
451 + value value;
452 + writes Action::post("/settings/config/{key}");
453 + }
351 454 }
352 455
353 - /// Row density, as a bounded number rather than a slider.
354 - ///
355 - /// The description says what the value may be and not what it looks like:
356 - /// `Field::min` and `max` are the bounds the shipped slider draws as a track,
357 - /// and a renderer with no slider draws a number that still cannot go out of
358 - /// range. Naming the widget would have been the description choosing a control.
359 - fn row_height(state: &Panels<'_>) -> Result<Field, RouteError> {
360 - let current = get(state, ConfigKey::RowHeight)?.unwrap_or_else(|| "24".to_owned());
361 - Ok(Field::new(
362 - FieldKind::Number,
363 - ConfigKey::RowHeight.as_str(),
364 - "Row height",
365 - )
366 - .value(current)
367 - .hint("Between 20 and 32 pixels.")
368 - .writes(Action::post(format!(
Lines truncated
@@ -81,7 +81,7 @@
81 81 use quasi_router::layout::{FieldKind, Tone};
82 82 use quasi_router::{
83 83 Act, Action, Choice, Field, Locating, Node, Outcome, Request, Response, RouteError, Router,
84 - Row, Slot, Tag,
84 + Row, Tag,
85 85 };
86 86
87 87 use super::Panels;
@@ -289,7 +289,7 @@
289 289
290 290 /// The settings window again, which is what every act here answers with.
291 291 fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
292 - Ok(super::settings::screen(state)?.into())
292 + Ok(super::settings::showing(state)?.into())
293 293 }
294 294
295 295 /// The row an address names.
@@ -301,29 +301,35 @@
301 301 .map_err(|_| RouteError::not_found("that is not a row"))
302 302 }
303 303
304 - /// The whole section, added to the settings body.
305 - pub(super) fn section(body: Slot, state: &Panels<'_>) -> Slot {
306 - let mut body = body
307 - .with(Node::section("Storage"))
308 - .with(Node::text(
304 + /// The whole section, spliced into the settings body.
305 + ///
306 + /// Nodes rather than a `Slot` handed in and handed back, which is the shape a
307 + /// declaration is refused and the shape the settings screen cannot splice now
308 + /// that it is one. The three sections beside this one made the same move.
309 + pub(super) fn section(state: &Panels<'_>) -> Vec<Node> {
310 + let mut body = vec![
311 + Node::section("Storage"),
312 + Node::text(
309 313 "Each library is an independent sample collection with its own database and files. A library can contain multiple vaults (top-level browse buckets).",
310 - ))
311 - .with(libraries(state));
314 + ),
315 + libraries(state),
316 + ];
312 317
313 318 if let Some(at) = state.storage.renaming() {
314 - body = body.with(rename_form(at, state));
319 + body.push(rename_form(at, state));
315 320 }
316 321
317 - body = maintenance(body, state);
322 + body.extend(maintenance(state));
318 323
319 324 if state.storage.loose_files() {
320 - body = body.with(Node::Text {
325 + body.push(Node::Text {
321 326 text: "This library uses loose-files mode. Samples are referenced in place, not duplicated.".to_owned(),
322 327 tone: Tone::Warning,
323 328 });
324 329 }
325 330
326 - add_library(body, state)
331 + body.extend(add_library(state));
332 + body
327 333 }
328 334
329 335 /// The libraries, one row each.
@@ -422,7 +428,7 @@
422 428 /// swapping its label, which is the shipped busy state minus the spinner: a
423 429 /// spinner is a renderer's way of drawing "working", and every host has one or
424 430 /// has something better.
425 - fn maintenance(body: Slot, state: &Panels<'_>) -> Slot {
431 + fn maintenance(state: &Panels<'_>) -> Vec<Node> {
426 432 let scanning = state.storage.scanning();
427 433 let mut scan_act = Act::new(
428 434 if scanning { "Scanning..." } else { "Scan" },
@@ -431,30 +437,29 @@
431 437 if scanning {
432 438 scan_act = scan_act.disabled();
433 439 }
434 - let mut body = body.with(Node::Act(scan_act));
440 + let mut body = vec![Node::Act(scan_act)];
435 441
436 442 if let Some(scan) = state.storage.scan() {
437 - body = body.with(Node::text(format!(
443 + body.push(Node::text(format!(
438 444 "{} samples, {} total, {} database",
439 445 scan.samples,
440 446 bytes(scan.total_bytes),
441 447 bytes(scan.db_bytes),
442 448 )));
443 449 let (age, stale) = scan_age(scan.age_secs);
444 - body = body.with(Node::Text {
450 + body.push(Node::Text {
445 451 text: age,
446 452 tone: if stale { Tone::Warning } else { Tone::Neutral },
447 453 });
448 454 }
449 455
450 - body = body
451 - .with(Node::text(
452 - "Free disk by deleting samples no longer referenced anywhere in the library. Local-only: other synced devices keep their own copies.",
453 - ))
454 - .with(Node::Act(Act::new(
455 - "Cleanup orphans",
456 - Action::post("/settings/storage/orphans"),
457 - )));
456 + body.push(Node::text(
457 + "Free disk by deleting samples no longer referenced anywhere in the library. Local-only: other synced devices keep their own copies.",
458 + ));
459 + body.push(Node::Act(Act::new(
460 + "Cleanup orphans",
461 + Action::post("/settings/storage/orphans"),
462 + )));
458 463
459 464 let backfilling = state.storage.backfilling();
460 465 let mut backfill = Act::new(
@@ -468,11 +473,10 @@
468 473 if backfilling {
469 474 backfill = backfill.disabled();
470 475 }
471 - body = body
472 - .with(Node::text(
473 - "Compute the audio feature data used by tag suggestions for samples that don't have it yet. Runs in the background: keep working; it yields to any analysis you start and resumes later.",
474 - ))
475 - .with(Node::Act(backfill));
476 + body.push(Node::text(
477 + "Compute the audio feature data used by tag suggestions for samples that don't have it yet. Runs in the background: keep working; it yields to any analysis you start and resumes later.",
478 + ));
479 + body.push(Node::Act(backfill));
476 480
477 481 let mut integrity = Act::new(
478 482 "Verify library integrity",
@@ -481,15 +485,15 @@
481 485 if state.storage.busy() {
482 486 integrity = integrity.disabled();
483 487 }
488 + body.push(Node::text(
489 + "Re-hash every stored sample and confirm its bytes still match its content address. Catches silent on-disk corruption. Runs in the background: the result appears in the status line.",
490 + ));
491 + body.push(Node::Act(integrity));
484 492 body
485 - .with(Node::text(
486 - "Re-hash every stored sample and confirm its bytes still match its content address. Catches silent on-disk corruption. Runs in the background: the result appears in the status line.",
487 - ))
488 - .with(Node::Act(integrity))
489 493 }
490 494
491 495 /// The Add Library form: a name, a folder, and how samples are stored.
492 - fn add_library(body: Slot, state: &Panels<'_>) -> Slot {
496 + fn add_library(state: &Panels<'_>) -> Vec<Node> {
493 497 let draft = state.storage.draft();
494 498
495 499 // The error the shipped form left unexplained: a folder chosen and no name,
@@ -502,16 +506,17 @@
502 506 name = name.error("A library needs a name.");
503 507 }
504 508
505 - let mut body = body
506 - .with(Node::section("Add Library"))
507 - .with(Node::Field(Box::new(name)))
508 - .with(Node::Act(Act::new(
509 + let mut body = vec![
510 + Node::section("Add Library"),
511 + Node::Field(Box::new(name)),
512 + Node::Act(Act::new(
509 513 "Choose folder...",
510 514 Action::post("/settings/storage/folder"),
511 - )));
515 + )),
516 + ];
512 517
513 518 if let Some(folder) = &draft.folder {
514 - body = body.with(Node::text(folder.clone()));
519 + body.push(Node::text(folder.clone()));
515 520 }
516 521
517 522 // A radio and not a select, which is what `FieldKind::Radio` was added for
@@ -533,10 +538,10 @@
533 538 })
534 539 .hint(STYLE_HINT)
535 540 .writes(Action::post("/settings/storage/draft/style"));
536 - body = body.with(Node::Field(Box::new(style)));
541 + body.push(Node::Field(Box::new(style)));
537 542
538 543 if draft.reference_in_place {
539 - body = body.with(Node::Text {
544 + body.push(Node::Text {
540 545 text: "Moving or deleting originals will break references. This cannot be undone."
541 546 .to_owned(),
542 547 tone: Tone::Warning,
@@ -555,12 +560,13 @@
555 560 cancel = cancel.disabled();
556 561 }
557 562
558 - body.with(Node::text(
563 + body.push(Node::text(
559 564 "Create New makes an empty library in that folder. Add Existing adopts one that is already there.",
560 - ))
561 - .with(Node::Act(new))
562 - .with(Node::Act(existing))
563 - .with(Node::Act(cancel))
565 + ));
566 + body.push(Node::Act(new));
567 + body.push(Node::Act(existing));
568 + body.push(Node::Act(cancel));
569 + body
564 570 }
565 571
566 572 /// A byte count, as the app spells one everywhere else.
@@ -85,7 +85,7 @@
85 85
86 86 /// The settings window again, which is what both acts answer with.
87 87 fn settled(state: &Panels<'_>) -> Result<Response, RouteError> {
88 - Ok(super::settings::screen(state)?.into())
88 + Ok(super::settings::showing(state)?.into())
89 89 }
90 90
91 91 /// What the section draws, read off the app once.