Skip to main content

max / audiofiles

23.3 KB · 608 lines History Blame Raw
1 //! The detail panel, described rather than built.
2 //!
3 //! The fifth audiofiles screen, and the first whose subject is *the selection*
4 //! rather than an address. `sync` established that a state machine is four
5 //! screens at one route; `export` established the same for a flow. This is the
6 //! third and the shape has stopped being a discovery: **one route answers
7 //! however many screens the app's state has, because the state is something
8 //! that happened and not somewhere you can go.** Nothing navigates to "three
9 //! samples are chosen".
10 //!
11 //! # What the description deletes
12 //!
13 //! The multi-selection reduction stays in the app (`ui::detail::summarize`,
14 //! made `pub(crate)` for this) and everything around it goes. The shipped panel
15 //! writes "varies" in three places, each as its own `match` over
16 //! `Option<Result<V, ()>>` with its own em-dash fallback; here that is
17 //! [`Shared`] and one function. A renderer that wants to draw disagreement
18 //! differently from absence now can, and until this port the two were the same
19 //! string.
20 //!
21 //! # What is deliberately not described
22 //!
23 //! - **The waveform.** 100 lines of it, and every one is a host fact: a
24 //! click maps a pixel to a frame, a hover paints a line at the pointer, and
25 //! the playback cursor is read out of a mutex a worker is filling. None of
26 //! that is a fact about a sample. `Node::Image` would be the nearest member
27 //! and it is not near: an image is a picture at an address, and this is a
28 //! canvas that answers a pointer.
29 //! - **The Tab-from-table focus handoff.** `state.focus_tag_input` asks the tag
30 //! field to take focus this frame, which is a fact about a keyboard and a
31 //! window rather than about the screen. `Act::key` names the key that reaches
32 //! a control, and there is no member that says "this field has the caret now"
33 //! — correctly, because that is what a host's focus ring is for.
34 //! - **The collapsing sections.** Whether Metadata is open is remembered per
35 //! `id_salt` by egui. `Node::section` says a section starts; whether the host
36 //! lets a reader fold it is renderer policy, and the settings port settled
37 //! that already.
38 //!
39 //! # THE FINDINGS, and both are second consumers
40 //!
41 //! **1. A control that is offered but not available cannot say why.** The two
42 //! Discovery buttons are drawn disabled with the sentence that would make them
43 //! work: "Re-analyze this sample with spectral features enabled to find similar
44 //! samples." [`Act`] has [`State::Disabled`](quasi_router::layout::State) and
45 //! nothing else, so the description can say the button is dead and not what
46 //! would revive it. Every renderer then either drops the sentence or invents
47 //! somewhere to put it.
48 //!
49 //! This is makeover-layout `e761833e` — "an option that is offered but not
50 //! currently available, and the precondition that would make it available, has
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
53 //! missing fact, two members, which is what a second consumer looks like. Filed
54 //! rather than invented here.
55 //!
56 //! Note what this port did *not* do: it did not drop the disabled controls, and
57 //! it did not fold the precondition into the label. Both would have hidden the
58 //! gap. The buttons are described as disabled and the sentence is said beside
59 //! them as prose, which is honest and slightly wrong in exactly the way the
60 //! finding predicts.
61 //!
62 //! **2. Handing text to the clipboard is a host act with no vocabulary.**
63 //! `Copy Path` is `ui.ctx().copy_text(path)`. It is the same shape as opening an
64 //! address outside the app, which quasi answers with
65 //! [`Outcome::Goto`](quasi_router::Outcome) and every host performs its own way
66 //! — and there is no clipboard equivalent, so this port routes it through an
67 //! [`Intent`](super::Intent) and the host copies. That works and it is the
68 //! wrong layer: an intent is for the app's own UI state, and a clipboard is the
69 //! *system's*. Written down rather than worked around quietly.
70
71 use quasi_router::layout::{FieldKind, Notice, Tone};
72 use quasi_router::{
73 Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, Tag,
74 };
75
76 use super::{Analysis, Coverage, Detailed, Focus, Panels, Shared, Source, Spread, Suggested};
77
78 /// The region the screen answers into.
79 const BODY: &str = "detail-body";
80
81 /// The field a tag is typed into.
82 const TAG: &str = "tag";
83
84 /// What a set of samples agree on, if they agree on anything.
85 ///
86 /// Lived in `ui::detail` until that module was deleted (2026-08-22) and came
87 /// here rather than going with it: the described screen's multi-selection body
88 /// is what reads it now, and "they all say 90, or they vary" is a fact about a
89 /// selection rather than about a renderer.
90 ///
91 /// Reduce a field across a multi-selection to one displayable value. Returns
92 /// `None` when the selection is empty or the first item lacks the field (nothing
93 /// to show), `Some(Err(()))` when the values differ or any item lacks the field
94 /// (renders as "varies"), and `Some(Ok(v))` when every item shares value `v`.
95 pub(crate) fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>>
96 where
97 F: Fn(&T) -> Option<V>,
98 V: PartialEq,
99 {
100 let mut iter = items.iter().map(&extract);
101 let first = iter.next()??;
102 for v in iter {
103 match v {
104 Some(v) if v == first => {}
105 Some(_) => return Some(Err(())),
106 None => return Some(Err(())),
107 }
108 }
109 Some(Ok(first))
110 }
111
112 /// Register this screen's routes.
113 ///
114 /// Everything is a `POST` to `/detail/...` and the answer is always the same
115 /// screen, because there is only one: what changes is the selection, and the
116 /// selection is not addressable. See this module's header.
117 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
118 router
119 .get("/detail", index)
120 .post("/detail/tags", add_tag)
121 .post("/detail/tags/{tag}/remove", remove_tag)
122 .post("/detail/tags/suggest", suggest)
123 .post("/detail/tags/{tag}/accept", accept)
124 .post("/detail/path/copy", copy_path)
125 .post("/detail/edit", edit)
126 .post("/detail/forge", forge)
127 .post("/detail/similar", find_similar)
128 .post("/detail/duplicates", find_duplicates)
129 .post("/detail/selection/tags/{tag}/spread", spread_tag)
130 .post("/detail/selection/tags/{tag}/strip", strip_tag)
131 }
132
133 /// `GET /detail`
134 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
135 Ok(screen(state).into())
136 }
137
138 /// `POST /detail/tags`
139 ///
140 /// The tag is validated by the app, which already refuses an invalid one with a
141 /// status message. What this refuses is the empty submission, because a control
142 /// that appears to do nothing is worse than one that says why.
143 fn add_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
144 let tag = request.payload.get(TAG).unwrap_or_default().trim();
145 if tag.is_empty() {
146 return Ok(Response::from(screen(state)).toast(Tone::Danger, "Type a tag first."));
147 }
148 state.detail.add_tag(tag);
149 Ok(screen(state).into())
150 }
151
152 /// `POST /detail/tags/{tag}/remove`
153 fn remove_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
154 let tag = named(&request)?;
155 state.detail.remove_tag(&tag);
156 Ok(screen(state).into())
157 }
158
159 /// `POST /detail/tags/suggest`
160 fn suggest(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
161 state.detail.suggest();
162 Ok(screen(state).into())
163 }
164
165 /// `POST /detail/tags/{tag}/accept`
166 fn accept(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
167 let tag = named(&request)?;
168 state.detail.accept(&tag);
169 Ok(screen(state).into())
170 }
171
172 /// `POST /detail/path/copy`
173 fn copy_path(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
174 state.detail.copy_path();
175 Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied."))
176 }
177
178 /// `POST /detail/edit`
179 fn edit(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
180 state.detail.edit();
181 Ok(screen(state).into())
182 }
183
184 /// `POST /detail/forge`
185 fn forge(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
186 state.detail.forge();
187 Ok(screen(state).into())
188 }
189
190 /// `POST /detail/similar`
191 ///
192 /// Refused where the features it reads were never computed, and that refusal is
193 /// the route's rather than only the button's: an address is reachable by typing,
194 /// so a disabled control is an affordance and not a guarantee. The shipped panel
195 /// has only the button, which is why this is the one place the described version
196 /// is stricter than what it ports.
197 fn find_similar(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
198 if !one(state).is_some_and(|sample| sample.has_spectral) {
199 return Err(RouteError::not_found(SPECTRAL));
200 }
201 state.detail.find_similar();
202 Ok(screen(state).into())
203 }
204
205 /// `POST /detail/duplicates`
206 fn find_duplicates(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
207 if !one(state).is_some_and(|sample| sample.has_fingerprint) {
208 return Err(RouteError::not_found(FINGERPRINT));
209 }
210 state.detail.find_duplicates();
211 Ok(screen(state).into())
212 }
213
214 /// `POST /detail/selection/tags/{tag}/spread`
215 fn spread_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
216 let tag = named(&request)?;
217 state.detail.spread_tag(&tag);
218 Ok(screen(state).into())
219 }
220
221 /// `POST /detail/selection/tags/{tag}/strip`
222 fn strip_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
223 let tag = named(&request)?;
224 state.detail.strip_tag(&tag);
225 Ok(screen(state).into())
226 }
227
228 /// The tag a request names.
229 fn named(request: &Request) -> Result<String, RouteError> {
230 Ok(request.captures.require("tag")?.to_owned())
231 }
232
233 /// The sample in focus, if one is.
234 fn one(state: &Panels<'_>) -> Option<Detailed> {
235 match state.detail.focus() {
236 Focus::One(sample) => Some(*sample),
237 Focus::Nothing | Focus::Several(_) => None,
238 }
239 }
240
241 /// What the two discovery paths need, said the way the shipped panel says it.
242 const SPECTRAL: &str =
243 "Re-analyze this sample with spectral features enabled to find similar samples.";
244 const FINGERPRINT: &str = "Re-analyze this sample with fingerprinting enabled to find duplicates.";
245
246 /// The screen, which is a different screen per selection.
247 fn screen(state: &Panels<'_>) -> Screen {
248 let body = Slot::new(BODY, RegionKind::Pane);
249 let body = match state.detail.focus() {
250 Focus::Nothing => body.with(Node::empty("Select a sample")),
251 Focus::One(sample) => one_sample(body, &sample),
252 Focus::Several(spread) => several(body, &spread),
253 };
254 Screen::sidebar_content("Detail").with(body)
255 }
256
257 /// One sample: what it is, what it is tagged with, and what can be done to it.
258 fn one_sample(body: Slot, sample: &Detailed) -> Slot {
259 let mut body = body.with(Node::page(&sample.name));
260
261 if let Some(analysis) = &sample.analysis {
262 body = metadata(body, analysis);
263 }
264 body = tags(body, sample);
265 body = actions(body, sample);
266 discovery(body, sample)
267 }
268
269 /// What analysis found, as a table of facts.
270 ///
271 /// A two-column table rather than a strip of [`Node::Stats`], and the difference
272 /// is the claim: a figure strip says "these are the numbers this screen is
273 /// about", which is right for a dashboard and wrong here — sample rate and
274 /// channel count are properties of a file, not headline figures. The shipped
275 /// panel draws an `egui::Grid` of label/value pairs and that is what this is.
276 fn metadata(body: Slot, analysis: &Analysis) -> Slot {
277 use quasi_router::{Cell, Cells, Column};
278
279 let mut rows = vec![
280 fact("Duration", seconds(analysis.duration)),
281 fact("Sample rate", format!("{} Hz", analysis.sample_rate)),
282 fact("Channels", analysis.channels.to_string()),
283 ];
284 if let Some(bpm) = analysis.bpm {
285 rows.insert(1, fact("BPM", format!("{bpm:.0}")));
286 }
287 if let Some(key) = &analysis.musical_key {
288 rows.insert(if analysis.bpm.is_some() { 2 } else { 1 }, fact("Key", key));
289 }
290 if let Some(peak) = analysis.peak_db {
291 rows.push(fact("Peak", format!("{peak:.1} dB")));
292 }
293 if let Some(rms) = analysis.rms_db {
294 rows.push(fact("RMS", format!("{rms:.1} dB")));
295 }
296 if let Some(lufs) = analysis.lufs {
297 rows.push(fact("LUFS", format!("{lufs:.1}")));
298 }
299 if let Some(is_loop) = analysis.is_loop {
300 rows.push(fact("Loop", if is_loop { "Yes" } else { "No" }));
301 }
302
303 body.with(Node::section("Metadata")).with(Node::Table {
304 columns: vec![Column::new("Field"), Column::new("Value")],
305 rows: rows
306 .into_iter()
307 .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(value)]))
308 .collect(),
309 // Nine fields at most, and every one that was found is here.
310 more: None,
311 })
312 }
313
314 /// One label and one value.
315 fn fact(field: &str, value: impl Into<String>) -> (String, String) {
316 (field.to_owned(), value.into())
317 }
318
319 /// What it is tagged with, where each tag came from, and what may be added.
320 ///
321 /// The provenance is a [`Tone`] on the token rather than a second collapsed
322 /// section listing the same tags again. The shipped panel has both — chips at
323 /// the top, a "Tag sources" fold underneath repeating every tag with a coloured
324 /// word beside it — and the fold exists because a chip had nowhere to carry the
325 /// fact. A token does: it has a tone, and the tone is what the fold was
326 /// colouring anyway.
327 fn tags(body: Slot, sample: &Detailed) -> Slot {
328 let mut body = body.with(Node::section("Tags"));
329
330 if sample.tags.is_empty() {
331 body = body.with(Node::text("No tags"));
332 } else {
333 for tagged in &sample.tags {
334 body = body.with(Node::Token(Tag {
335 kind: quasi_router::layout::Token::Chip { removable: true },
336 label: format!("{} ({})", tagged.name, tagged.source.as_str()),
337 tone: tone_of(&tagged.source),
338 latched: false,
339 action: Some(Action::post(format!("/detail/tags/{}/remove", tagged.name))),
340 }));
341 }
342 }
343
344 body = body.with(Node::Form {
345 fields: vec![Field::new(FieldKind::Text, TAG, "Add tag").hint("Use dots: genre.house")],
346 submit: "Add".to_owned(),
347 action: Action::post("/detail/tags"),
348 });
349
350 body = body.with(Node::Act(Act::new(
351 "Suggest similar tags",
352 Action::post("/detail/tags/suggest"),
353 )));
354
355 for suggestion in &sample.suggestions {
356 body = body.with(Node::Act(Act::new(
357 offer(suggestion),
358 Action::post(format!("/detail/tags/{}/accept", suggestion.tag)),
359 )));
360 }
361 body
362 }
363
364 /// A suggestion, as the control that takes it reads.
365 ///
366 /// The score and the neighbour count are in the label rather than in a hover,
367 /// because a hover is a pointer affordance and the description has readers with
368 /// no pointer. The shipped panel puts the count in `on_hover_text`, which a
369 /// terminal renderer would have lost.
370 fn offer(suggestion: &Suggested) -> String {
371 format!(
372 "Add {} ({:.0}%, on {} similar)",
373 suggestion.tag,
374 suggestion.score * 100.0,
375 suggestion.neighbours,
376 )
377 }
378
379 /// What provenance reads as.
380 ///
381 /// Four sources onto three tones, which is a narrowing the shipped panel does
382 /// not do: it gives each source its own palette entry, including two of the
383 /// categorical colours, which are for telling series apart rather than for
384 /// meaning anything. A tone says what a thing *is*, so a tag the app derived and
385 /// a tag a rule matched are both "the app did this" and a hand-typed one is an
386 /// ordinary fact.
387 fn tone_of(source: &Source) -> Tone {
388 match source {
389 Source::Manual => Tone::Neutral,
390 Source::Rule | Source::Folder => Tone::Info,
391 Source::Suggested | Source::Cluster | Source::Other(_) => Tone::Warning,
392 }
393 }
394
395 /// What can be done to the sample.
396 fn actions(body: Slot, sample: &Detailed) -> Slot {
397 let mut body = body.with(Node::section("Actions"));
398 // Present whether or not there is a path, and dead when there is not. This
399 // used to be hidden when `path` was `None`, which is the shape [`discovery`]
400 // argues against four functions below: a control that vanishes when its
401 // prerequisite is missing teaches nothing. The shipped panel draws it
402 // always and does nothing when pressed with no path, which teaches less
403 // still, so neither side was saying what it meant.
404 let mut copy = Act::new("Copy Path", Action::post("/detail/path/copy"));
405 if sample.path.is_none() {
406 copy = copy.disabled();
407 }
408 body = body.with(Node::Act(copy));
409 if sample.is_sample {
410 body = body
411 .with(Node::Act(
412 Act::new("Edit", Action::post("/detail/edit")).key("e"),
413 ))
414 .with(Node::Act(
415 Act::new("Forge", Action::post("/detail/forge")).key("f"),
416 ));
417 }
418 body
419 }
420
421 /// Finding related samples, and saying so when it cannot be done.
422 ///
423 /// Both controls are described whether or not they can run, which is the
424 /// shipped panel's choice and the right one: a control that vanishes when its
425 /// prerequisite is missing teaches nothing, and `add_enabled(false, ..)` with a
426 /// disabled hover is what the panel does. See this module's header for what the
427 /// description cannot yet carry across — the hover sentence, which is said as
428 /// prose here because there is nowhere on the [`Act`] to put it.
429 fn discovery(body: Slot, sample: &Detailed) -> Slot {
430 if !sample.is_sample {
431 return body;
432 }
433 let mut body = body.with(Node::section("Discovery"));
434
435 let mut similar = Act::new("Find Similar", Action::post("/detail/similar")).key("shift+f");
436 if !sample.has_spectral {
437 similar = similar.disabled();
438 }
439 body = body.with(Node::Act(similar));
440
441 let mut duplicates =
442 Act::new("Find Duplicates", Action::post("/detail/duplicates")).key("shift+d");
443 if !sample.has_fingerprint {
444 duplicates = duplicates.disabled();
445 }
446 body = body.with(Node::Act(duplicates));
447
448 // The preconditions, as prose beside the controls they are about. The
449 // finding is that this belongs on the control.
450 if !sample.has_spectral {
451 body = body.with(Node::Notice {
452 kind: Notice::Banner,
453 tone: Tone::Info,
454 text: SPECTRAL.to_owned(),
455 });
456 }
457 if !sample.has_fingerprint {
458 body = body.with(Node::Notice {
459 kind: Notice::Banner,
460 tone: Tone::Info,
461 text: FINGERPRINT.to_owned(),
462 });
463 }
464 body
465 }
466
467 /// Several samples: what they agree on, and what can be done to all of them.
468 fn several(body: Slot, spread: &Spread) -> Slot {
469 let heading = if spread.folders == 0 {
470 format!("{} samples selected", spread.samples)
471 } else {
472 format!(
473 "{} samples \u{b7} {} folders selected",
474 spread.samples, spread.folders
475 )
476 };
477 let mut body = body.with(Node::page(heading));
478
479 if spread.samples == 0 {
480 return body.with(Node::empty("No sample metadata to summarize"));
481 }
482
483 body = agreed(body, spread);
484 body = coverage(body, spread);
485 // Three controls where the shipped panel has one. `draw_multi_summary`
486 // offers "Edit as bulk" and the other two bulk operations are reached from
487 // the file list's context menu, which is a place rather than a fact: what
488 // they all act on is this selection, so this is where they are said.
489 body.with(Node::Act(Act::new("Tag all", Action::get("/bulk/tag"))))
490 .with(Node::Act(Act::new("Move all", Action::get("/bulk/move"))))
491 .with(Node::Act(Act::new(
492 "Rename all",
493 Action::get("/bulk/rename"),
494 )))
495 }
496
497 /// What every chosen sample says, where they say the same thing.
498 fn agreed(body: Slot, spread: &Spread) -> Slot {
499 use quasi_router::{Cell, Cells, Column};
500
501 body.with(Node::section("In common")).with(Node::Table {
502 columns: vec![Column::new("Field"), Column::new("Value")],
503 rows: [
504 ("BPM", &spread.bpm),
505 ("Key", &spread.musical_key),
506 ("Duration", &spread.duration),
507 ]
508 .into_iter()
509 .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(reads(value))]))
510 .collect(),
511 more: None,
512 })
513 }
514
515 /// What a shared field reads as.
516 ///
517 /// Three answers where the shipped panel has two strings, because it collapsed
518 /// [`Shared::Varies`] and [`Shared::Absent`] onto "varies" and an em dash
519 /// without either being a described fact. Saying which it is here is what lets a
520 /// renderer draw them differently.
521 fn reads(shared: &Shared) -> String {
522 match shared {
523 Shared::Same(value) => value.clone(),
524 Shared::Varies => "varies".to_owned(),
525 Shared::Absent => "\u{2014}".to_owned(),
526 }
527 }
528
529 /// Every tag any of them carries, with what it would take to make it unanimous.
530 ///
531 /// A [`Node::List`] rather than a wrap of tokens with a context menu on each,
532 /// which is what the shipped panel has. A right-click menu is a pointer
533 /// affordance; [`Row::menu`](quasi_router::Row) is the described form of the same
534 /// thing and every host answers it its own way. The partial-coverage count is in
535 /// the row's own text rather than in a hover, for the reason a suggestion's
536 /// score is: a reader with no pointer never sees a hover.
537 fn coverage(body: Slot, spread: &Spread) -> Slot {
538 if spread.tags.is_empty() {
539 return body.with(Node::section("Tags")).with(Node::text("No tags"));
540 }
541
542 body.with(Node::section("Tags"))
543 .with(Node::List {
544 rows: spread
545 .tags
546 .iter()
547 .map(|tag| row(tag, spread.samples))
548 .collect(),
549 more: None,
550 })
551 .with(Node::text(
552 "Use a tag's menu to put it on the rest of the selection, or take it off all of them.",
553 ))
554 }
555
556 /// One tag across the selection, with the two things that can be done to it.
557 fn row(tag: &Coverage, samples: usize) -> quasi_router::Row {
558 use quasi_router::Row;
559
560 let full = tag.on == samples;
561 let mut row = Row::new(&tag.name);
562 if !full {
563 row = row.meta(format!("{} of {}", tag.on, samples));
564 }
565
566 let mut menu = Vec::new();
567 if !full {
568 menu.push(Act::new(
569 format!("Apply to remaining ({})", samples - tag.on),
570 Action::post(format!("/detail/selection/tags/{}/spread", tag.name)),
571 ));
572 }
573 menu.push(
574 Act::new(
575 if full {
576 format!("Remove from all ({})", tag.on)
577 } else {
578 format!("Remove from {}", tag.on)
579 },
580 Action::post(format!("/detail/selection/tags/{}/strip", tag.name)),
581 )
582 .tone(Tone::Danger),
583 );
584 row.menu = menu;
585 row
586 }
587
588 /// A duration as the panel writes it.
589 fn seconds(duration: f64) -> String {
590 if duration < 60.0 {
591 format!("{duration:.1}s")
592 } else {
593 #[expect(
594 clippy::cast_possible_truncation,
595 clippy::cast_sign_loss,
596 reason = "a sample's length in minutes is small and positive"
597 )]
598 let minutes = (duration / 60.0) as u32;
599 #[expect(
600 clippy::cast_possible_truncation,
601 clippy::cast_sign_loss,
602 reason = "the remainder is under sixty"
603 )]
604 let rest = (duration % 60.0) as u32;
605 format!("{minutes}:{rest:02}")
606 }
607 }
608