Skip to main content

max / audiofiles

Describe the main window and its status band Seven shapes: the window, the migration strip, the footer and the four things the footer says. The band's members were four functions that took the Slot and handed it back, which is the refused container shape, so they answer with nodes the region splices instead. Two productions paid for it. `beside Optional` is the tag badges, which are the only droppable fact in the band; `tally` is the two standalone figures, the chosen count and the untagged count. `screen` answers with a Response rather than a Screen, on wave 15's rule: a wrapper in front of a declared screen that hands back a screen is a supplier the conversion itself wrote. Twenty-five callers were writing `.into()` on the way out already. The migration strip is a plain `-> Slot` reached through a loop over the Option rather than the `-> Option<Slot>` the queue expected. R10 would have wanted a predicate and a total reader per number, which is the pair wave 12 retired.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 21:05 UTC
Signed with PGP, not checked
Commit: 14047c398608b26c6b59406cc5725d3ecdc59214
Parent: 5894515
4 files changed, +328 insertions, -236 deletions
M Cargo.lock +44 -34
@@ -459,6 +459,7 @@
459 459 "objc2-foundation 0.3.2",
460 460 "parking_lot",
461 461 "pollster 1.0.1",
462 + "quasi-declare",
462 463 "quasi-immediate",
463 464 "quasi-router",
464 465 "rayon",
@@ -4254,9 +4255,18 @@
4254 4255 source = "registry+https://github.com/rust-lang/crates.io-index"
4255 4256 checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
4256 4257
4258 + [[package]]
4259 + name = "quasi-declare"
4260 + version = "0.1.1"
4261 + dependencies = [
4262 + "proc-macro2",
4263 + "quote",
4264 + "syn 2.0.119",
4265 + ]
4266 +
4257 4267 [[package]]
4258 4268 name = "quasi-immediate"
4259 - version = "0.101.0"
4269 + version = "0.101.1"
4260 4270 dependencies = [
4261 4271 "docengine",
4262 4272 "egui",
@@ -4267,7 +4277,7 @@
4267 4277
4268 4278 [[package]]
4269 4279 name = "quasi-router"
4270 - version = "0.101.0"
4280 + version = "0.101.13"
4271 4281 dependencies = [
4272 4282 "makeover-layout",
4273 4283 ]
@@ -7556,38 +7566,6 @@
7556 7566 "winnow 1.0.4",
7557 7567 ]
7558 7568
7559 - [[patch.unused]]
7560 - name = "quasi-axum"
7561 - version = "0.101.0"
7562 -
7563 - [[patch.unused]]
7564 - name = "quasi-basics"
7565 - version = "0.101.0"
7566 -
7567 - [[patch.unused]]
7568 - name = "quasi-http"
7569 - version = "0.101.0"
7570 -
7571 - [[patch.unused]]
7572 - name = "quasi-notifs"
7573 - version = "0.101.0"
7574 -
7575 - [[patch.unused]]
7576 - name = "quasi-store"
7577 - version = "0.1.0"
7578 -
7579 - [[patch.unused]]
7580 - name = "quasi-tauri"
7581 - version = "0.101.0"
7582 -
7583 - [[patch.unused]]
7584 - name = "quasi-webview"
7585 - version = "0.101.0"
7586 -
7587 - [[patch.unused]]
7588 - name = "quasi-type"
7589 - version = "0.1.3"
7590 -
7591 7569 [[patch.unused]]
7592 7570 name = "kberg"
7593 7571 version = "0.1.0"
@@ -7599,3 +7577,35 @@
7599 7577 [[patch.unused]]
7600 7578 name = "painhours"
7601 7579 version = "0.1.0"
7580 +
7581 + [[patch.unused]]
7582 + name = "quasi-type"
7583 + version = "0.1.3"
7584 +
7585 + [[patch.unused]]
7586 + name = "quasi-axum"
7587 + version = "0.101.1"
7588 +
7589 + [[patch.unused]]
7590 + name = "quasi-basics"
7591 + version = "0.101.1"
7592 +
7593 + [[patch.unused]]
7594 + name = "quasi-http"
7595 + version = "0.101.1"
7596 +
7597 + [[patch.unused]]
7598 + name = "quasi-notifs"
7599 + version = "0.101.2"
7600 +
7601 + [[patch.unused]]
7602 + name = "quasi-store"
7603 + version = "0.1.0"
7604 +
7605 + [[patch.unused]]
7606 + name = "quasi-tauri"
7607 + version = "0.101.1"
7608 +
7609 + [[patch.unused]]
7610 + name = "quasi-webview"
7611 + version = "0.101.1"
@@ -112,7 +112,7 @@
112 112 return Err(RouteError::not_found("no such vault"));
113 113 }
114 114 state.library.open_vault(id);
115 - Ok(super::shell::screen(state).into())
115 + Ok(super::shell::showing(state))
116 116 }
117 117
118 118 /// `POST /vaults/{id}/delete`
@@ -126,41 +126,41 @@
126 126 return Err(RouteError::not_found(LAST_VAULT));
127 127 }
128 128 state.library.delete_vault(id);
129 - Ok(super::shell::screen(state).into())
129 + Ok(super::shell::showing(state))
130 130 }
131 131
132 132 /// `POST /tags/{path}/filter`
133 133 fn toggle_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
134 134 let path = request.captures.require("path")?.to_owned();
135 135 state.library.toggle_tag(&path);
136 - Ok(super::shell::screen(state).into())
136 + Ok(super::shell::showing(state))
137 137 }
138 138
139 139 /// `POST /tags/{path}/remove`
140 140 fn remove_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
141 141 let path = request.captures.require("path")?.to_owned();
142 142 state.library.remove_tag(&path);
143 - Ok(super::shell::screen(state).into())
143 + Ok(super::shell::showing(state))
144 144 }
145 145
146 146 /// `POST /collections/{id}/open`
147 147 fn open_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
148 148 let id = numbered(&request, "no such collection")?;
149 149 state.library.open_collection(id);
150 - Ok(super::shell::screen(state).into())
150 + Ok(super::shell::showing(state))
151 151 }
152 152
153 153 /// `POST /collections/close`
154 154 fn close_collection(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
155 155 state.library.close_collection();
156 - Ok(super::shell::screen(state).into())
156 + Ok(super::shell::showing(state))
157 157 }
158 158
159 159 /// `POST /collections/{id}/delete`
160 160 fn delete_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
161 161 let id = numbered(&request, "no such collection")?;
162 162 state.library.delete_collection(id);
163 - Ok(super::shell::screen(state).into())
163 + Ok(super::shell::showing(state))
164 164 }
165 165
166 166 /// The id a request names.
@@ -105,13 +105,11 @@
105 105 //! pass verifies the root is clean, so stopping defers the remainder rather than
106 106 //! abandoning it.
107 107
108 - use quasi_router::layout::{Priority, Tone};
109 - use quasi_router::{
110 - Act, Action, Figure, Meter, Node, RegionKind, Request, Response, RouteError, Router, Screen,
111 - Slot, Tag,
112 - };
108 + use quasi_declare::declare;
109 + use quasi_router::layout::Tone;
110 + use quasi_router::{Request, Response, RouteError, Router};
113 111
114 - use super::{Panel, Panels, Playing, Saying};
112 + use super::{Panel, Panels, Saying};
115 113
116 114 /// The band under the list.
117 115 const FOOT: &str = "shell-foot";
@@ -130,19 +128,19 @@
130 128
131 129 /// `GET /`
132 130 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
133 - Ok(screen(state).into())
131 + Ok(showing(state))
134 132 }
135 133
136 134 /// `POST /playback/stop`
137 135 fn stop(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
138 136 state.shell.stop();
139 - Ok(screen(state).into())
137 + Ok(showing(state))
140 138 }
141 139
142 140 /// `POST /hint/dismiss`
143 141 fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
144 142 state.shell.dismiss_hint();
145 - Ok(screen(state).into())
143 + Ok(showing(state))
146 144 }
147 145
148 146 /// `POST /storage/pause`
@@ -154,61 +152,175 @@
154 152 return Err(RouteError::not_found("no migration is running"));
155 153 }
156 154 state.shell.pause_migration();
157 - Ok(screen(state).into())
155 + Ok(showing(state))
158 156 }
159 157
160 - /// The window: what you can filter by, what is in it, and what it is doing.
158 + /// The window, read and then described.
161 159 ///
162 - /// `pub(super)` because the sidebar's routes answer it. Every control in
163 - /// [`library`](super::library) changes what the *list* shows — choosing a vault,
164 - /// applying a tag filter, opening a collection — so the answer is the window
165 - /// rather than the corner of it that was pressed.
166 - pub(super) fn screen(state: &Panels<'_>) -> Screen {
167 - let screen = Screen::sidebar_content("audiofiles").with(super::toolbar::body(state));
168 -
169 - // The left column is one of two things and sometimes neither, which is the
170 - // shipped window's own arrangement rather than a new one: the filter panel
171 - // takes the sidebar's place while it is open, and the sidebar itself is a
172 - // toggle. Said here because the toggles are already described -- `Panel`
173 - // carries both facts and the toolbar draws both chips -- so a host reading
174 - // `sidebar_visible` a second time to decide the layout would be the second
175 - // answer this layer exists to remove.
176 - //
177 - // The filter panel is a screen of its own (`/filters`) and stays one. It is
178 - // the host that puts it where the sidebar was, the same way it puts the
179 - // detail pane on the right: both are regions the window arranges, and
180 - // neither is a region of this screen.
181 - let showing = state.bar.showing();
182 - let screen = if showing.contains(&Panel::Filters) || !showing.contains(&Panel::Sidebar) {
183 - screen
184 - } else {
185 - screen.with(super::library::body(state))
186 - };
187 - let screen = screen.with(super::files::body(state));
188 -
189 - // Above the footer, which is where the shipped strip declares itself, and
190 - // only while there is something to say. See the header.
191 - match migrating(state) {
192 - Some(strip) => screen.with(strip).with(foot(state)),
193 - None => screen.with(foot(state)),
194 - }
160 + /// `pub(super)` because the sidebar's and the toolbar's routes answer it. Every
161 + /// control in [`library`](super::library) changes what the *list* shows --
162 + /// choosing a vault, applying a tag filter, opening a collection -- so the
163 + /// answer is the window rather than the corner of it that was pressed.
164 + ///
165 + /// A [`Response`] rather than a [`Screen`], which is the rule wave 15 settled
166 + /// on `settings::showing`: a wrapper in front of a declared screen that hands
167 + /// back a screen is a supplier the conversion itself wrote, and the census
168 + /// counts it. Every caller was writing `.into()` on the way out anyway.
169 + pub(super) fn showing(state: &Panels<'_>) -> Response {
170 + Response::from(screen(state, &read(state)))
195 171 }
196 172
197 - /// Blobs being moved into their shards, while any are.
198 - fn migrating(state: &Panels<'_>) -> Option<Slot> {
199 - let running = state.shell.migrating()?;
200 - Some(
201 - Slot::new(STRIP, RegionKind::Band)
202 - .with(Node::text("Optimising storage layout"))
203 - .with(Node::Meter(
204 - Meter::new(clamp(running.done), clamp(running.total)).label("files"),
205 - ))
206 - .with(Node::Act(
207 - Act::new("Pause", Action::post("/storage/pause")).confirm(
208 - "Stop for now. The remainder resumes the next time this vault opens. Pause?",
209 - ),
210 - )),
211 - )
173 + /// What the window and its band say, read off the app.
174 + struct Window {
175 + /// Whether the library sidebar is drawn beside the list.
176 + ///
177 + /// The left column is one of two things and sometimes neither, which is the
178 + /// shipped window's own arrangement rather than a new one: the filter panel
179 + /// takes the sidebar's place while it is open, and the sidebar itself is a
180 + /// toggle. Read once here because the toggles are already described --
181 + /// `Panel` carries both facts and the toolbar draws both chips -- so a host
182 + /// reading `sidebar_visible` a second time to decide the layout would be
183 + /// the second answer this layer exists to remove.
184 + ///
185 + /// The filter panel is a screen of its own (`/filters`) and stays one. It is
186 + /// the host that puts it where the sidebar was, the same way it puts the
187 + /// detail pane on the right: both are regions the window arranges, and
188 + /// neither is a region of this screen.
189 + sidebar: bool,
190 + /// The blob migration, while one is running.
191 + moving: Option<Moving>,
192 + /// What is playing, while anything is.
193 + playing: Option<Sound>,
194 + /// How many rows are chosen, while more than one is.
195 + chosen: Option<String>,
196 + /// How far analysis has got, while there is anything to have got through.
197 + analysed: Option<Coverage>,
198 + /// The samples that cannot find their files, while any cannot.
199 + lost: Option<Lost>,
200 + /// What the app is saying, while it is saying anything.
201 + said: Option<Said>,
202 + /// Whether the first-launch hint is still up.
203 + ///
204 + /// False while the app is saying something, which is the shipped footer's
205 + /// own order: a status line and a hint never share the band.
206 + hinting: bool,
207 + /// What preview plays through, as the line the band draws.
208 + preview: String,
209 + /// Whether that line is a warning, which it is when there is no device.
210 + ///
211 + /// Said rather than omitted, because a silent preview with no device is the
212 + /// case this line exists to make diagnosable without opening Settings.
213 + tone: Tone,
214 + /// The focused sample's tags.
215 + tags: Vec<String>,
216 + }
217 +
218 + /// Blobs on their way into their shards.
219 + struct Moving {
220 + /// How many have moved, as the meter carries a count.
221 + done: u32,
222 + /// How many there are.
223 + total: u32,
224 + }
225 +
226 + /// What is playing, and how far through.
227 + struct Sound {
228 + /// What it is called.
229 + name: String,
230 + /// How far in, in seconds.
231 + position: u32,
232 + /// How long it runs, in seconds.
233 + total: u32,
234 + /// Both of those as the transport writes them.
235 + clock: String,
236 + }
237 +
238 + /// How much of what is on screen has been analysed.
239 + struct Coverage {
240 + /// How many of them have been analysed.
241 + analysed: u32,
242 + /// How many are on screen.
243 + samples: u32,
244 + /// What that proportion means: green once it is all of them.
245 + tone: Tone,
246 + /// How many carry no tags, once analysis has produced anything.
247 + ///
248 + /// Absent until it has, which is the shipped footer's rule: before the
249 + /// first result every sample is untagged and the count says nothing.
250 + untagged: Option<String>,
251 + }
252 +
253 + /// The samples that have lost their files.
254 + struct Lost {
255 + /// The sentence, pluralised for one.
256 + said: String,
257 + }
258 +
259 + /// Whatever the app is telling the user.
260 + struct Said {
261 + /// What it says.
262 + text: String,
263 + /// What kind of thing it is.
264 + tone: Tone,
265 + }
266 +
267 + /// What the window draws, read off the app.
268 + fn read(state: &Panels<'_>) -> Window {
269 + let showing = state.bar.showing();
270 + let status = state.shell.status();
271 + let seen = state.shell.analysed();
272 + let chosen = state.shell.chosen();
273 + let missing = state.integrity.missing();
274 + let device = state.shell.device();
275 + Window {
276 + sidebar: !showing.contains(&Panel::Filters) && showing.contains(&Panel::Sidebar),
277 + moving: state.shell.migrating().map(|running| Moving {
278 + done: clamp(running.done),
279 + total: clamp(running.total),
280 + }),
281 + playing: state.shell.playing().map(|playing| Sound {
282 + name: playing.name,
283 + position: playing.position,
284 + total: playing.total,
285 + clock: format!("{}/{}", clock(playing.position), clock(playing.total)),
286 + }),
287 + chosen: (chosen > 1).then(|| chosen.to_string()),
288 + analysed: (seen.samples > 0).then(|| Coverage {
289 + analysed: seen.analysed,
290 + samples: seen.samples,
291 + tone: if seen.analysed == seen.samples {
292 + Tone::Success
293 + } else {
294 + Tone::Neutral
295 + },
296 + untagged: (seen.analysed > 0 && seen.untagged > 0).then(|| seen.untagged.to_string()),
297 + }),
298 + lost: (missing > 0).then(|| Lost {
299 + said: format!(
300 + "{missing} sample{} cannot find {} file.",
301 + if missing == 1 { "" } else { "s" },
302 + if missing == 1 { "its" } else { "their" },
303 + ),
304 + }),
305 + said: status.map(|(text, saying)| Said {
306 + text,
307 + tone: match saying {
308 + Saying::Failed => Tone::Danger,
309 + Saying::Ordinary => Tone::Neutral,
310 + },
311 + }),
312 + hinting: state.shell.hinting() && state.shell.status().is_none(),
313 + preview: match &device {
314 + Some(device) => format!("Preview: {device}"),
315 + None => "Preview: no device".to_owned(),
316 + },
317 + tone: if device.is_some() {
318 + Tone::Neutral
319 + } else {
320 + Tone::Warning
321 + },
322 + tags: state.shell.tags(),
323 + }
212 324 }
213 325
214 326 /// A count as the meter carries one.
@@ -216,152 +328,153 @@
216 328 u32::try_from(count).unwrap_or(u32::MAX)
217 329 }
218 330
219 - /// The status band.
220 - fn foot(state: &Panels<'_>) -> Slot {
221 - let mut band = Slot::new(FOOT, RegionKind::Band);
331 + declare! {
332 + /// The window: what you can filter by, what is in it, and what it is doing.
333 + ///
334 + /// Three of its five regions are other modules' and arrive already drawn.
335 + /// `files::body` is the one worth naming: the standalone `/files` window
336 + /// wraps the same shape in a screen of its own, so **two callers, one
337 + /// description**, and the list never had to learn that it might not be
338 + /// alone.
339 + ///
340 + /// The strip is placed above the footer, which is where the shipped one
341 + /// declares itself, and only while there is something to say. See the
342 + /// header.
343 + shape screen(state: &Panels<'_>, window: &Window) -> Screen;
222 344
223 - if let Some(playing) = state.shell.playing() {
224 - band = transport(band, &playing);
345 + screen sidebar_content "audiofiles" {
346 + include super::toolbar::body(state);
347 + include super::library::body(state) when window.sidebar;
348 + include super::files::body(state);
349 +
350 + for moving in window.moving.iter() {
351 + include migrating(moving);
352 + }
353 + include foot(window);
225 354 }
226 -
227 - let chosen = state.shell.chosen();
228 - if chosen > 1 {
229 - band = band.with(Node::Figure(Figure::new(chosen.to_string(), "selected")));
230 - }
231 -
232 - band = coverage(band, state);
233 - band = missing(band, state);
234 - band = saying(band, state);
235 -
236 - if let Some(device) = state.shell.device() {
237 - band = band.with(Node::text(format!("Preview: {device}")));
238 - } else {
239 - // Said rather than omitted, because a silent preview with no device is
240 - // the case this line exists to make diagnosable without opening
241 - // Settings.
242 - band = band.with(Node::Text {
243 - text: "Preview: no device".to_owned(),
244 - tone: Tone::Warning,
245 - });
246 - }
247 -
248 - // The focused sample's tags, inert. The shipped footer renders these as
249 - // muted text rather than chips on purpose -- "these are inert
250 - // (informational only), so the affordance contract should not invite a
251 - // click" -- which is exactly what a badge is and a chip is not.
252 - //
253 - // Optional, and they are the only thing in this band that is. A badge here
254 - // repeats a fact the detail panel states in full, so a window with no room
255 - // for everything loses the repetition first. Everything else in the footer
256 - // is a fact stated nowhere else on the screen.
257 - for tag in state.shell.tags() {
258 - band = band.with_ranked(Node::Token(Tag::badge(tag)), Priority::Optional);
259 - }
260 -
261 - band
262 355 }
263 356
264 - /// What is playing, and how far through.
265 - fn transport(band: Slot, playing: &Playing) -> Slot {
266 - band.with(Node::text(format!("Playing: {}", playing.name)))
267 - .with(Node::Meter(
268 - Meter::new(playing.position, playing.total).label("seconds"),
269 - ))
270 - .with(Node::text(format!(
271 - "{}/{}",
272 - clock(playing.position),
273 - clock(playing.total)
274 - )))
275 - .with(Node::Act(
276 - Act::new("Stop", Action::post("/playback/stop")).key("space"),
277 - ))
357 + declare! {
358 + /// Blobs being moved into their shards.
359 + ///
360 + /// Pause is honest here in a way a cancel usually is not, and the
361 + /// description says so on the control: the sweep is resumable and records
362 + /// nothing until a pass verifies the root is clean, so stopping defers the
363 + /// remainder rather than abandoning it.
364 + shape migrating(moving: &Moving) -> Slot;
365 +
366 + region STRIP as Band {
367 + text "Optimising storage layout";
368 + proportion moving.done moving.total {
369 + label "files";
370 + }
371 + act "Pause" to post "/storage/pause" {
372 + confirm "Stop for now. The remainder resumes the next time this \
373 + vault opens. Pause?";
374 + }
375 + }
278 376 }
279 377
280 - /// How much of what is on screen has been analysed.
281 - ///
282 - /// A [`Meter`] and a [`Figure`], where the shipped footer has two coloured
283 - /// strings. The proportion is the meter; the untagged count is a separate fact
284 - /// about the same set rather than a second proportion of it, which is why it is
285 - /// a figure and not a second bar.
286 - fn coverage(band: Slot, state: &Panels<'_>) -> Slot {
287 - let seen = state.shell.analysed();
288 - if seen.samples == 0 {
289 - return band;
290 - }
378 + declare! {
379 + /// The status band.
380 + shape foot(window: &Window) -> Slot;
291 381
292 - let done = seen.analysed == seen.samples;
293 - let mut band = band.with(Node::Meter(
294 - Meter::new(seen.analysed, seen.samples)
295 - .label("analysed")
296 - .tone(if done { Tone::Success } else { Tone::Neutral }),
297 - ));
382 + region FOOT as Band {
383 + for sound in window.playing.iter() {
384 + extend transport(sound);
385 + }
386 + for chosen in window.chosen.iter() {
387 + tally chosen "selected";
388 + }
389 + for seen in window.analysed.iter() {
390 + extend coverage(seen);
391 + }
392 + for lost in window.lost.iter() {
393 + extend missing(lost);
394 + }
395 + extend saying(window);
396 + toned &window.preview window.tone;
298 397
299 - // Suppressed until analysis has produced something, which is the shipped
300 - // footer's rule: before the first result every sample is untagged and the
301 - // count says nothing.
302 - if seen.analysed > 0 && seen.untagged > 0 {
303 - band = band.with(Node::Figure(Figure::new(
304 - seen.untagged.to_string(),
305 - "untagged",
306 - )));
398 + // The focused sample's tags, inert. The shipped footer renders these as
399 + // muted text rather than chips on purpose -- "these are inert
400 + // (informational only), so the affordance contract should not invite a
401 + // click" -- which is exactly what a badge is and a chip is not.
402 + //
403 + // Optional, and they are the only thing in this band that is. A badge
404 + // here repeats a fact the detail panel states in full, so a window with
405 + // no room for everything loses the repetition first. Everything else in
406 + // the footer is a fact stated nowhere else on the screen.
407 + for tag in window.tags.iter() {
408 + beside Optional badge tag;
409 + }
307 410 }
308 - band
309 411 }
310 412
311 - /// The samples that have lost their files, and the way in to what can be done.
312 - ///
313 - /// **The band says it because no description can raise the overlay that says
314 - /// it.** The shipped app puts the warning up over whatever the user was doing,
315 - /// as soon as a vault load finds anything missing, and nothing a route answers
316 - /// can do that: `Outcome::Over` exists because something was pressed. See
317 - /// [`integrity`](super::integrity)'s header and the finding it shares with the
318 - /// import preflight. So the fact is stated where the app states its other facts
319 - /// and the modal is one act away.
320 - fn missing(band: Slot, state: &Panels<'_>) -> Slot {
321 - let missing = state.integrity.missing();
322 - if missing == 0 {
323 - return band;
413 + declare! {
414 + /// What is playing, and how far through.
415 + shape transport(sound: &Sound) -> Vec<Node>;
416 +
417 + text "Playing: {sound.name}";
418 + proportion sound.position sound.total {
419 + label "seconds";
420 + }
421 + text &sound.clock;
422 + act "Stop" to post "/playback/stop" {
423 + key "space";
324 424 }
325 - band.with(Node::banner(
326 - Tone::Warning,
327 - format!(
328 - "{missing} sample{} cannot find {} file.",
329 - if missing == 1 { "" } else { "s" },
330 - if missing == 1 { "its" } else { "their" },
331 - ),
332 - ))
333 - .with(Node::Act(Act::new(
334 - "What is missing",
335 - Action::get("/library/loose-files"),
336 - )))
337 425 }
338 426
339 - /// Whatever the app is telling the user.
340 - ///
341 - /// Tone rather than a timer. The shipped footer decides the colour from
342 - /// `is_error_status`, a substring match over the message it is about to draw,
343 - /// and then decides how long to keep it up from two constants and an elapsed
344 - /// `Instant`. The first half is a fact the app knows when it writes the message
345 - /// and is what `Tone` carries; the second half is renderer policy and is gone.
346 - fn saying(band: Slot, state: &Panels<'_>) -> Slot {
347 - if let Some((text, saying)) = state.shell.status() {
348 - return band.with(Node::toast(
349 - match saying {
350 - Saying::Failed => Tone::Danger,
351 - Saying::Ordinary => Tone::Neutral,
352 - },
353 - text,
354 - ));
427 + declare! {
428 + /// How much of what is on screen has been analysed.
429 + ///
430 + /// A meter and a figure, where the shipped footer has two coloured strings.
431 + /// The proportion is the meter; the untagged count is a separate fact about
432 + /// the same set rather than a second proportion of it, which is why it is a
433 + /// figure and not a second bar.
434 + shape coverage(seen: &Coverage) -> Vec<Node>;
435 +
436 + proportion seen.analysed seen.samples {
437 + label "analysed";
438 + tone seen.tone;
355 439 }
356 - if state.shell.hinting() {
357 - return band
Lines truncated
@@ -129,7 +129,7 @@
129 129 fn search(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
130 130 let query = request.payload.get(QUERY).unwrap_or_default();
131 131 state.bar.search(query);
132 - Ok(super::shell::screen(state).into())
132 + Ok(super::shell::showing(state))
133 133 }
134 134
135 135 /// `POST /search/scope`
@@ -144,7 +144,7 @@
144 144 "folder" => state.bar.set_scope(false),
145 145 _ => return Err(RouteError::not_found("no such scope")),
146 146 }
147 - Ok(super::shell::screen(state).into())
147 + Ok(super::shell::showing(state))
148 148 }
149 149
150 150 /// `GET /search/save`
@@ -185,7 +185,7 @@
185 185 return Err(RouteError::not_found("a collection needs a name"));
186 186 }
187 187 state.bar.save_collection(name);
188 - Ok(super::shell::screen(state).into())
188 + Ok(super::shell::showing(state))
189 189 }
190 190
191 191 /// `POST /undo`
@@ -202,7 +202,7 @@
202 202 return Err(RouteError::not_found("there is nothing to undo"));
203 203 }
204 204 state.bar.undo();
205 - Ok(super::shell::screen(state).into())
205 + Ok(super::shell::showing(state))
206 206 }
207 207
208 208 /// `POST /panels/{panel}`
@@ -210,13 +210,13 @@
210 210 let named = request.captures.require("panel")?;
211 211 let panel = Panel::from_key(named).ok_or_else(|| RouteError::not_found("no such panel"))?;
212 212 state.bar.toggle(panel);
213 - Ok(super::shell::screen(state).into())
213 + Ok(super::shell::showing(state))
214 214 }
215 215
216 216 /// `POST /here/root`
217 217 fn root(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
218 218 state.bar.go_root();
219 - Ok(super::shell::screen(state).into())
219 + Ok(super::shell::showing(state))
220 220 }
221 221
222 222 /// `POST /here/{id}/{depth}`
@@ -237,13 +237,13 @@
237 237 .parse()
238 238 .map_err(|_| RouteError::not_found("no such place in the trail"))?;
239 239 state.bar.go_to(id, depth);
240 - Ok(super::shell::screen(state).into())
240 + Ok(super::shell::showing(state))
241 241 }
242 242
243 243 /// `POST /here/leave`
244 244 fn leave(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
245 245 state.bar.leave();
246 - Ok(super::shell::screen(state).into())
246 + Ok(super::shell::showing(state))
247 247 }
248 248
249 249 /// `POST /here/similar/{at}`
@@ -259,19 +259,19 @@
259 259 .parse()
260 260 .map_err(|_| RouteError::not_found("no such place in the trail"))?;
261 261 state.bar.walk_back(at);
262 - Ok(super::shell::screen(state).into())
262 + Ok(super::shell::showing(state))
263 263 }
264 264
265 265 /// `POST /basket/show`
266 266 fn show_basket(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
267 267 state.bar.show_basket();
268 - Ok(super::shell::screen(state).into())
268 + Ok(super::shell::showing(state))
269 269 }
270 270
271 271 /// `POST /basket/clear`
272 272 fn clear_basket(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
273 273 state.bar.empty_basket();
274 - Ok(super::shell::screen(state).into())
274 + Ok(super::shell::showing(state))
275 275 }
276 276
277 277 /// `POST /basket/{at}/remove`
@@ -285,7 +285,7 @@
285 285 .parse()
286 286 .map_err(|_| RouteError::not_found("no such anchor in the basket"))?;
287 287 state.bar.drop_from_basket(at);
288 - Ok(super::shell::screen(state).into())
288 + Ok(super::shell::showing(state))
289 289 }
290 290
291 291 /// The toolbar, as a region something else holds.