max / audiofiles
2 files changed,
+273 insertions,
-144 deletions
| @@ -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