//! The detail panel, described rather than built. //! //! The fifth audiofiles screen, and the first whose subject is *the selection* //! rather than an address. `sync` established that a state machine is four //! screens at one route; `export` established the same for a flow. This is the //! third and the shape has stopped being a discovery: **one route answers //! however many screens the app's state has, because the state is something //! that happened and not somewhere you can go.** Nothing navigates to "three //! samples are chosen". //! //! # What the description deletes //! //! The multi-selection reduction stays in the app (`ui::detail::summarize`, //! made `pub(crate)` for this) and everything around it goes. The shipped panel //! writes "varies" in three places, each as its own `match` over //! `Option>` with its own em-dash fallback; here that is //! [`Shared`] and one function. A renderer that wants to draw disagreement //! differently from absence now can, and until this port the two were the same //! string. //! //! # What is deliberately not described //! //! - **The waveform.** 100 lines of it, and every one is a host fact: a //! click maps a pixel to a frame, a hover paints a line at the pointer, and //! the playback cursor is read out of a mutex a worker is filling. None of //! that is a fact about a sample. `Node::Image` would be the nearest member //! and it is not near: an image is a picture at an address, and this is a //! canvas that answers a pointer. //! - **The Tab-from-table focus handoff.** `state.focus_tag_input` asks the tag //! field to take focus this frame, which is a fact about a keyboard and a //! window rather than about the screen. `Act::key` names the key that reaches //! a control, and there is no member that says "this field has the caret now" //! — correctly, because that is what a host's focus ring is for. //! - **The collapsing sections.** Whether Metadata is open is remembered per //! `id_salt` by egui. `Node::section` says a section starts; whether the host //! lets a reader fold it is renderer policy, and the settings port settled //! that already. //! //! # THE FINDINGS, and both are second consumers //! //! **1. A control that is offered but not available cannot say why.** The two //! Discovery buttons are drawn disabled with the sentence that would make them //! work: "Re-analyze this sample with spectral features enabled to find similar //! samples." [`Act`] has [`State::Disabled`](quasi_router::layout::State) and //! nothing else, so the description can say the button is dead and not what //! would revive it. Every renderer then either drops the sentence or invents //! somewhere to put it. //! //! This is makeover-layout `e761833e` — "an option that is offered but not //! currently available, and the precondition that would make it available, has //! no description" — arriving from the other side. That one is about a //! [`Choice`](quasi_router::Choice) inside a picker; this is an [`Act`]. Same //! missing fact, two members, which is what a second consumer looks like. Filed //! rather than invented here. //! //! Note what this port did *not* do: it did not drop the disabled controls, and //! it did not fold the precondition into the label. Both would have hidden the //! gap. The buttons are described as disabled and the sentence is said beside //! them as prose, which is honest and slightly wrong in exactly the way the //! finding predicts. //! //! **2. Handing text to the clipboard is a host act with no vocabulary.** //! `Copy Path` is `ui.ctx().copy_text(path)`. It is the same shape as opening an //! address outside the app, which quasi answers with //! [`Outcome::Goto`](quasi_router::Outcome) and every host performs its own way //! — and there is no clipboard equivalent, so this port routes it through an //! [`Intent`](super::Intent) and the host copies. That works and it is the //! wrong layer: an intent is for the app's own UI state, and a clipboard is the //! *system's*. Written down rather than worked around quietly. use quasi_router::layout::{FieldKind, Notice, Tone}; use quasi_router::{ Act, Action, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, Tag, }; use super::{Analysis, Coverage, Detailed, Focus, Panels, Shared, Source, Spread, Suggested}; /// The region the screen answers into. const BODY: &str = "detail-body"; /// The field a tag is typed into. const TAG: &str = "tag"; /// What a set of samples agree on, if they agree on anything. /// /// Lived in `ui::detail` until that module was deleted (2026-08-22) and came /// here rather than going with it: the described screen's multi-selection body /// is what reads it now, and "they all say 90, or they vary" is a fact about a /// selection rather than about a renderer. /// /// Reduce a field across a multi-selection to one displayable value. Returns /// `None` when the selection is empty or the first item lacks the field (nothing /// to show), `Some(Err(()))` when the values differ or any item lacks the field /// (renders as "varies"), and `Some(Ok(v))` when every item shares value `v`. pub(crate) fn summarize(items: &[T], extract: F) -> Option> where F: Fn(&T) -> Option, V: PartialEq, { let mut iter = items.iter().map(&extract); let first = iter.next()??; for v in iter { match v { Some(v) if v == first => {} Some(_) => return Some(Err(())), None => return Some(Err(())), } } Some(Ok(first)) } /// Register this screen's routes. /// /// Everything is a `POST` to `/detail/...` and the answer is always the same /// screen, because there is only one: what changes is the selection, and the /// selection is not addressable. See this module's header. pub fn routes(router: Router>) -> Router> { router .get("/detail", index) .post("/detail/tags", add_tag) .post("/detail/tags/{tag}/remove", remove_tag) .post("/detail/tags/suggest", suggest) .post("/detail/tags/{tag}/accept", accept) .post("/detail/path/copy", copy_path) .post("/detail/edit", edit) .post("/detail/forge", forge) .post("/detail/similar", find_similar) .post("/detail/duplicates", find_duplicates) .post("/detail/selection/tags/{tag}/spread", spread_tag) .post("/detail/selection/tags/{tag}/strip", strip_tag) } /// `GET /detail` fn index(state: &Panels<'_>, _request: Request) -> Result { Ok(screen(state).into()) } /// `POST /detail/tags` /// /// The tag is validated by the app, which already refuses an invalid one with a /// status message. What this refuses is the empty submission, because a control /// that appears to do nothing is worse than one that says why. fn add_tag(state: &Panels<'_>, request: Request) -> Result { let tag = request.payload.get(TAG).unwrap_or_default().trim(); if tag.is_empty() { return Ok(Response::from(screen(state)).toast(Tone::Danger, "Type a tag first.")); } state.detail.add_tag(tag); Ok(screen(state).into()) } /// `POST /detail/tags/{tag}/remove` fn remove_tag(state: &Panels<'_>, request: Request) -> Result { let tag = named(&request)?; state.detail.remove_tag(&tag); Ok(screen(state).into()) } /// `POST /detail/tags/suggest` fn suggest(state: &Panels<'_>, _request: Request) -> Result { state.detail.suggest(); Ok(screen(state).into()) } /// `POST /detail/tags/{tag}/accept` fn accept(state: &Panels<'_>, request: Request) -> Result { let tag = named(&request)?; state.detail.accept(&tag); Ok(screen(state).into()) } /// `POST /detail/path/copy` fn copy_path(state: &Panels<'_>, _request: Request) -> Result { state.detail.copy_path(); Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied.")) } /// `POST /detail/edit` fn edit(state: &Panels<'_>, _request: Request) -> Result { state.detail.edit(); Ok(screen(state).into()) } /// `POST /detail/forge` fn forge(state: &Panels<'_>, _request: Request) -> Result { state.detail.forge(); Ok(screen(state).into()) } /// `POST /detail/similar` /// /// Refused where the features it reads were never computed, and that refusal is /// the route's rather than only the button's: an address is reachable by typing, /// so a disabled control is an affordance and not a guarantee. The shipped panel /// has only the button, which is why this is the one place the described version /// is stricter than what it ports. fn find_similar(state: &Panels<'_>, _request: Request) -> Result { if !one(state).is_some_and(|sample| sample.has_spectral) { return Err(RouteError::not_found(SPECTRAL)); } state.detail.find_similar(); Ok(screen(state).into()) } /// `POST /detail/duplicates` fn find_duplicates(state: &Panels<'_>, _request: Request) -> Result { if !one(state).is_some_and(|sample| sample.has_fingerprint) { return Err(RouteError::not_found(FINGERPRINT)); } state.detail.find_duplicates(); Ok(screen(state).into()) } /// `POST /detail/selection/tags/{tag}/spread` fn spread_tag(state: &Panels<'_>, request: Request) -> Result { let tag = named(&request)?; state.detail.spread_tag(&tag); Ok(screen(state).into()) } /// `POST /detail/selection/tags/{tag}/strip` fn strip_tag(state: &Panels<'_>, request: Request) -> Result { let tag = named(&request)?; state.detail.strip_tag(&tag); Ok(screen(state).into()) } /// The tag a request names. fn named(request: &Request) -> Result { Ok(request.captures.require("tag")?.to_owned()) } /// The sample in focus, if one is. fn one(state: &Panels<'_>) -> Option { match state.detail.focus() { Focus::One(sample) => Some(*sample), Focus::Nothing | Focus::Several(_) => None, } } /// What the two discovery paths need, said the way the shipped panel says it. const SPECTRAL: &str = "Re-analyze this sample with spectral features enabled to find similar samples."; const FINGERPRINT: &str = "Re-analyze this sample with fingerprinting enabled to find duplicates."; /// The screen, which is a different screen per selection. fn screen(state: &Panels<'_>) -> Screen { let body = Slot::new(BODY, RegionKind::Pane); let body = match state.detail.focus() { Focus::Nothing => body.with(Node::empty("Select a sample")), Focus::One(sample) => one_sample(body, &sample), Focus::Several(spread) => several(body, &spread), }; Screen::sidebar_content("Detail").with(body) } /// One sample: what it is, what it is tagged with, and what can be done to it. fn one_sample(body: Slot, sample: &Detailed) -> Slot { let mut body = body.with(Node::page(&sample.name)); if let Some(analysis) = &sample.analysis { body = metadata(body, analysis); } body = tags(body, sample); body = actions(body, sample); discovery(body, sample) } /// What analysis found, as a table of facts. /// /// A two-column table rather than a strip of [`Node::Stats`], and the difference /// is the claim: a figure strip says "these are the numbers this screen is /// about", which is right for a dashboard and wrong here — sample rate and /// channel count are properties of a file, not headline figures. The shipped /// panel draws an `egui::Grid` of label/value pairs and that is what this is. fn metadata(body: Slot, analysis: &Analysis) -> Slot { use quasi_router::{Cell, Cells, Column}; let mut rows = vec![ fact("Duration", seconds(analysis.duration)), fact("Sample rate", format!("{} Hz", analysis.sample_rate)), fact("Channels", analysis.channels.to_string()), ]; if let Some(bpm) = analysis.bpm { rows.insert(1, fact("BPM", format!("{bpm:.0}"))); } if let Some(key) = &analysis.musical_key { rows.insert(if analysis.bpm.is_some() { 2 } else { 1 }, fact("Key", key)); } if let Some(peak) = analysis.peak_db { rows.push(fact("Peak", format!("{peak:.1} dB"))); } if let Some(rms) = analysis.rms_db { rows.push(fact("RMS", format!("{rms:.1} dB"))); } if let Some(lufs) = analysis.lufs { rows.push(fact("LUFS", format!("{lufs:.1}"))); } if let Some(is_loop) = analysis.is_loop { rows.push(fact("Loop", if is_loop { "Yes" } else { "No" })); } body.with(Node::section("Metadata")).with(Node::Table { columns: vec![Column::new("Field"), Column::new("Value")], rows: rows .into_iter() .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(value)])) .collect(), // Nine fields at most, and every one that was found is here. more: None, }) } /// One label and one value. fn fact(field: &str, value: impl Into) -> (String, String) { (field.to_owned(), value.into()) } /// What it is tagged with, where each tag came from, and what may be added. /// /// The provenance is a [`Tone`] on the token rather than a second collapsed /// section listing the same tags again. The shipped panel has both — chips at /// the top, a "Tag sources" fold underneath repeating every tag with a coloured /// word beside it — and the fold exists because a chip had nowhere to carry the /// fact. A token does: it has a tone, and the tone is what the fold was /// colouring anyway. fn tags(body: Slot, sample: &Detailed) -> Slot { let mut body = body.with(Node::section("Tags")); if sample.tags.is_empty() { body = body.with(Node::text("No tags")); } else { for tagged in &sample.tags { body = body.with(Node::Token(Tag { kind: quasi_router::layout::Token::Chip { removable: true }, label: format!("{} ({})", tagged.name, tagged.source.as_str()), tone: tone_of(&tagged.source), latched: false, action: Some(Action::post(format!("/detail/tags/{}/remove", tagged.name))), })); } } body = body.with(Node::Form { fields: vec![Field::new(FieldKind::Text, TAG, "Add tag").hint("Use dots: genre.house")], submit: "Add".to_owned(), action: Action::post("/detail/tags"), }); body = body.with(Node::Act(Act::new( "Suggest similar tags", Action::post("/detail/tags/suggest"), ))); for suggestion in &sample.suggestions { body = body.with(Node::Act(Act::new( offer(suggestion), Action::post(format!("/detail/tags/{}/accept", suggestion.tag)), ))); } body } /// A suggestion, as the control that takes it reads. /// /// The score and the neighbour count are in the label rather than in a hover, /// because a hover is a pointer affordance and the description has readers with /// no pointer. The shipped panel puts the count in `on_hover_text`, which a /// terminal renderer would have lost. fn offer(suggestion: &Suggested) -> String { format!( "Add {} ({:.0}%, on {} similar)", suggestion.tag, suggestion.score * 100.0, suggestion.neighbours, ) } /// What provenance reads as. /// /// Four sources onto three tones, which is a narrowing the shipped panel does /// not do: it gives each source its own palette entry, including two of the /// categorical colours, which are for telling series apart rather than for /// meaning anything. A tone says what a thing *is*, so a tag the app derived and /// a tag a rule matched are both "the app did this" and a hand-typed one is an /// ordinary fact. fn tone_of(source: &Source) -> Tone { match source { Source::Manual => Tone::Neutral, Source::Rule | Source::Folder => Tone::Info, Source::Suggested | Source::Cluster | Source::Other(_) => Tone::Warning, } } /// What can be done to the sample. fn actions(body: Slot, sample: &Detailed) -> Slot { let mut body = body.with(Node::section("Actions")); // Present whether or not there is a path, and dead when there is not. This // used to be hidden when `path` was `None`, which is the shape [`discovery`] // argues against four functions below: a control that vanishes when its // prerequisite is missing teaches nothing. The shipped panel draws it // always and does nothing when pressed with no path, which teaches less // still, so neither side was saying what it meant. let mut copy = Act::new("Copy Path", Action::post("/detail/path/copy")); if sample.path.is_none() { copy = copy.disabled(); } body = body.with(Node::Act(copy)); if sample.is_sample { body = body .with(Node::Act( Act::new("Edit", Action::post("/detail/edit")).key("e"), )) .with(Node::Act( Act::new("Forge", Action::post("/detail/forge")).key("f"), )); } body } /// Finding related samples, and saying so when it cannot be done. /// /// Both controls are described whether or not they can run, which is the /// shipped panel's choice and the right one: a control that vanishes when its /// prerequisite is missing teaches nothing, and `add_enabled(false, ..)` with a /// disabled hover is what the panel does. See this module's header for what the /// description cannot yet carry across — the hover sentence, which is said as /// prose here because there is nowhere on the [`Act`] to put it. fn discovery(body: Slot, sample: &Detailed) -> Slot { if !sample.is_sample { return body; } let mut body = body.with(Node::section("Discovery")); let mut similar = Act::new("Find Similar", Action::post("/detail/similar")).key("shift+f"); if !sample.has_spectral { similar = similar.disabled(); } body = body.with(Node::Act(similar)); let mut duplicates = Act::new("Find Duplicates", Action::post("/detail/duplicates")).key("shift+d"); if !sample.has_fingerprint { duplicates = duplicates.disabled(); } body = body.with(Node::Act(duplicates)); // The preconditions, as prose beside the controls they are about. The // finding is that this belongs on the control. if !sample.has_spectral { body = body.with(Node::Notice { kind: Notice::Banner, tone: Tone::Info, text: SPECTRAL.to_owned(), }); } if !sample.has_fingerprint { body = body.with(Node::Notice { kind: Notice::Banner, tone: Tone::Info, text: FINGERPRINT.to_owned(), }); } body } /// Several samples: what they agree on, and what can be done to all of them. fn several(body: Slot, spread: &Spread) -> Slot { let heading = if spread.folders == 0 { format!("{} samples selected", spread.samples) } else { format!( "{} samples \u{b7} {} folders selected", spread.samples, spread.folders ) }; let mut body = body.with(Node::page(heading)); if spread.samples == 0 { return body.with(Node::empty("No sample metadata to summarize")); } body = agreed(body, spread); body = coverage(body, spread); // Three controls where the shipped panel has one. `draw_multi_summary` // offers "Edit as bulk" and the other two bulk operations are reached from // the file list's context menu, which is a place rather than a fact: what // they all act on is this selection, so this is where they are said. body.with(Node::Act(Act::new("Tag all", Action::get("/bulk/tag")))) .with(Node::Act(Act::new("Move all", Action::get("/bulk/move")))) .with(Node::Act(Act::new( "Rename all", Action::get("/bulk/rename"), ))) } /// What every chosen sample says, where they say the same thing. fn agreed(body: Slot, spread: &Spread) -> Slot { use quasi_router::{Cell, Cells, Column}; body.with(Node::section("In common")).with(Node::Table { columns: vec![Column::new("Field"), Column::new("Value")], rows: [ ("BPM", &spread.bpm), ("Key", &spread.musical_key), ("Duration", &spread.duration), ] .into_iter() .map(|(field, value)| Cells::new(vec![Cell::new(field), Cell::new(reads(value))])) .collect(), more: None, }) } /// What a shared field reads as. /// /// Three answers where the shipped panel has two strings, because it collapsed /// [`Shared::Varies`] and [`Shared::Absent`] onto "varies" and an em dash /// without either being a described fact. Saying which it is here is what lets a /// renderer draw them differently. fn reads(shared: &Shared) -> String { match shared { Shared::Same(value) => value.clone(), Shared::Varies => "varies".to_owned(), Shared::Absent => "\u{2014}".to_owned(), } } /// Every tag any of them carries, with what it would take to make it unanimous. /// /// A [`Node::List`] rather than a wrap of tokens with a context menu on each, /// which is what the shipped panel has. A right-click menu is a pointer /// affordance; [`Row::menu`](quasi_router::Row) is the described form of the same /// thing and every host answers it its own way. The partial-coverage count is in /// the row's own text rather than in a hover, for the reason a suggestion's /// score is: a reader with no pointer never sees a hover. fn coverage(body: Slot, spread: &Spread) -> Slot { if spread.tags.is_empty() { return body.with(Node::section("Tags")).with(Node::text("No tags")); } body.with(Node::section("Tags")) .with(Node::List { rows: spread .tags .iter() .map(|tag| row(tag, spread.samples)) .collect(), more: None, }) .with(Node::text( "Use a tag's menu to put it on the rest of the selection, or take it off all of them.", )) } /// One tag across the selection, with the two things that can be done to it. fn row(tag: &Coverage, samples: usize) -> quasi_router::Row { use quasi_router::Row; let full = tag.on == samples; let mut row = Row::new(&tag.name); if !full { row = row.meta(format!("{} of {}", tag.on, samples)); } let mut menu = Vec::new(); if !full { menu.push(Act::new( format!("Apply to remaining ({})", samples - tag.on), Action::post(format!("/detail/selection/tags/{}/spread", tag.name)), )); } menu.push( Act::new( if full { format!("Remove from all ({})", tag.on) } else { format!("Remove from {}", tag.on) }, Action::post(format!("/detail/selection/tags/{}/strip", tag.name)), ) .tone(Tone::Danger), ); row.menu = menu; row } /// A duration as the panel writes it. fn seconds(duration: f64) -> String { if duration < 60.0 { format!("{duration:.1}s") } else { #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a sample's length in minutes is small and positive" )] let minutes = (duration / 60.0) as u32; #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "the remainder is under sixty" )] let rest = (duration % 60.0) as u32; format!("{minutes}:{rest:02}") } }