Skip to main content

max / audiofiles

Describe the detail panel Ten shapes: the screen, the one-sample body and its four sections, the several-sample body and its two, and the row a tag covers. Eight of the ten took the body region and handed it back, which is every shape in the file except the screen and the row. They answer with nodes now. The screen was a match over three focus states building one region three ways; read into two Options and a bool, it is one region with a guard and two loops. The metadata table was a vec built by inserting at computed indices, which is a read rather than a description. The order it produced is the order the read now writes. The tag tokens are what earned `removable` in quasi-declare 0.1.3.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 21:41 UTC
Signed with PGP, not checked
Commit: 0f7c08093742e9109d9541920c508090eee269fc
Parent: 786f57d
2 files changed, +273 insertions, -144 deletions
M Cargo.lock +13 -13
@@ -4257,7 +4257,7 @@
4257 4257
4258 4258 [[package]]
4259 4259 name = "quasi-declare"
4260 - version = "0.1.2"
4260 + version = "0.1.3"
4261 4261 dependencies = [
4262 4262 "proc-macro2",
4263 4263 "quote",
@@ -7566,18 +7566,6 @@
7566 7566 "winnow 1.0.4",
7567 7567 ]
7568 7568
7569 - [[patch.unused]]
7570 - name = "kberg"
7571 - version = "0.1.0"
7572 -
7573 - [[patch.unused]]
7574 - name = "ops-status"
7575 - version = "0.1.0"
7576 -
7577 - [[patch.unused]]
7578 - name = "painhours"
7579 - version = "0.1.0"
7580 -
7581 7569 [[patch.unused]]
7582 7570 name = "quasi-axum"
7583 7571 version = "0.101.1"
@@ -7609,3 +7597,15 @@
7609 7597 [[patch.unused]]
7610 7598 name = "quasi-type"
7611 7599 version = "0.1.3"
7600 +
7601 + [[patch.unused]]
7602 + name = "kberg"
7603 + version = "0.1.0"
7604 +
7605 + [[patch.unused]]
7606 + name = "ops-status"
7607 + version = "0.1.0"
7608 +
7609 + [[patch.unused]]
7610 + name = "painhours"
7611 + version = "0.1.0"
@@ -41,7 +41,7 @@
41 41 //! **1. A control that is offered but not available cannot say why.** The two
42 42 //! Discovery buttons are drawn disabled with the sentence that would make them
43 43 //! work: "Re-analyze this sample with spectral features enabled to find similar
44 - //! samples." [`Act`] has [`State::Disabled`](quasi_router::layout::State) and
44 + //! samples." [`Act`](quasi_router::Act) has [`State::Disabled`](quasi_router::layout::State) and
45 45 //! nothing else, so the description can say the button is dead and not what
46 46 //! would revive it. Every renderer then either drops the sentence or invents
47 47 //! somewhere to put it.
@@ -49,7 +49,7 @@
49 49 //! This is makeover-layout `e761833e` — "an option that is offered but not
50 50 //! currently available, and the precondition that would make it available, has
51 51 //! no description" — arriving from the other side. That one is about a
52 - //! [`Choice`](quasi_router::Choice) inside a picker; this is an [`Act`]. Same
52 + //! [`Choice`](quasi_router::Choice) inside a picker; this is an [`Act`](quasi_router::Act). Same
53 53 //! missing fact, two members, which is what a second consumer looks like. Filed
54 54 //! rather than invented here.
55 55 //!
@@ -68,12 +68,11 @@
68 68 //! wrong layer: an intent is for the app's own UI state, and a clipboard is the
69 69 //! *system's*. Written down rather than worked around quietly.
70 70
71 - use quasi_router::layout::{FieldKind, Tone};
72 - use quasi_router::{
73 - Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, Tag,
74 - };
71 + use quasi_declare::declare;
72 + use quasi_router::layout::Tone;
73 + use quasi_router::{Request, Response, RouteError, Router};
75 74
76 - use super::{Analysis, Coverage, Detailed, Focus, Panels, Shared, Source, Spread, Suggested};
75 + use super::{Analysis, Detailed, Focus, Panels, Shared, Source, Spread, Suggested};
77 76
78 77 /// The region the screen answers into.
79 78 const BODY: &str = "detail-body";
@@ -131,7 +130,7 @@
131 130
132 131 /// `GET /detail`
133 132 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
134 - Ok(screen(state).into())
133 + Ok(showing(state))
135 134 }
136 135
137 136 /// `POST /detail/tags`
@@ -142,48 +141,48 @@
142 141 fn add_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
143 142 let tag = request.payload.get(TAG).unwrap_or_default().trim();
144 143 if tag.is_empty() {
145 - return Ok(Response::from(screen(state)).toast(Tone::Danger, "Type a tag first."));
144 + return Ok(showing(state).toast(Tone::Danger, "Type a tag first."));
146 145 }
147 146 state.detail.add_tag(tag);
148 - Ok(screen(state).into())
147 + Ok(showing(state))
149 148 }
150 149
151 150 /// `POST /detail/tags/{tag}/remove`
152 151 fn remove_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
153 152 let tag = named(&request)?;
154 153 state.detail.remove_tag(&tag);
155 - Ok(screen(state).into())
154 + Ok(showing(state))
156 155 }
157 156
158 157 /// `POST /detail/tags/suggest`
159 158 fn suggest(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
160 159 state.detail.suggest();
161 - Ok(screen(state).into())
160 + Ok(showing(state))
162 161 }
163 162
164 163 /// `POST /detail/tags/{tag}/accept`
165 164 fn accept(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
166 165 let tag = named(&request)?;
167 166 state.detail.accept(&tag);
168 - Ok(screen(state).into())
167 + Ok(showing(state))
169 168 }
170 169
171 170 /// `POST /detail/path/copy`
172 171 fn copy_path(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
173 172 state.detail.copy_path();
174 - Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied."))
173 + Ok(showing(state).toast(Tone::Success, "Path copied."))
175 174 }
176 175
177 176 /// `POST /detail/edit`
178 177 fn edit(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
179 178 state.detail.edit();
180 - Ok(screen(state).into())
179 + Ok(showing(state))
181 180 }
182 181
183 182 /// `POST /detail/forge`
184 183 fn forge(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
185 184 state.detail.forge();
186 - Ok(screen(state).into())
185 + Ok(showing(state))
187 186 }
188 187
189 188 /// `POST /detail/similar`
@@ -198,7 +197,7 @@
198 197 return Err(RouteError::not_found(SPECTRAL));
199 198 }
200 199 state.detail.find_similar();
201 - Ok(screen(state).into())
200 + Ok(showing(state))
202 201 }
203 202
204 203 /// `POST /detail/duplicates`
@@ -207,21 +206,21 @@
207 206 return Err(RouteError::not_found(FINGERPRINT));
208 207 }
209 208 state.detail.find_duplicates();
210 - Ok(screen(state).into())
209 + Ok(showing(state))
211 210 }
212 211
213 212 /// `POST /detail/selection/tags/{tag}/spread`
214 213 fn spread_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
215 214 let tag = named(&request)?;
216 215 state.detail.spread_tag(&tag);
217 - Ok(screen(state).into())
216 + Ok(showing(state))
218 217 }
219 218
220 219 /// `POST /detail/selection/tags/{tag}/strip`
221 220 fn strip_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
222 221 let tag = named(&request)?;
223 222 state.detail.strip_tag(&tag);
224 - Ok(screen(state).into())
223 + Ok(showing(state))
225 224 }
226 225
227 226 /// The tag a request names.
@@ -242,133 +241,263 @@
242 241 "Re-analyze this sample with spectral features enabled to find similar samples.";
243 242 const FINGERPRINT: &str = "Re-analyze this sample with fingerprinting enabled to find duplicates.";
244 243
245 - /// The screen, which is a different screen per selection.
246 - fn screen(state: &Panels<'_>) -> Screen {
247 - let body = Slot::new(BODY, RegionKind::Pane);
248 - let body = match state.detail.focus() {
249 - Focus::Nothing => body.with(Node::empty("Select a sample")),
250 - Focus::One(sample) => one_sample(body, &sample),
251 - Focus::Several(spread) => several(body, &spread),
252 - };
253 - Screen::sidebar_content("Detail").with(body)
244 + /// The screen, read and then described.
245 + pub(super) fn showing(state: &Panels<'_>) -> Response {
246 + Response::from(screen(&read(state)))
254 247 }
255 248
256 - /// One sample: what it is, what it is tagged with, and what can be done to it.
257 - fn one_sample(body: Slot, sample: &Detailed) -> Slot {
258 - let mut body = body.with(Node::page(&sample.name));
259 -
260 - if let Some(analysis) = &sample.analysis {
261 - body = metadata(body, analysis);
262 - }
263 - body = tags(body, sample);
264 - body = actions(body, sample);
265 - discovery(body, sample)
249 + /// What the panel draws, read off the app.
250 + struct Detail {
251 + /// The sample in focus, while exactly one is.
252 + one: Option<Focused>,
253 + /// The selection, while more than one thing is chosen.
254 + several: Option<Several>,
255 + /// Whether nothing is chosen at all.
256 + nothing: bool,
266 257 }
267 258
268 - /// What analysis found, as a table of facts.
259 + /// What is in focus: a sample or a folder, and what can be done to it.
269 260 ///
270 - /// A two-column table rather than a strip of [`Node::Stats`], and the difference
271 - /// is the claim: a figure strip says "these are the numbers this screen is
272 - /// about", which is right for a dashboard and wrong here — sample rate and
273 - /// channel count are properties of a file, not headline figures. The shipped
274 - /// panel draws an `egui::Grid` of label/value pairs and that is what this is.
275 - fn metadata(body: Slot, analysis: &Analysis) -> Slot {
276 - use quasi_router::{Cell, Cells, Column, Table};
277 -
278 - let mut rows = vec![
279 - fact("Duration", seconds(analysis.duration)),
280 - fact("Sample rate", format!("{} Hz", analysis.sample_rate)),
281 - fact("Channels", analysis.channels.to_string()),
282 - ];
283 - if let Some(bpm) = analysis.bpm {
284 - rows.insert(1, fact("BPM", format!("{bpm:.0}")));
285 - }
286 - if let Some(key) = &analysis.musical_key {
287 - rows.insert(if analysis.bpm.is_some() { 2 } else { 1 }, fact("Key", key));
288 - }
289 - if let Some(peak) = analysis.peak_db {
290 - rows.push(fact("Peak", format!("{peak:.1} dB")));
291 - }
292 - if let Some(rms) = analysis.rms_db {
293 - rows.push(fact("RMS", format!("{rms:.1} dB")));
294 - }
295 - if let Some(lufs) = analysis.lufs {
296 - rows.push(fact("LUFS", format!("{lufs:.1}")));
297 - }
298 - if let Some(is_loop) = analysis.is_loop {
299 - rows.push(fact("Loop", if is_loop { "Yes" } else { "No" }));
300 - }
301 -
302 - // Positional cells, and they stay that way: the two columns and the two
303 - // cells are written in the same expression, and it is the *rows* that come
304 - // and go here rather than the cells within one. Every row is a field and a
305 - // value, so there is no conditional cell to shift the ones behind it.
306 - //
307 - // No `more`: nine fields at most, and every one that was found is here.
308 - body.with(Node::section("Metadata")).with(Node::from(
309 - Table::new(vec![Column::new("Field"), Column::new("Value")]).rows(
310 - rows.into_iter()
311 - .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(value)])),
312 - ),
313 - ))
261 + /// Named for the focus rather than for a sample because a folder lands here
262 + /// too, and `is_sample` is what tells them apart.
263 + struct Focused {
264 + /// What it is called.
265 + name: String,
266 + /// What analysis found, where it has run.
267 + facts: Option<Vec<Fact>>,
268 + /// What it is tagged with, and where each tag came from.
269 + tags: Vec<Tag>,
270 + /// Whether it carries none.
271 + untagged: bool,
272 + /// Tags found on acoustically similar samples, once asked for.
273 + suggestions: Vec<Suggestion>,
274 + /// Whether there is no path to copy.
275 + pathless: bool,
276 + /// Whether it is a sample rather than a folder, which is what the editing
277 + /// controls need: the shipped panel offers Edit and Forge only where there
278 + /// is a hash to open them on.
279 + is_sample: bool,
280 + /// Finding related samples, for a sample. Absent for a folder.
281 + finding: Option<Finding>,
314 282 }
315 283
316 - /// One label and one value.
317 - fn fact(field: &str, value: impl Into<String>) -> (String, String) {
318 - (field.to_owned(), value.into())
284 + /// One label and one value, as the metadata table draws it.
285 + struct Fact {
286 + /// What the fact is called.
287 + field: &'static str,
288 + /// What it says.
289 + value: String,
319 290 }
320 291
321 - /// What it is tagged with, where each tag came from, and what may be added.
322 - ///
323 - /// The provenance is a [`Tone`] on the token rather than a second collapsed
324 - /// section listing the same tags again. The shipped panel has both — chips at
325 - /// the top, a "Tag sources" fold underneath repeating every tag with a coloured
326 - /// word beside it — and the fold exists because a chip had nowhere to carry the
327 - /// fact. A token does: it has a tone, and the tone is what the fold was
328 - /// colouring anyway.
329 - fn tags(body: Slot, sample: &Detailed) -> Slot {
330 - let mut body = body.with(Node::section("Tags"));
331 -
332 - if sample.tags.is_empty() {
333 - body = body.with(Node::text("No tags"));
334 - } else {
335 - for tagged in &sample.tags {
336 - body = body.with(Node::Token(
337 - Tag::removable(
338 - format!("{} ({})", tagged.name, tagged.source.as_str()),
339 - Action::post(format!("/detail/tags/{}/remove", tagged.name)),
340 - )
341 - .tone(tone_of(&tagged.source)),
342 - ));
343 - }
344 - }
345 -
346 - body = body.with(Node::Form {
347 - fields: vec![Field::new(FieldKind::Text, TAG, "Add tag").hint("Use dots: genre.house")],
348 - submit: "Add".to_owned(),
349 - action: Action::post("/detail/tags"),
350 - });
351 -
352 - body = body.with(Node::Act(Act::new(
353 - "Suggest similar tags",
354 - Action::post("/detail/tags/suggest"),
355 - )));
356 -
357 - for suggestion in &sample.suggestions {
358 - body = body.with(Node::Act(Act::new(
359 - offer(suggestion),
360 - Action::post(format!("/detail/tags/{}/accept", suggestion.tag)),
361 - )));
362 - }
363 - body
292 + /// One tag on one sample, as the token it is.
293 + struct Tag {
294 + /// The tag itself, which the removal address carries.
295 + name: String,
296 + /// The tag and where it came from, which is what the token reads.
297 + label: String,
298 + /// What the provenance reads as.
299 + ///
300 + /// Four sources onto three tones, which is a narrowing the shipped panel
301 + /// does not do: it gives each source its own palette entry, including two of
302 + /// the categorical colours, which are for telling series apart rather than
303 + /// for meaning anything. A tone says what a thing *is*, so a tag the app
304 + /// derived and a tag a rule matched are both "the app did this" and a
305 + /// hand-typed one is an ordinary fact.
306 + tone: Tone,
364 307 }
365 308
366 - /// A suggestion, as the control that takes it reads.
309 + /// One suggested tag, as the control that takes it reads.
367 310 ///
368 311 /// The score and the neighbour count are in the label rather than in a hover,
369 312 /// because a hover is a pointer affordance and the description has readers with
370 313 /// no pointer. The shipped panel puts the count in `on_hover_text`, which a
371 314 /// terminal renderer would have lost.
315 + struct Suggestion {
316 + /// The tag itself, which the accept address carries.
317 + tag: String,
318 + /// What the control reads.
319 + offer: String,
320 + }
321 +
322 + /// Finding related samples, and saying so when it cannot be done.
323 + ///
324 + /// Both controls are described whether or not they can run, which is the
325 + /// shipped panel's choice and the right one: a control that vanishes when its
326 + /// prerequisite is missing teaches nothing. See this module's header for what
327 + /// the description cannot yet carry across -- the hover sentence, which is said
328 + /// as prose here because there is nowhere on the `Act` to put it.
329 + struct Finding {
330 + /// Whether the spectral features Find Similar reads were computed.
331 + spectral: bool,
332 + /// Whether the fingerprint Find Duplicates reads was computed.
333 + fingerprint: bool,
334 + }
335 +
336 + /// Several samples: what they agree on, and what can be done to all of them.
337 + struct Several {
338 + /// The heading, which counts the folders only when there are any.
339 + heading: String,
340 + /// What there is to summarise, while there is anything.
341 + summary: Option<Summary>,
342 + }
343 +
344 + /// What a selection of samples has in common.
345 + struct Summary {
346 + /// The three shared fields, in the order the table draws them.
347 + common: [Fact; 3],
348 + /// Every tag any of them carries, with what it would take to make it
349 + /// unanimous.
350 + tags: Vec<Covered>,
351 + /// Whether none of them carries anything.
352 + untagged: bool,
353 + }
354 +
355 + /// One tag across the selection, and the two things that can be done to it.
356 + struct Covered {
357 + /// The tag.
358 + name: String,
359 + /// How far it reaches, where it does not reach all of them.
360 + ///
361 + /// The partial-coverage count is in the row's own text rather than in a
362 + /// hover, for the reason a suggestion's score is: a reader with no pointer
363 + /// never sees a hover.
364 + partial: Option<String>,
365 + /// What putting it on the rest reads, where there is a rest.
366 + spread: Option<String>,
367 + /// What taking it off reads.
368 + strip: String,
369 + }
370 +
371 + /// What the panel draws, read off the app.
372 + fn read(state: &Panels<'_>) -> Detail {
373 + match state.detail.focus() {
374 + Focus::Nothing => Detail {
375 + one: None,
376 + several: None,
377 + nothing: true,
378 + },
379 + Focus::One(sample) => Detail {
380 + one: Some(sample_read(&sample)),
381 + several: None,
382 + nothing: false,
383 + },
384 + Focus::Several(spread) => Detail {
385 + one: None,
386 + several: Some(several_read(&spread)),
387 + nothing: false,
388 + },
389 + }
390 + }
391 +
392 + /// One sample, read off the app.
393 + fn sample_read(sample: &Detailed) -> Focused {
394 + Focused {
395 + name: sample.name.clone(),
396 + facts: sample.analysis.as_ref().map(facts),
397 + tags: sample
398 + .tags
399 + .iter()
400 + .map(|tagged| Tag {
401 + name: tagged.name.clone(),
402 + label: format!("{} ({})", tagged.name, tagged.source.as_str()),
403 + tone: tone_of(&tagged.source),
404 + })
405 + .collect(),
406 + untagged: sample.tags.is_empty(),
407 + suggestions: sample
408 + .suggestions
409 + .iter()
410 + .map(|suggestion| Suggestion {
411 + tag: suggestion.tag.clone(),
412 + offer: offer(suggestion),
413 + })
414 + .collect(),
415 + pathless: sample.path.is_none(),
416 + is_sample: sample.is_sample,
417 + finding: sample.is_sample.then_some(Finding {
418 + spectral: sample.has_spectral,
419 + fingerprint: sample.has_fingerprint,
420 + }),
421 + }
422 + }
423 +
424 + /// What analysis found, in the order the table draws it.
425 + ///
426 + /// Nine fields at most, and every one that was found is here. The optional ones
427 + /// sit where the shipped grid puts them rather than being appended, which is
428 + /// what the inserts this replaces were doing by index.
429 + fn facts(analysis: &Analysis) -> Vec<Fact> {
430 + let mut facts = vec![fact("Duration", seconds(analysis.duration))];
431 + if let Some(bpm) = analysis.bpm {
432 + facts.push(fact("BPM", format!("{bpm:.0}")));
433 + }
434 + if let Some(key) = &analysis.musical_key {
435 + facts.push(fact("Key", key));
436 + }
437 + facts.push(fact("Sample rate", format!("{} Hz", analysis.sample_rate)));
438 + facts.push(fact("Channels", analysis.channels.to_string()));
439 + if let Some(peak) = analysis.peak_db {
440 + facts.push(fact("Peak", format!("{peak:.1} dB")));
441 + }
442 + if let Some(rms) = analysis.rms_db {
443 + facts.push(fact("RMS", format!("{rms:.1} dB")));
444 + }
445 + if let Some(lufs) = analysis.lufs {
446 + facts.push(fact("LUFS", format!("{lufs:.1}")));
447 + }
448 + if let Some(is_loop) = analysis.is_loop {
449 + facts.push(fact("Loop", if is_loop { "Yes" } else { "No" }));
450 + }
451 + facts
452 + }
453 +
454 + /// One label and one value.
455 + fn fact(field: &'static str, value: impl Into<String>) -> Fact {
456 + Fact {
457 + field,
458 + value: value.into(),
459 + }
460 + }
461 +
462 + /// A selection, read off the app.
463 + fn several_read(spread: &Spread) -> Several {
464 + Several {
465 + heading: if spread.folders == 0 {
466 + format!("{} samples selected", spread.samples)
467 + } else {
468 + format!(
469 + "{} samples \u{b7} {} folders selected",
470 + spread.samples, spread.folders
471 + )
472 + },
473 + summary: (spread.samples > 0).then(|| Summary {
474 + common: [
475 + fact("BPM", reads(&spread.bpm)),
476 + fact("Key", reads(&spread.musical_key)),
477 + fact("Duration", reads(&spread.duration)),
478 + ],
479 + tags: spread
480 + .tags
481 + .iter()
482 + .map(|tag| Covered {
483 + name: tag.name.clone(),
484 + partial: (tag.on != spread.samples)
485 + .then(|| format!("{} of {}", tag.on, spread.samples)),
486 + spread: (tag.on != spread.samples)
487 + .then(|| format!("Apply to remaining ({})", spread.samples - tag.on)),
488 + strip: if tag.on == spread.samples {
489 + format!("Remove from all ({})", tag.on)
490 + } else {
491 + format!("Remove from {}", tag.on)
492 + },
493 + })
494 + .collect(),
495 + untagged: spread.tags.is_empty(),
496 + }),
497 + }
498 + }
499 +
500 + /// A suggestion, as the control that takes it reads. See [`Suggestion`].
372 501 fn offer(suggestion: &Suggested) -> String {
Lines truncated