Skip to main content

max / audiofiles

14.6 KB · 356 lines History Blame Raw
1 //! The main window, described: the file list with a status band under it.
2 //!
3 //! The eighth audiofiles port, and the first with **more than one region**.
4 //! Every described screen before this is a single `RegionKind::Pane`, which
5 //! meant `Screen::sidebar_content`'s arrangement had nothing to arrange and
6 //! `RegionKind::Band` and `Sidebar` had never been written by this app at all.
7 //! A description layer whose every screen is one box is not yet describing a
8 //! layout.
9 //!
10 //! # What composing a region turns out to cost: nothing
11 //!
12 //! `files::body` was a private function building the list's slot and is now
13 //! public, unchanged. The standalone `/files` window wraps it in a screen; this
14 //! screen puts it beside a band. **Two callers, one description**, and the list
15 //! did not have to learn that it might not be alone. That is the property a
16 //! region is for, and it is worth stating because the alternative — a
17 //! `files::screen` and a `files::embedded_screen` — is what every app that lacks
18 //! regions ends up writing.
19 //!
20 //! # The footer, and the three things it stops storing
21 //!
22 //! `ui::footer::draw_footer` is 373 lines and carries a surprising amount of
23 //! state that is not the app's:
24 //!
25 //! - **A fade timer.** The status line fades after five seconds and hides after
26 //! thirty, tracked with `status_set_at`, two `Duration` constants, an
27 //! `egui::Id` round-trip through `ctx.data` to notice the text changed, and a
28 //! `request_repaint_after` to land the transition. All of it is renderer
29 //! policy, which `Message::undo` settled in the vocabulary already: "How long
30 //! an undo stays offered is renderer policy". The description says the status
31 //! and its tone; how long a host keeps it up is the host's.
32 //! - **A width breakpoint.** `let narrow = ctx.content_rect().width() < 1000.0`
33 //! decides whether three items sit on the first row or a second one. That is a
34 //! layout decision made from a pixel measurement inside a drawing function,
35 //! and it is the clearest case in this app of the thing the description layer
36 //! exists to take away.
37 //!
38 //! Worth being exact about what replaced it, because the obvious answer is
39 //! the wrong one. `Ranked` (quasi 0.18.0) lets a placement say what it is
40 //! worth when room runs out, and the toolbar's `< 900` collapse became three
41 //! priorities. This band's `< 1000` did not, and should not: it *reflows*,
42 //! moving three items to a second row and dropping nothing. Ranking them
43 //! would delete facts the shipped footer keeps. Wrapping a row that does not
44 //! fit is the host's arithmetic in its own units, which is what the deletion
45 //! above already claimed, so nothing is owed here.
46 //!
47 //! The one member that is genuinely droppable is the tag badges, and they
48 //! say so.
49 //! - **A hand-painted progress bar.** Twenty lines of `rect_filled`, a bevel and
50 //! a click-to-seek hit test. The bar is [`Meter`]; the seek is not (see
51 //! below).
52 //!
53 //! # THE FINDING: `Meter` refuses the second of the two proportions here
54 //!
55 //! This band has two, and the vocabulary treats them differently:
56 //!
57 //! - **Analysis coverage** — "142/200 analyzed" — is a proportion of a set and
58 //! exactly what [`Meter`] was added for. No argument.
59 //! - **Playback position** is refused by `Meter`'s own header: "This is a
60 //! proportion of a set and not the progress of an operation. A running timer
61 //! or a fetch is imperative and live, and a screen is described once per
62 //! answer."
63 //!
64 //! It is described as a `Meter` here anyway, and the reason is the finding the
65 //! export port already filed (`quasi:docs:meter-refuses-progress`): the premise
66 //! moved when `Runtime::reload` landed. A screen is no longer described once; it
67 //! is described whenever the host asks, and this host asks every frame for
68 //! exactly the reason the export flow does. Playback is the **second consumer**
69 //! and the sharper one — the export progress at least moves when a worker
70 //! finishes a file, and this moves at the sample clock with nobody touching
71 //! anything.
72 //!
73 //! The type is right and the paragraph is wrong. Nothing here changes the type.
74 //!
75 //! # What is deliberately not described
76 //!
77 //! - **Click-to-seek on the progress bar.** The waveform's rule: a click that
78 //! maps a pixel to a frame and writes into a mutex an audio thread is filling
79 //! is a host fact, not a fact about a sample.
80 //! - **The toolbar and the sidebar.** Two more regions and the reason this
81 //! module is named for the shell rather than the footer: `toolbar.rs` (706)
82 //! and `sidebar.rs` (722) are the other two bands of the same window, and each
83 //! is its own pass. The screen below has the two regions that exist.
84 //!
85 //! # The migration strip, and why it is here rather than anywhere else
86 //!
87 //! `ui/layout_strip.rs` is 62 lines and its header argues, correctly, that
88 //! moving blobs into hash-prefix shards deserves a strip rather than a modal or
89 //! a mode: the migration auto-starts at vault open, the library stays usable
90 //! while it runs because reads resolve both layouts, and seizing the window
91 //! would be the wrong trade. That argument is about *where the fact goes*, which
92 //! makes it this module's: it is a band of the main window, declared after the
93 //! footer so it stacks above it.
94 //!
95 //! So there is no `/storage` address and no fourteenth capability for it. It is
96 //! [`Shell::migrating`](super::Shell::migrating), a band that is there while the
97 //! fact is true, and a described screen answering that band every time it is
98 //! asked is what makes "appears while a job runs" sayable at all. Compare the
99 //! loose-files warning three paragraphs down: the shipped app raises *that* as
100 //! an overlay, which is the thing no description can do, and this one was
101 //! already a band.
102 //!
103 //! Pause is honest here in a way a cancel usually is not, and the description
104 //! says so on the control: the sweep is resumable and records nothing until a
105 //! pass verifies the root is clean, so stopping defers the remainder rather than
106 //! abandoning it.
107
108 use quasi_router::layout::{Notice, Priority, Tone};
109 use quasi_router::{
110 Act, Action, Figure, Meter, Node, RegionKind, Request, Response, RouteError, Router, Screen,
111 Slot, Tag,
112 };
113
114 use super::{Panels, Playing, Saying};
115
116 /// The band under the list.
117 const FOOT: &str = "shell-foot";
118
119 /// The band above it, while blobs are being moved.
120 const STRIP: &str = "shell-migration";
121
122 /// Register the main screen's routes.
123 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
124 router
125 .get("/", index)
126 .post("/playback/stop", stop)
127 .post("/hint/dismiss", dismiss)
128 .post("/storage/pause", pause)
129 }
130
131 /// `GET /`
132 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
133 Ok(screen(state).into())
134 }
135
136 /// `POST /playback/stop`
137 fn stop(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
138 state.shell.stop();
139 Ok(screen(state).into())
140 }
141
142 /// `POST /hint/dismiss`
143 fn dismiss(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
144 state.shell.dismiss_hint();
145 Ok(screen(state).into())
146 }
147
148 /// `POST /storage/pause`
149 ///
150 /// Refused when nothing is migrating, for the sweep's reason: the address is
151 /// reachable by typing and pausing nothing is not a thing that happened.
152 fn pause(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
153 if state.shell.migrating().is_none() {
154 return Err(RouteError::not_found("no migration is running"));
155 }
156 state.shell.pause_migration();
157 Ok(screen(state).into())
158 }
159
160 /// The window: what you can filter by, what is in it, and what it is doing.
161 ///
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")
168 .with(super::toolbar::body(state))
169 .with(super::library::body(state))
170 .with(super::files::body(state));
171
172 // Above the footer, which is where the shipped strip declares itself, and
173 // only while there is something to say. See the header.
174 match migrating(state) {
175 Some(strip) => screen.with(strip).with(foot(state)),
176 None => screen.with(foot(state)),
177 }
178 }
179
180 /// Blobs being moved into their shards, while any are.
181 fn migrating(state: &Panels<'_>) -> Option<Slot> {
182 let running = state.shell.migrating()?;
183 Some(
184 Slot::new(STRIP, RegionKind::Band)
185 .with(Node::text("Optimising storage layout"))
186 .with(Node::Meter(
187 Meter::new(clamp(running.done), clamp(running.total)).label("files"),
188 ))
189 .with(Node::Act(
190 Act::new("Pause", Action::post("/storage/pause")).confirm(
191 "Stop for now. The remainder resumes the next time this vault opens. Pause?",
192 ),
193 )),
194 )
195 }
196
197 /// A count as the meter carries one.
198 fn clamp(count: usize) -> u32 {
199 u32::try_from(count).unwrap_or(u32::MAX)
200 }
201
202 /// The status band.
203 fn foot(state: &Panels<'_>) -> Slot {
204 let mut band = Slot::new(FOOT, RegionKind::Band);
205
206 if let Some(playing) = state.shell.playing() {
207 band = transport(band, &playing);
208 }
209
210 let chosen = state.shell.chosen();
211 if chosen > 1 {
212 band = band.with(Node::Figure(Figure::new(chosen.to_string(), "selected")));
213 }
214
215 band = coverage(band, state);
216 band = missing(band, state);
217 band = saying(band, state);
218
219 if let Some(device) = state.shell.device() {
220 band = band.with(Node::text(format!("Preview: {device}")));
221 } else {
222 // Said rather than omitted, because a silent preview with no device is
223 // the case this line exists to make diagnosable without opening
224 // Settings.
225 band = band.with(Node::Text {
226 text: "Preview: no device".to_owned(),
227 tone: Tone::Warning,
228 });
229 }
230
231 // The focused sample's tags, inert. The shipped footer renders these as
232 // muted text rather than chips on purpose -- "these are inert
233 // (informational only), so the affordance contract should not invite a
234 // click" -- which is exactly what a badge is and a chip is not.
235 //
236 // Optional, and they are the only thing in this band that is. A badge here
237 // repeats a fact the detail panel states in full, so a window with no room
238 // for everything loses the repetition first. Everything else in the footer
239 // is a fact stated nowhere else on the screen.
240 for tag in state.shell.tags() {
241 band = band.with_ranked(Node::Token(Tag::badge(tag)), Priority::Optional);
242 }
243
244 band
245 }
246
247 /// What is playing, and how far through.
248 fn transport(band: Slot, playing: &Playing) -> Slot {
249 band.with(Node::text(format!("Playing: {}", playing.name)))
250 .with(Node::Meter(
251 Meter::new(playing.position, playing.total).label("seconds"),
252 ))
253 .with(Node::text(format!(
254 "{}/{}",
255 clock(playing.position),
256 clock(playing.total)
257 )))
258 .with(Node::Act(
259 Act::new("Stop", Action::post("/playback/stop")).key("space"),
260 ))
261 }
262
263 /// How much of what is on screen has been analysed.
264 ///
265 /// A [`Meter`] and a [`Figure`], where the shipped footer has two coloured
266 /// strings. The proportion is the meter; the untagged count is a separate fact
267 /// about the same set rather than a second proportion of it, which is why it is
268 /// a figure and not a second bar.
269 fn coverage(band: Slot, state: &Panels<'_>) -> Slot {
270 let seen = state.shell.analysed();
271 if seen.samples == 0 {
272 return band;
273 }
274
275 let done = seen.analysed == seen.samples;
276 let mut band = band.with(Node::Meter(
277 Meter::new(seen.analysed, seen.samples)
278 .label("analysed")
279 .tone(if done { Tone::Success } else { Tone::Neutral }),
280 ));
281
282 // Suppressed until analysis has produced something, which is the shipped
283 // footer's rule: before the first result every sample is untagged and the
284 // count says nothing.
285 if seen.analysed > 0 && seen.untagged > 0 {
286 band = band.with(Node::Figure(Figure::new(
287 seen.untagged.to_string(),
288 "untagged",
289 )));
290 }
291 band
292 }
293
294 /// The samples that have lost their files, and the way in to what can be done.
295 ///
296 /// **The band says it because no description can raise the overlay that says
297 /// it.** The shipped app puts the warning up over whatever the user was doing,
298 /// as soon as a vault load finds anything missing, and nothing a route answers
299 /// can do that: `Outcome::Over` exists because something was pressed. See
300 /// [`integrity`](super::integrity)'s header and the finding it shares with the
301 /// import preflight. So the fact is stated where the app states its other facts
302 /// and the modal is one act away.
303 fn missing(band: Slot, state: &Panels<'_>) -> Slot {
304 let missing = state.integrity.missing();
305 if missing == 0 {
306 return band;
307 }
308 band.with(Node::Notice {
309 kind: Notice::Banner,
310 tone: Tone::Warning,
311 text: format!(
312 "{missing} sample{} cannot find {} file.",
313 if missing == 1 { "" } else { "s" },
314 if missing == 1 { "its" } else { "their" },
315 ),
316 })
317 .with(Node::Act(Act::new(
318 "What is missing",
319 Action::get("/library/loose-files"),
320 )))
321 }
322
323 /// Whatever the app is telling the user.
324 ///
325 /// Tone rather than a timer. The shipped footer decides the colour from
326 /// `is_error_status`, a substring match over the message it is about to draw,
327 /// and then decides how long to keep it up from two constants and an elapsed
328 /// `Instant`. The first half is a fact the app knows when it writes the message
329 /// and is what `Tone` carries; the second half is renderer policy and is gone.
330 fn saying(band: Slot, state: &Panels<'_>) -> Slot {
331 if let Some((text, saying)) = state.shell.status() {
332 return band.with(Node::Notice {
333 kind: Notice::Toast,
334 tone: match saying {
335 Saying::Failed => Tone::Danger,
336 Saying::Ordinary => Tone::Neutral,
337 },
338 text,
339 });
340 }
341 if state.shell.hinting() {
342 return band
343 .with(Node::text("Right-click for options. F1 for shortcuts."))
344 .with(Node::Act(Act::new(
345 "Dismiss",
346 Action::post("/hint/dismiss"),
347 )));
348 }
349 band
350 }
351
352 /// Seconds as the transport writes them.
353 fn clock(seconds: u32) -> String {
354 format!("{}:{:02}", seconds / 60, seconds % 60)
355 }
356