max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+793 insertions,
-9 deletions
| @@ -4242,7 +4242,7 @@ | |||
| 4242 | 4242 | ||
| 4243 | 4243 | [[package]] | |
| 4244 | 4244 | name = "quasi-immediate" | |
| 4245 | - | version = "0.1.0" | |
| 4245 | + | version = "0.2.0" | |
| 4246 | 4246 | dependencies = [ | |
| 4247 | 4247 | "docengine", | |
| 4248 | 4248 | "egui", |
| @@ -22,7 +22,7 @@ | |||
| 22 | 22 | # The described screens, behind audiofiles-browser's `quasi` feature. By git URL | |
| 23 | 23 | # with a version requirement, per the tree's rule for cross-repo deps. | |
| 24 | 24 | quasi-router = { git = "https://makenot.work/git/max/quasi.git", version = "0.2" } | |
| 25 | - | quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.1" } | |
| 25 | + | quasi-immediate = { git = "https://makenot.work/git/max/quasi.git", version = "0.2" } | |
| 26 | 26 | egui = { version = "0.35", default-features = false, features = ["default_fonts"] } | |
| 27 | 27 | egui_extras = { version = "0.35", default-features = false } | |
| 28 | 28 | eframe = { version = "0.35", default-features = false, features = ["default_fonts", "glow"] } |
| @@ -168,6 +168,19 @@ | |||
| 168 | 168 | // has neither this call nor the module it reaches. | |
| 169 | 169 | #[cfg(feature = "quasi")] | |
| 170 | 170 | crate::quasi::panel::draw_settings(ctx, state); | |
| 171 | + | // The described file list has no shipped window to share a toggle with | |
| 172 | + | // -- the real one is the main pane -- so Settings opens it too, which is | |
| 173 | + | // the only place a feature-gated affordance can go without adding one to | |
| 174 | + | // the shipped chrome. | |
| 175 | + | #[cfg(feature = "quasi")] | |
| 176 | + | { | |
| 177 | + | state.described.show_files = true; | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | #[cfg(feature = "quasi")] | |
| 182 | + | if state.described.show_files { | |
| 183 | + | crate::quasi::panel::draw_files(ctx, state); | |
| 171 | 184 | } | |
| 172 | 185 | ||
| 173 | 186 | // Sync panel overlay |
| @@ -31,6 +31,7 @@ | |||
| 31 | 31 | // a choice made here. | |
| 32 | 32 | #![allow(clippy::needless_pass_by_value)] | |
| 33 | 33 | ||
| 34 | + | pub mod files; | |
| 34 | 35 | pub mod panel; | |
| 35 | 36 | pub mod settings; | |
| 36 | 37 | pub mod sync; | |
| @@ -386,6 +387,164 @@ | |||
| 386 | 387 | fn queue_cap_change(&self, _cap_bytes: i64) {} | |
| 387 | 388 | } | |
| 388 | 389 | ||
| 390 | + | /// One sample, as the description needs to name it. | |
| 391 | + | #[derive(Debug, Clone, PartialEq)] | |
| 392 | + | pub struct Sample { | |
| 393 | + | /// The row's own id, which is what its addresses are built from. | |
| 394 | + | pub id: i64, | |
| 395 | + | /// What it is called. | |
| 396 | + | pub name: String, | |
| 397 | + | /// How long it runs, in seconds. | |
| 398 | + | pub duration: Option<f64>, | |
| 399 | + | /// Beats per minute, where analysis found some. | |
| 400 | + | pub bpm: Option<f64>, | |
| 401 | + | /// The musical key, where analysis found one. | |
| 402 | + | pub key: Option<String>, | |
| 403 | + | /// Peak level in dBFS. | |
| 404 | + | pub peak_db: Option<f64>, | |
| 405 | + | /// Whatever it is tagged with. | |
| 406 | + | pub tags: Vec<String>, | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | /// Which columns the file list is showing. | |
| 410 | + | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] | |
| 411 | + | pub struct ColumnsShown { | |
| 412 | + | /// Show the duration column. | |
| 413 | + | pub duration: bool, | |
| 414 | + | /// Show the tempo column. | |
| 415 | + | pub bpm: bool, | |
| 416 | + | /// Show the musical key column. | |
| 417 | + | pub key: bool, | |
| 418 | + | /// Show the peak level column. | |
| 419 | + | pub peak_db: bool, | |
| 420 | + | /// Show the tags column. | |
| 421 | + | pub tags: bool, | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | /// The sample list, as much of it as a described screen needs. | |
| 425 | + | /// | |
| 426 | + | /// The third narrow trait, and the first whose writes do **not** go through a | |
| 427 | + | /// handle the app already had: selecting a row and playing one are | |
| 428 | + | /// `&mut BrowserState`. See [`files`]'s header — those are recorded as intents | |
| 429 | + | /// and applied by the host after the frame, which is the app's own | |
| 430 | + | /// `pending_action` pattern rather than something invented for this. | |
| 431 | + | pub trait Files { | |
| 432 | + | /// The rows on screen now, already loaded and filtered by the app. | |
| 433 | + | fn samples(&self) -> Vec<Sample>; | |
| 434 | + | ||
| 435 | + | /// Which columns the user has switched on. | |
| 436 | + | fn columns(&self) -> ColumnsShown; | |
| 437 | + | ||
| 438 | + | /// The column in force, and whether it runs up. | |
| 439 | + | fn sort(&self) -> (String, bool); | |
| 440 | + | ||
| 441 | + | /// The row the app is pointing at. | |
| 442 | + | fn current(&self) -> Option<i64>; | |
| 443 | + | ||
| 444 | + | /// Select this row. | |
| 445 | + | fn open(&self, id: i64); | |
| 446 | + | ||
| 447 | + | /// Preview this row. | |
| 448 | + | fn play(&self, id: i64); | |
| 449 | + | ||
| 450 | + | /// Order by this column. | |
| 451 | + | fn sort_by(&self, column: &str); | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | /// What a described screen asked the app to do to itself. | |
| 455 | + | /// | |
| 456 | + | /// The frame boundary, made explicit. A route cannot hold `&mut BrowserState`, | |
| 457 | + | /// so a screen that acts on the app's own UI state records what was asked and | |
| 458 | + | /// the panel applies it afterwards. `SettingsUiState::pending_action` is the | |
| 459 | + | /// same pattern, already in this app and documented as "set by the UI, consumed | |
| 460 | + | /// by the app layer each frame". | |
| 461 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 462 | + | pub enum Intent { | |
| 463 | + | /// Select a row. | |
| 464 | + | Open(i64), | |
| 465 | + | /// Preview a row. | |
| 466 | + | Play(i64), | |
| 467 | + | /// Order by a column. | |
| 468 | + | SortBy(String), | |
| 469 | + | } | |
| 470 | + | ||
| 471 | + | /// The app's file list, as the narrow thing a described screen borrows. | |
| 472 | + | /// | |
| 473 | + | /// Reads come off `BrowserState` directly; writes are recorded rather than | |
| 474 | + | /// performed, because a route holds `&BrowserState` and selecting a row is | |
| 475 | + | /// `&mut`. See [`files`]'s header for why that is a frame boundary rather than a | |
| 476 | + | /// shortcoming. | |
| 477 | + | pub struct FromContents<'a> { | |
| 478 | + | /// What the app has loaded and filtered already. | |
| 479 | + | pub state: &'a crate::state::BrowserState, | |
| 480 | + | /// What the described screen asked for, applied after the frame. | |
| 481 | + | pub intents: &'a std::cell::RefCell<Vec<Intent>>, | |
| 482 | + | } | |
| 483 | + | ||
| 484 | + | impl Files for FromContents<'_> { | |
| 485 | + | fn samples(&self) -> Vec<Sample> { | |
| 486 | + | self.state | |
| 487 | + | .nav | |
| 488 | + | .contents | |
| 489 | + | .iter() | |
| 490 | + | .map(|node| Sample { | |
| 491 | + | id: node.node.id.as_i64(), | |
| 492 | + | name: node.node.name.clone(), | |
| 493 | + | duration: node.duration, | |
| 494 | + | bpm: node.bpm, | |
| 495 | + | key: node.musical_key.clone(), | |
| 496 | + | peak_db: node.peak_db, | |
| 497 | + | tags: node.tags.clone(), | |
| 498 | + | }) | |
| 499 | + | .collect() | |
| 500 | + | } | |
| 501 | + | ||
| 502 | + | fn columns(&self) -> ColumnsShown { | |
| 503 | + | let shown = &self.state.column_config; | |
| 504 | + | ColumnsShown { | |
| 505 | + | duration: shown.show_duration, | |
| 506 | + | bpm: shown.show_bpm, | |
| 507 | + | key: shown.show_key, | |
| 508 | + | peak_db: shown.show_peak_db, | |
| 509 | + | tags: shown.show_tags, | |
| 510 | + | } | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | fn sort(&self) -> (String, bool) { | |
| 514 | + | let by = match self.state.nav.sort_column { | |
| 515 | + | crate::state::SortColumn::Name => "Name", | |
| 516 | + | crate::state::SortColumn::Bpm => "BPM", | |
| 517 | + | crate::state::SortColumn::Key => "Key", | |
| 518 | + | crate::state::SortColumn::Duration => "Duration", | |
| 519 | + | }; | |
| 520 | + | ( | |
| 521 | + | by.to_owned(), | |
| 522 | + | matches!( | |
| 523 | + | self.state.nav.sort_direction, | |
| 524 | + | crate::state::SortDirection::Ascending | |
| 525 | + | ), | |
| 526 | + | ) | |
| 527 | + | } | |
| 528 | + | ||
| 529 | + | fn current(&self) -> Option<i64> { | |
| 530 | + | self.state.selected_node().map(|node| node.node.id.as_i64()) | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | fn open(&self, id: i64) { | |
| 534 | + | self.intents.borrow_mut().push(Intent::Open(id)); | |
| 535 | + | } | |
| 536 | + | ||
| 537 | + | fn play(&self, id: i64) { | |
| 538 | + | self.intents.borrow_mut().push(Intent::Play(id)); | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | fn sort_by(&self, column: &str) { | |
| 542 | + | self.intents | |
| 543 | + | .borrow_mut() | |
| 544 | + | .push(Intent::SortBy(column.to_owned())); | |
| 545 | + | } | |
| 546 | + | } | |
| 547 | + | ||
| 389 | 548 | /// A theme the host resolved, as the description needs to name it. | |
| 390 | 549 | /// | |
| 391 | 550 | /// Three strings rather than the app's own `ThemeMeta`, so the described screen | |
| @@ -413,6 +572,8 @@ | |||
| 413 | 572 | pub config: &'a dyn Config, | |
| 414 | 573 | /// Cloud sync, for the sync screen. | |
| 415 | 574 | pub sync: &'a dyn Sync, | |
| 575 | + | /// The sample list, for the files screen. | |
| 576 | + | pub files: &'a dyn Files, | |
| 416 | 577 | /// The themes on offer, resolved by the host at startup. | |
| 417 | 578 | pub themes: &'a [ThemeChoice], | |
| 418 | 579 | } | |
| @@ -423,7 +584,7 @@ | |||
| 423 | 584 | /// cost is nothing, and building it fresh is what lets the state borrow. | |
| 424 | 585 | #[must_use] | |
| 425 | 586 | pub fn router<'a>() -> Router<Panels<'a>> { | |
| 426 | - | sync::routes(settings::routes(Router::new())) | |
| 587 | + | files::routes(sync::routes(settings::routes(Router::new()))) | |
| 427 | 588 | } | |
| 428 | 589 | ||
| 429 | 590 | #[cfg(test)] |
| @@ -30,7 +30,11 @@ | |||
| 30 | 30 | use quasi_immediate::{Immediate, Runtime, Step}; | |
| 31 | 31 | use quasi_router::{Request, Response, Screen}; | |
| 32 | 32 | ||
| 33 | - | use super::{FromBackend, FromSyncManager, Panels, Sync, ThemeChoice, Unconfigured}; | |
| 33 | + | use std::cell::RefCell; | |
| 34 | + | ||
| 35 | + | use super::{ | |
| 36 | + | FromBackend, FromContents, FromSyncManager, Intent, Panels, Sync, ThemeChoice, Unconfigured, | |
| 37 | + | }; | |
| 34 | 38 | use crate::state::BrowserState; | |
| 35 | 39 | use crate::ui::theme; | |
| 36 | 40 | ||
| @@ -43,10 +47,18 @@ | |||
| 43 | 47 | pub struct Described { | |
| 44 | 48 | settings: Option<Runtime>, | |
| 45 | 49 | sync: Option<Runtime>, | |
| 50 | + | files: Option<Runtime>, | |
| 51 | + | /// Whether the described file list is open. | |
| 52 | + | /// | |
| 53 | + | /// Its own flag rather than the shipped list's, because the shipped list is | |
| 54 | + | /// always showing: it is the app's main pane and not a window. So this is | |
| 55 | + | /// the one described screen with no toggle to share, and it gets its own. | |
| 56 | + | pub show_files: bool, | |
| 46 | 57 | } | |
| 47 | 58 | ||
| 48 | 59 | /// Draw the described settings window, and act on whatever was pressed. | |
| 49 | 60 | pub fn draw_settings(ctx: &egui::Context, state: &mut BrowserState) { | |
| 61 | + | let intents = RefCell::new(Vec::new()); | |
| 50 | 62 | let mut runtime = state.described.settings.take(); | |
| 51 | 63 | let closed = window( | |
| 52 | 64 | ctx, | |
| @@ -54,9 +66,11 @@ | |||
| 54 | 66 | &mut runtime, | |
| 55 | 67 | state, | |
| 56 | 68 | None, | |
| 69 | + | &intents, | |
| 57 | 70 | "/settings", | |
| 58 | 71 | ); | |
| 59 | 72 | state.described.settings = runtime; | |
| 73 | + | apply(state, intents.into_inner()); | |
| 60 | 74 | if closed { | |
| 61 | 75 | state.settings.show_manager = false; | |
| 62 | 76 | state.described.settings = None; | |
| @@ -69,6 +83,7 @@ | |||
| 69 | 83 | /// panel answers with a whole second window. Here it is [`Unconfigured`], which | |
| 70 | 84 | /// reports disconnected and refuses to connect. | |
| 71 | 85 | pub fn draw_sync(ctx: &egui::Context, state: &mut BrowserState, sync: Option<&SyncManager>) { | |
| 86 | + | let intents = RefCell::new(Vec::new()); | |
| 72 | 87 | let mut runtime = state.described.sync.take(); | |
| 73 | 88 | let closed = window( | |
| 74 | 89 | ctx, | |
| @@ -76,15 +91,94 @@ | |||
| 76 | 91 | &mut runtime, | |
| 77 | 92 | state, | |
| 78 | 93 | sync, | |
| 94 | + | &intents, | |
| 79 | 95 | "/sync", | |
| 80 | 96 | ); | |
| 81 | 97 | state.described.sync = runtime; | |
| 98 | + | apply(state, intents.into_inner()); | |
| 82 | 99 | if closed { | |
| 83 | 100 | state.sync.show_panel = false; | |
| 84 | 101 | state.described.sync = None; | |
| 85 | 102 | } | |
| 86 | 103 | } | |
| 87 | 104 | ||
| 105 | + | /// Draw the described file list, and act on whatever was pressed. | |
| 106 | + | pub fn draw_files(ctx: &egui::Context, state: &mut BrowserState) { | |
| 107 | + | let intents = RefCell::new(Vec::new()); | |
| 108 | + | let mut runtime = state.described.files.take(); | |
| 109 | + | let closed = window( | |
| 110 | + | ctx, | |
| 111 | + | "Samples (described)", | |
| 112 | + | &mut runtime, | |
| 113 | + | state, | |
| 114 | + | None, | |
| 115 | + | &intents, | |
| 116 | + | "/files", | |
| 117 | + | ); | |
| 118 | + | state.described.files = runtime; | |
| 119 | + | apply(state, intents.into_inner()); | |
| 120 | + | if closed { | |
| 121 | + | state.described.show_files = false; | |
| 122 | + | state.described.files = None; | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// Do what a described screen asked the app to do to itself. | |
| 127 | + | /// | |
| 128 | + | /// **The frame boundary.** A route holds `&BrowserState` and cannot select a | |
| 129 | + | /// row, so it records an [`Intent`] and this runs afterwards, with the `&mut` | |
| 130 | + | /// the app has anyway. `SettingsUiState::pending_action` is the same pattern | |
| 131 | + | /// already in this app. | |
| 132 | + | /// | |
| 133 | + | /// Each arm calls what the shipped list calls, rather than reaching into the | |
| 134 | + | /// fields itself: a described screen that set `nav.selection` by hand would be a | |
| 135 | + | /// second implementation of selection, which is what the port is for avoiding. | |
| 136 | + | fn apply(state: &mut BrowserState, intents: Vec<Intent>) { | |
| 137 | + | for intent in intents { | |
| 138 | + | match intent { | |
| 139 | + | Intent::Open(id) => { | |
| 140 | + | if let Some(at) = index_of(state, id) { | |
| 141 | + | state.nav.selection.set_single(at); | |
| 142 | + | state.refresh_selected_tags(); | |
| 143 | + | state.refresh_selected_detail(); | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | Intent::Play(id) => { | |
| 147 | + | if let Some(at) = index_of(state, id) { | |
| 148 | + | state.nav.selection.set_single(at); | |
| 149 | + | state.autoplay_current(); | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | Intent::SortBy(column) => { | |
| 153 | + | let key = match column.as_str() { | |
| 154 | + | "Name" => crate::state::SortColumn::Name, | |
| 155 | + | "BPM" => crate::state::SortColumn::Bpm, | |
| 156 | + | "Key" => crate::state::SortColumn::Key, | |
| 157 | + | "Duration" => crate::state::SortColumn::Duration, | |
| 158 | + | // The route already refused anything else, so this is a | |
| 159 | + | // column the app grew and the description has not learned. | |
| 160 | + | _ => continue, | |
| 161 | + | }; | |
| 162 | + | state.toggle_sort(key); | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | /// Where a sample sits in what is on screen. | |
| 169 | + | /// | |
| 170 | + | /// The described screen addresses a row by its own id and the app selects by | |
| 171 | + | /// index, so one of them has to translate. Here rather than in the description: | |
| 172 | + | /// an index is a fact about the current filter and sort, which is exactly the | |
| 173 | + | /// kind of thing an address should not be. | |
| 174 | + | fn index_of(state: &BrowserState, id: i64) -> Option<usize> { | |
| 175 | + | state | |
| 176 | + | .nav | |
| 177 | + | .contents | |
| 178 | + | .iter() | |
| 179 | + | .position(|node| node.node.id.as_i64() == id) | |
| 180 | + | } | |
| 181 | + | ||
| 88 | 182 | /// One described window: draw it, act on it, and say whether it was closed. | |
| 89 | 183 | fn window( | |
| 90 | 184 | ctx: &egui::Context, | |
| @@ -92,6 +186,7 @@ | |||
| 92 | 186 | runtime: &mut Option<Runtime>, | |
| 93 | 187 | state: &BrowserState, | |
| 94 | 188 | sync: Option<&SyncManager>, | |
| 189 | + | intents: &RefCell<Vec<Intent>>, | |
| 95 | 190 | home: &str, | |
| 96 | 191 | ) -> bool { | |
| 97 | 192 | let themes = themes(); | |
| @@ -107,7 +202,7 @@ | |||
| 107 | 202 | // after it is the loop below. | |
| 108 | 203 | let runtime = match runtime { | |
| 109 | 204 | Some(runtime) => runtime, | |
| 110 | - | none => match answer(state, sync, &themes, Request::get(home)) { | |
| 205 | + | none => match answer(state, sync, &themes, intents, Request::get(home)) { | |
| 111 | 206 | Ok(screen) => none.insert(Runtime::new(screen)), | |
| 112 | 207 | Err(message) => { | |
| 113 | 208 | ui.label(message); | |
| @@ -117,7 +212,7 @@ | |||
| 117 | 212 | }; | |
| 118 | 213 | ||
| 119 | 214 | let step = runtime.show(ui, &immediate); | |
| 120 | - | perform(runtime, ui, state, sync, &themes, step); | |
| 215 | + | perform(runtime, ui, state, sync, &themes, intents, step); | |
| 121 | 216 | }); | |
| 122 | 217 | ||
| 123 | 218 | !open | |
| @@ -130,11 +225,12 @@ | |||
| 130 | 225 | state: &BrowserState, | |
| 131 | 226 | sync: Option<&SyncManager>, | |
| 132 | 227 | themes: &[ThemeChoice], | |
| 228 | + | intents: &RefCell<Vec<Intent>>, | |
| 133 | 229 | step: Step, | |
| 134 | 230 | ) { | |
| 135 | 231 | match step { | |
| 136 | 232 | Step::Idle => {} | |
| 137 | - | Step::Call(request) => match answer(state, sync, themes, request.clone()) { | |
| 233 | + | Step::Call(request) => match answer(state, sync, themes, intents, request.clone()) { | |
| 138 | 234 | Ok(screen) => { | |
| 139 | 235 | runtime.apply(&request, Response::screen(screen)); | |
| 140 | 236 | } | |
| @@ -147,7 +243,7 @@ | |||
| 147 | 243 | ui.horizontal(|ui| { | |
| 148 | 244 | if ui.button("Yes").clicked() { | |
| 149 | 245 | let next = runtime.answer(true); | |
| 150 | - | perform(runtime, ui, state, sync, themes, next); | |
| 246 | + | perform(runtime, ui, state, sync, themes, intents, next); | |
| 151 | 247 | } | |
| 152 | 248 | if ui.button("No").clicked() { | |
| 153 | 249 | runtime.answer(false); | |
| @@ -186,6 +282,7 @@ | |||
| 186 | 282 | state: &BrowserState, | |
| 187 | 283 | sync: Option<&SyncManager>, | |
| 188 | 284 | themes: &[ThemeChoice], | |
| 285 | + | intents: &RefCell<Vec<Intent>>, | |
| 189 | 286 | request: Request, | |
| 190 | 287 | ) -> Result<Screen, String> { | |
| 191 | 288 | let config = FromBackend(&*state.backend); | |
| @@ -196,9 +293,11 @@ | |||
| 196 | 293 | None => &unconfigured, | |
| 197 | 294 | }; | |
| 198 | 295 | ||
| 296 | + | let files = FromContents { state, intents }; | |
| 199 | 297 | let panels = Panels { | |
| 200 | 298 | config: &config, | |
| 201 | 299 | sync, | |
| 300 | + | files: &files, | |
| 202 | 301 | themes, | |
| 203 | 302 | }; | |
| 204 | 303 | let response = super::router() |
| @@ -11,7 +11,10 @@ | |||
| 11 | 11 | use audiofiles_core::config_key::ConfigKey; | |
| 12 | 12 | use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen}; | |
| 13 | 13 | ||
| 14 | - | use super::{Config, Panels, Pricing, State, Status, Subscription, Sync, ThemeChoice, router}; | |
| 14 | + | use super::{ | |
| 15 | + | ColumnsShown, Config, Files, Panels, Pricing, Sample, State, Status, Subscription, Sync, | |
| 16 | + | ThemeChoice, router, | |
| 17 | + | }; | |
| 15 | 18 | ||
| 16 | 19 | /// A config store in memory. | |
| 17 | 20 | /// | |
| @@ -53,6 +56,107 @@ | |||
| 53 | 56 | } | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 59 | + | /// A file list in memory, recording what was asked of it. | |
| 60 | + | /// | |
| 61 | + | /// The reads are plain data and the writes are recorded, which is the shape the | |
| 62 | + | /// real adapter has for a reason the fixture makes visible: selecting a row is | |
| 63 | + | /// `&mut BrowserState`, so a route can only ask. | |
| 64 | + | #[derive(Default)] | |
| 65 | + | struct FakeFiles { | |
| 66 | + | samples: Vec<Sample>, | |
| 67 | + | shown: ColumnsShown, | |
| 68 | + | by: String, | |
| 69 | + | ascending: bool, | |
| 70 | + | current: Option<i64>, | |
| 71 | + | asked: RefCell<Vec<String>>, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | impl FakeFiles { | |
| 75 | + | fn with(samples: Vec<Sample>) -> Self { | |
| 76 | + | Self { | |
| 77 | + | samples, | |
| 78 | + | shown: ColumnsShown { | |
| 79 | + | duration: true, | |
| 80 | + | bpm: true, | |
| 81 | + | key: true, | |
| 82 | + | peak_db: false, | |
| 83 | + | tags: true, | |
| 84 | + | }, | |
| 85 | + | by: "Name".to_owned(), | |
| 86 | + | ascending: true, | |
| 87 | + | ..Self::default() | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | fn asked(&self) -> Vec<String> { | |
| 92 | + | self.asked.borrow().clone() | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | impl Files for FakeFiles { | |
| 97 | + | fn samples(&self) -> Vec<Sample> { | |
| 98 | + | self.samples.clone() | |
| 99 | + | } | |
| 100 | + | fn columns(&self) -> ColumnsShown { | |
| 101 | + | self.shown | |
| 102 | + | } | |
| 103 | + | fn sort(&self) -> (String, bool) { | |
| 104 | + | (self.by.clone(), self.ascending) | |
| 105 | + | } | |
| 106 | + | fn current(&self) -> Option<i64> { | |
| 107 | + | self.current | |
| 108 | + | } | |
| 109 | + | fn open(&self, id: i64) { | |
| 110 | + | self.asked.borrow_mut().push(format!("open:{id}")); | |
| 111 | + | } | |
| 112 | + | fn play(&self, id: i64) { | |
| 113 | + | self.asked.borrow_mut().push(format!("play:{id}")); | |
| 114 | + | } | |
| 115 | + | fn sort_by(&self, column: &str) { | |
| 116 | + | self.asked.borrow_mut().push(format!("sort:{column}")); | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | /// A sample with the fields a row names. | |
| 121 | + | fn sample(id: i64, name: &str) -> Sample { | |
| 122 | + | Sample { | |
| 123 | + | id, | |
| 124 | + | name: name.to_owned(), | |
| 125 | + | duration: Some(1.5), | |
| 126 | + | bpm: Some(120.0), | |
| 127 | + | key: Some("Am".to_owned()), | |
| 128 | + | peak_db: Some(-3.2), | |
| 129 | + | tags: vec!["drums".to_owned(), "loop".to_owned()], | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// A router call against this file list. | |
| 134 | + | fn listing(files: &FakeFiles, request: Request) -> Result<Response, quasi_router::RouteError> { | |
| 135 | + | let store = Store::default(); | |
| 136 | + | let sync = Offline; | |
| 137 | + | let themes = themes(); | |
| 138 | + | let state = Panels { | |
| 139 | + | config: &store, | |
| 140 | + | sync: &sync, | |
| 141 | + | files, | |
| 142 | + | themes: &themes, | |
| 143 | + | }; | |
| 144 | + | router().handle(&state, request) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// The table on a screen. | |
| 148 | + | fn table_of(screen: &Screen) -> (Vec<quasi_router::Column>, Vec<quasi_router::Cells>) { | |
| 149 | + | screen | |
| 150 | + | .slots | |
| 151 | + | .iter() | |
| 152 | + | .flat_map(|slot| &slot.body) | |
| 153 | + | .find_map(|node| match node { | |
| 154 | + | Node::Table { columns, rows } => Some((columns.clone(), rows.clone())), | |
| 155 | + | _ => None, | |
| 156 | + | }) | |
| 157 | + | .expect("the screen draws a table") | |
| 158 | + | } | |
| 159 | + | ||
| 56 | 160 | /// Sync that reports nothing and does nothing. | |
| 57 | 161 | /// | |
| 58 | 162 | /// The settings tests do not touch it, and it is here because `Panels` is one | |
| @@ -139,9 +243,11 @@ | |||
| 139 | 243 | let store = Store::default(); | |
| 140 | 244 | let themes = themes(); | |
| 141 | 245 | let sync = Offline; | |
| 246 | + | let files = FakeFiles::default(); | |
| 142 | 247 | let state = Panels { | |
| 143 | 248 | config: &store, | |
| 144 | 249 | sync: &sync, | |
| 250 | + | files: &files, | |
| 145 | 251 | themes: &themes, | |
| 146 | 252 | }; | |
| 147 | 253 | let response = router() | |
| @@ -181,9 +287,11 @@ | |||
| 181 | 287 | let store = Store::with(&[(ConfigKey::PreviewLoop, "1")]); | |
| 182 | 288 | let themes = themes(); | |
| 183 | 289 | let sync = Offline; | |
| 290 | + | let files = FakeFiles::default(); | |
| 184 | 291 | let state = Panels { | |
| 185 | 292 | config: &store, | |
| 186 | 293 | sync: &sync, | |
| 294 | + | files: &files, | |
| 187 | 295 | themes: &themes, | |
| 188 | 296 | }; | |
| 189 | 297 | ||
| @@ -217,9 +325,11 @@ | |||
| 217 | 325 | let store = Store::default(); | |
| 218 | 326 | let themes = themes(); | |
| 219 | 327 | let sync = Offline; | |
| 328 | + | let files = FakeFiles::default(); | |
| 220 | 329 | let state = Panels { | |
| 221 | 330 | config: &store, | |
| 222 | 331 | sync: &sync, | |
| 332 | + | files: &files, | |
| 223 | 333 | themes: &themes, | |
| 224 | 334 | }; | |
| 225 | 335 | let refused = router().handle( | |
| @@ -239,9 +349,11 @@ | |||
| 239 | 349 | let store = Store::default(); | |
| 240 | 350 | let themes = themes(); | |
| 241 | 351 | let sync = Offline; | |
| 352 | + | let files = FakeFiles::default(); | |
| 242 | 353 | let state = Panels { | |
| 243 | 354 | config: &store, | |
| 244 | 355 | sync: &sync, | |
| 356 | + | files: &files, | |
| 245 | 357 | themes: &themes, | |
| 246 | 358 | }; | |
| 247 | 359 | ||
| @@ -284,9 +396,11 @@ | |||
| 284 | 396 | let store = Store::with(&[(ConfigKey::Theme, "nord")]); | |
| 285 | 397 | let themes = themes(); | |
| 286 | 398 | let sync = Offline; | |
| 399 | + | let files = FakeFiles::default(); | |
| 287 | 400 | let state = Panels { | |
| 288 | 401 | config: &store, | |
| 289 | 402 | sync: &sync, | |
| 403 | + | files: &files, | |
| 290 | 404 | themes: &themes, | |
| 291 | 405 | }; | |
| 292 | 406 | let response = router() | |
| @@ -325,9 +439,11 @@ | |||
| 325 | 439 | let store = Store::with(&[(ConfigKey::ColumnConfig, "{\"show_bpm\":false}")]); | |
| 326 | 440 | let themes = themes(); | |
| 327 | 441 | let sync = Offline; | |
| 442 | + | let files = FakeFiles::default(); | |
| 328 | 443 | let state = Panels { | |
| 329 | 444 | config: &store, | |
| 330 | 445 | sync: &sync, | |
| 446 | + | files: &files, | |
| 331 | 447 | themes: &themes, | |
| 332 | 448 | }; | |
| 333 | 449 | let response = router() | |
| @@ -472,9 +588,11 @@ | |||
| 472 | 588 | fn syncing(sync: &FakeSync, request: Request) -> Result<Response, quasi_router::RouteError> { | |
| 473 | 589 | let store = Store::default(); | |
| 474 | 590 | let themes = themes(); | |
| 591 | + | let files = FakeFiles::default(); | |
| 475 | 592 | let state = Panels { | |
| 476 | 593 | config: &store, | |
| 477 | 594 | sync, | |
| 595 | + | files: &files, | |
| 478 | 596 | themes: &themes, | |
| 479 | 597 | }; | |
| 480 | 598 | router().handle(&state, request) | |
| @@ -855,3 +973,120 @@ | |||
| 855 | 973 | // 20 GiB at the fixture's dollar-a-gibibyte, monthly. | |
| 856 | 974 | assert!(hint.contains("$20"), "{hint}"); | |
| 857 | 975 | } | |
| 976 | + | ||
| 977 | + | #[test] | |
| 978 | + | fn the_file_list_describes_a_column_per_shown_flag() { | |
| 979 | + | // The columns were described before this port existed: | |
| 980 | + | // `ui::file_list::describe` already built `makeover_layout::Column`s. What | |
| 981 | + | // the port adds is the address a heading calls. | |
| 982 | + | let files = FakeFiles::with(vec![sample(1, "kick.wav")]); | |
| 983 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 984 | + | let (columns, rows) = table_of(screen_of(&response)); | |
| 985 | + | ||
| 986 | + | let names: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect(); | |
| 987 | + | // Peak is off in the fixture, so it is not described at all. | |
| 988 | + | assert_eq!(names, ["Name", "Duration", "BPM", "Key", "Tags", "Play"]); | |
| 989 | + | assert_eq!(rows.len(), 1); | |
| 990 | + | assert_eq!(rows[0].values.len(), columns.len(), "a cell per column"); | |
| 991 | + | } | |
| 992 | + | ||
| 993 | + | #[test] | |
| 994 | + | fn only_the_columns_with_a_sort_carry_an_address() { | |
| 995 | + | // Peak and Tags have no sort of their own and never had one, which is what | |
| 996 | + | // `Column::sortable` says when it is false: headings rather than controls. | |
| 997 | + | let files = FakeFiles::with(vec![sample(1, "kick.wav")]); | |
| 998 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 999 | + | let (columns, _) = table_of(screen_of(&response)); | |
| 1000 | + | ||
| 1001 | + | for column in &columns { | |
| 1002 | + | let addressed = column.reorder.is_some(); | |
| 1003 | + | let expected = matches!(column.name.as_str(), "Name" | "Duration" | "BPM" | "Key"); | |
| 1004 | + | assert_eq!( | |
| 1005 | + | addressed, expected, | |
| 1006 | + | "{} carried the wrong address", | |
| 1007 | + | column.name | |
| 1008 | + | ); | |
| 1009 | + | } | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | #[test] | |
| 1013 | + | fn the_column_in_force_carries_its_caret_and_the_others_do_not() { | |
| 1014 | + | let mut files = FakeFiles::with(vec![sample(1, "kick.wav")]); | |
| 1015 | + | files.by = "BPM".to_owned(); | |
| 1016 | + | files.ascending = false; | |
| 1017 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 1018 | + | let (columns, _) = table_of(screen_of(&response)); | |
| 1019 | + | ||
| 1020 | + | for column in &columns { | |
| 1021 | + | let sorted = column.sorted; | |
| 1022 | + | if column.name == "BPM" { | |
| 1023 | + | assert_eq!(sorted, Some(quasi_router::layout::Sort::Descending)); | |
| 1024 | + | } else { | |
| 1025 | + | assert_eq!(sorted, None, "{} claimed a sort", column.name); | |
| 1026 | + | } | |
| 1027 | + | } | |
| 1028 | + | } | |
| 1029 | + | ||
| 1030 | + | #[test] | |
| 1031 | + | fn a_row_is_addressed_by_its_own_id_and_not_by_where_it_sits() { | |
| 1032 | + | // An index is a fact about the current filter and sort, which is exactly | |
| 1033 | + | // what an address should not be. | |
| 1034 | + | let files = FakeFiles::with(vec![sample(7, "kick.wav"), sample(9, "snare.wav")]); | |
| 1035 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 1036 | + | let (_, rows) = table_of(screen_of(&response)); | |
| 1037 | + | ||
| 1038 | + | let opens: Vec<String> = rows | |
| 1039 | + | .iter() | |
| 1040 | + | .filter_map(|row| row.activate.as_ref()) | |
| 1041 | + | .map(|action| action.destination.as_str().to_owned()) | |
| 1042 | + | .collect(); | |
| 1043 | + | assert_eq!(opens, ["/files/7/open", "/files/9/open"]); | |
| 1044 | + | } | |
| 1045 | + | ||
| 1046 | + | #[test] | |
| 1047 | + | fn pressing_a_row_and_a_heading_reaches_the_app() { | |
| 1048 | + | let files = FakeFiles::with(vec![sample(7, "kick.wav")]); | |
| 1049 | + | listing(&files, Request::post("/files/7/open")).expect("answered"); | |
| 1050 | + | listing(&files, Request::post("/files/7/play")).expect("answered"); | |
| 1051 | + | listing(&files, Request::post("/files/sort/BPM")).expect("answered"); | |
| 1052 | + | assert_eq!(files.asked(), ["open:7", "play:7", "sort:BPM"]); | |
| 1053 | + | } | |
| 1054 | + | ||
| 1055 | + | #[test] | |
| 1056 | + | fn a_column_with_no_sort_is_refused_rather_than_ordered_by() { | |
| 1057 | + | let files = FakeFiles::with(vec![sample(1, "kick.wav")]); | |
| 1058 | + | let refused = listing(&files, Request::post("/files/sort/Tags")); | |
| 1059 | + | assert!(refused.is_err(), "Tags was accepted as a sort"); | |
| 1060 | + | assert!(files.asked().is_empty()); | |
| 1061 | + | } | |
| 1062 | + | ||
| 1063 | + | #[test] | |
| 1064 | + | fn an_empty_list_says_so_and_offers_the_way_out() { | |
| 1065 | + | // `703f4cd2`: the sentence and the way out are both on the node, because a | |
| 1066 | + | // region with a heading and no rows still has content. | |
| 1067 | + | let files = FakeFiles::with(Vec::new()); | |
| 1068 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 1069 | + | let stand_in = screen_of(&response) | |
| 1070 | + | .slots | |
| 1071 | + | .iter() | |
| 1072 | + | .flat_map(|slot| &slot.body) | |
| 1073 | + | .find_map(|node| match node { | |
| 1074 | + | Node::StandIn { message, act, .. } => Some((message.clone(), act.clone())), | |
| 1075 | + | _ => None, | |
| 1076 | + | }) | |
| 1077 | + | .expect("an empty list says so"); | |
| 1078 | + | assert!(stand_in.0.contains("Nothing here")); | |
| 1079 | + | assert!(stand_in.1.is_some(), "the empty list offered no way out"); | |
| 1080 | + | } | |
| 1081 | + | ||
| 1082 | + | #[test] | |
| 1083 | + | fn the_row_the_app_is_pointing_at_is_the_current_one() { | |
| 1084 | + | let mut files = FakeFiles::with(vec![sample(7, "kick.wav"), sample(9, "snare.wav")]); | |
| 1085 | + | files.current = Some(9); | |
| 1086 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 1087 | + | let (_, rows) = table_of(screen_of(&response)); | |
| 1088 | + | assert_eq!( | |
| 1089 | + | rows.iter().map(|row| row.current).collect::<Vec<_>>(), | |
| 1090 | + | [false, true] | |
| 1091 | + | ); | |
| 1092 | + | } |
| @@ -1,0 +1,276 @@ | |||
| 1 | + | //! The file list, described rather than built. | |
| 2 | + | //! | |
| 3 | + | //! The app's main screen and the third port. It is the one that needed | |
| 4 | + | //! `quasi-immediate` to grow a table first, and the one where the port is | |
| 5 | + | //! *least* of a rewrite: `ui::file_list::describe` already builds | |
| 6 | + | //! `makeover_layout::Column`s and hands them to `makeover_immediate::table`, so | |
| 7 | + | //! the columns were described before this module existed. What was not described | |
| 8 | + | //! is everything below the header — the rows, and what pressing one calls. | |
| 9 | + | //! | |
| 10 | + | //! # What is different about this screen, and it is not the table | |
| 11 | + | //! | |
| 12 | + | //! Every port before this one wrote through a handle that already took `&self`: | |
| 13 | + | //! the config store, the sync manager. **The file list acts on the app's own | |
| 14 | + | //! in-memory UI state** — which row is selected, what is playing — and those are | |
| 15 | + | //! `&mut BrowserState`. | |
| 16 | + | //! | |
| 17 | + | //! A handler is `fn(&S, Request)`. So a described file list cannot select a row | |
| 18 | + | //! by reaching for the field, and the answer is not to make the state | |
| 19 | + | //! interior-mutable to suit a description. | |
| 20 | + | //! | |
| 21 | + | //! The answer is the app's own, already in the codebase: `SettingsUiState` has | |
| 22 | + | //! `pending_action: Option<VaultAction>`, documented as "set by the UI, consumed | |
| 23 | + | //! by the app layer each frame". [`Intents`] is that pattern for described | |
| 24 | + | //! screens. The route records what the user asked for, the panel applies it to | |
| 25 | + | //! `&mut BrowserState` after the frame is drawn, and nothing needs a lock. | |
| 26 | + | //! | |
| 27 | + | //! Worth stating as a rule for the next port that meets this: **a described | |
| 28 | + | //! screen writing to UI state records an intent; a described screen writing to | |
| 29 | + | //! the app's data calls through a handle.** The first is not a lesser kind of | |
| 30 | + | //! port, it is what a frame boundary looks like from the description's side. | |
| 31 | + | //! | |
| 32 | + | //! # What is deliberately not described | |
| 33 | + | //! | |
| 34 | + | //! - **Drag and drop out of the app.** `draw_file_list` carries a macOS/Windows | |
| 35 | + | //! drag-cooldown state machine so an OS drag that ends outside the window does | |
| 36 | + | //! not leave egui's pointer state stale. That is a host input problem and | |
| 37 | + | //! nothing about it is a fact about a sample. | |
| 38 | + | //! - **The waveform, the inline rename, the context menu.** Each is its own | |
| 39 | + | //! screen or its own affordance; folding them in here would make the port | |
| 40 | + | //! about size rather than about shape. | |
| 41 | + | //! - **Virtual scrolling.** Recorded in the findings note as renderer policy | |
| 42 | + | //! from the start: windowing rows the app already holds is a performance | |
| 43 | + | //! technique, not a described fact. | |
| 44 | + | ||
| 45 | + | use quasi_router::layout::{Priority, Sort, Width}; | |
| 46 | + | use quasi_router::{ | |
| 47 | + | Act, Action, Cell, Cells, Column, Node, RegionKind, Request, Response, RouteError, Router, | |
| 48 | + | Screen, Slot, Tag, | |
| 49 | + | }; | |
| 50 | + | ||
| 51 | + | use super::{Panels, Sample}; | |
| 52 | + | ||
| 53 | + | /// The region the screen answers into. | |
| 54 | + | const BODY: &str = "files-body"; | |
| 55 | + | ||
| 56 | + | /// The columns, by the name the sort routes know them by. | |
| 57 | + | const NAME: &str = "Name"; | |
| 58 | + | const DUR: &str = "Duration"; | |
| 59 | + | const BPM: &str = "BPM"; | |
| 60 | + | const KEY: &str = "Key"; | |
| 61 | + | const PEAK: &str = "Peak dB"; | |
| 62 | + | const TAGS: &str = "Tags"; | |
| 63 | + | const PLAY: &str = "Play"; | |
| 64 | + | ||
| 65 | + | /// Register this screen's routes. | |
| 66 | + | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 67 | + | router | |
| 68 | + | .get("/files", index) | |
| 69 | + | .post("/files/{id}/open", open) | |
| 70 | + | .post("/files/{id}/play", play) | |
| 71 | + | .post("/files/sort/{column}", sort) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /// `GET /files` | |
| 75 | + | fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 76 | + | Ok(screen(state).into()) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | /// `POST /files/{id}/open` | |
| 80 | + | fn open(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 81 | + | let id = id_of(&request)?; | |
| 82 | + | state.files.open(id); | |
| 83 | + | Ok(screen(state).into()) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// `POST /files/{id}/play` | |
| 87 | + | fn play(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 88 | + | let id = id_of(&request)?; | |
| 89 | + | state.files.play(id); | |
| 90 | + | Ok(screen(state).into()) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// `POST /files/sort/{column}` | |
| 94 | + | /// | |
| 95 | + | /// The heading a user pressed. Which way it then sorts is the app's: pressing | |
| 96 | + | /// the column already in force reverses it, and the description says only which | |
| 97 | + | /// column was named. | |
| 98 | + | fn sort(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 99 | + | let column = request.captures.require("column")?; | |
| 100 | + | if !sortable(column) { | |
| 101 | + | return Err(RouteError::not_found("no such sort")); | |
| 102 | + | } | |
| 103 | + | state.files.sort_by(column); | |
| 104 | + | Ok(screen(state).into()) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// The row a request names. | |
| 108 | + | fn id_of(request: &Request) -> Result<i64, RouteError> { | |
| 109 | + | request | |
| 110 | + | .captures | |
| 111 | + | .require("id")? | |
| 112 | + | .parse() | |
| 113 | + | .map_err(|_| RouteError::not_found("no such sample")) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// Whether a column can be ordered by. | |
| 117 | + | /// | |
| 118 | + | /// Peak and Tags have no sort of their own and never had one, which is what | |
| 119 | + | /// `Column::sortable` says when it is false: they are headings rather than | |
| 120 | + | /// controls. | |
| 121 | + | fn sortable(column: &str) -> bool { | |
| 122 | + | matches!(column, NAME | DUR | BPM | KEY) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | /// The whole screen. | |
| 126 | + | fn screen(state: &Panels<'_>) -> Screen { | |
| 127 | + | let shown = state.files.columns(); | |
| 128 | + | let samples = state.files.samples(); | |
| 129 | + | let current = state.files.current(); | |
| 130 | + | ||
| 131 | + | let body = if samples.is_empty() { | |
| 132 | + | // The sentence and the way out are both on the node rather than on the | |
| 133 | + | // region: `703f4cd2` settled that a region with a heading and no rows | |
| 134 | + | // still has content, so emptiness belongs to the thing that is empty. | |
| 135 | + | Slot::new(BODY, RegionKind::Pane).with( | |
| 136 | + | Node::empty("Nothing here yet.") | |
| 137 | + | .offering(Act::new("Import samples", Action::post("/files/import"))), | |
| 138 | + | ) | |
| 139 | + | } else { | |
| 140 | + | Slot::new(BODY, RegionKind::Pane).with(Node::Table { | |
| 141 | + | columns: columns(state, shown), | |
| 142 | + | rows: samples | |
| 143 | + | .iter() | |
| 144 | + | .map(|sample| row(sample, shown, current)) | |
| 145 | + | .collect(), | |
| 146 | + | }) | |
| 147 | + | }; | |
| 148 | + | ||
| 149 | + | Screen::sidebar_content("Samples").with(body) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | /// The columns, in the order the shipped list puts them. | |
| 153 | + | /// | |
| 154 | + | /// Nearly a copy of `ui::file_list::describe`, and that is the point: it already | |
| 155 | + | /// built `makeover_layout::Column`s. What is added is the one thing that file | |
| 156 | + | /// could not say — the address a heading calls — which is | |
| 157 | + | /// [`Column::reorder`] and is quasi's rather than the vocabulary's. | |
| 158 | + | fn columns(state: &Panels<'_>, shown: super::ColumnsShown) -> Vec<Column> { | |
| 159 | + | let (by, ascending) = state.files.sort(); | |
| 160 | + | let sorted_by = |name: &str| { | |
| 161 | + | (name == by).then_some(if ascending { | |
| 162 | + | Sort::Ascending | |
| 163 | + | } else { | |
| 164 | + | Sort::Descending | |
| 165 | + | }) | |
| 166 | + | }; | |
| 167 | + | let data = |name: &'static str, priority: Priority| { | |
| 168 | + | let mut column = Column::new(name) | |
| 169 | + | .width(if name == NAME { | |
| 170 | + | Width::Fill | |
| 171 | + | } else { | |
| 172 | + | Width::Fixed | |
| 173 | + | }) | |
| 174 | + | .priority(priority); | |
| 175 | + | column.sorted = sorted_by(name); | |
| 176 | + | if sortable(name) { | |
| 177 | + | column = column.reorder(Action::post(format!("/files/sort/{name}"))); | |
| 178 | + | } | |
| 179 | + | column | |
| 180 | + | }; | |
| 181 | + | ||
| 182 | + | let mut columns = vec![data(NAME, Priority::Essential)]; | |
| 183 | + | if shown.duration { | |
| 184 | + | columns.push(data(DUR, Priority::Secondary)); | |
| 185 | + | } | |
| 186 | + | if shown.bpm { | |
| 187 | + | columns.push(data(BPM, Priority::Secondary)); | |
| 188 | + | } | |
| 189 | + | if shown.key { | |
| 190 | + | columns.push(data(KEY, Priority::Secondary)); | |
| 191 | + | } | |
| 192 | + | if shown.peak_db { | |
| 193 | + | columns.push(data(PEAK, Priority::Optional)); | |
| 194 | + | } | |
| 195 | + | if shown.tags { | |
| 196 | + | columns.push(data(TAGS, Priority::Optional)); | |
| 197 | + | } | |
| 198 | + | // The play control is essential, because a list of samples you cannot hear | |
| 199 | + | // is a list of filenames. | |
| 200 | + | columns.push(data(PLAY, Priority::Essential)); | |
| 201 | + | columns | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | /// One sample as a row. | |
| 205 | + | fn row(sample: &Sample, shown: super::ColumnsShown, current: Option<i64>) -> Cells { | |
| 206 | + | let mut values = vec![Cell::new(&sample.name)]; | |
| 207 | + | if shown.duration { | |
| 208 | + | values.push(Cell::new(seconds(sample.duration))); | |
| 209 | + | } | |
| 210 | + | if shown.bpm { | |
| 211 | + | values.push(Cell::new( | |
| 212 | + | sample | |
| 213 | + | .bpm | |
| 214 | + | .map_or_else(String::new, |bpm| format!("{bpm:.0}")), | |
| 215 | + | )); | |
| 216 | + | } | |
| 217 | + | if shown.key { | |
| 218 | + | values.push(Cell::new(sample.key.clone().unwrap_or_default())); | |
| 219 | + | } | |
| 220 | + | if shown.peak_db { | |
| 221 | + | values.push(Cell::new( | |
| 222 | + | sample | |
| 223 | + | .peak_db | |
| 224 | + | .map_or_else(String::new, |db| format!("{db:.1}")), | |
| 225 | + | )); | |
| 226 | + | } | |
| 227 | + | if shown.tags { | |
| 228 | + | // Tags as tokens rather than as joined prose, which is what | |
| 229 | + | // `RowPart::Tokens` was added for one level down: a tag keeps its own | |
| 230 | + | // edges instead of becoming a comma in a sentence. | |
| 231 | + | // `Cell::tag` for the first and `token` for the rest: a cell holds a | |
| 232 | + | // run, and a tag keeps its own edges rather than becoming a comma in a | |
| 233 | + | // sentence, which is what `RowPart::Tokens` was added for one level | |
| 234 | + | // down. | |
| 235 | + | let mut cell = match sample.tags.first() { | |
| 236 | + | Some(first) => Cell::tag(Tag::badge(first.clone())), | |
| 237 | + | None => Cell::new(""), | |
| 238 | + | }; | |
| 239 | + | for tag in sample.tags.iter().skip(1) { | |
| 240 | + | cell = cell.token(Tag::badge(tag.clone())); | |
| 241 | + | } | |
| 242 | + | values.push(cell); | |
| 243 | + | } | |
| 244 | + | values.push(Cell::acts([Act::new( | |
| 245 | + | "Play", | |
| 246 | + | Action::post(format!("/files/{}/play", sample.id)), | |
| 247 | + | )])); | |
| 248 | + | ||
| 249 | + | let mut row = Cells::new(values).activate(Action::post(format!("/files/{}/open", sample.id))); | |
| 250 | + | row.current = current == Some(sample.id); | |
| 251 | + | row | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// A duration as the list writes it. | |
| 255 | + | fn seconds(duration: Option<f64>) -> String { | |
| 256 | + | let Some(seconds) = duration else { | |
| 257 | + | return String::new(); | |
| 258 | + | }; | |
| 259 | + | if seconds < 60.0 { | |
| 260 | + | format!("{seconds:.1}s") | |
| 261 | + | } else { | |
| 262 | + | #[expect( | |
| 263 | + | clippy::cast_possible_truncation, | |
| 264 | + | clippy::cast_sign_loss, | |
| 265 | + | reason = "a sample's length in minutes is small and positive" | |
| 266 | + | )] | |
| 267 | + | let minutes = (seconds / 60.0) as u32; | |
| 268 | + | #[expect( | |
| 269 | + | clippy::cast_possible_truncation, | |
| 270 | + | clippy::cast_sign_loss, | |
| 271 | + | reason = "the remainder is under sixty" | |
| 272 | + | )] | |
| 273 | + | let rest = (seconds % 60.0) as u32; | |
| 274 | + | format!("{minutes}:{rest:02}") | |
| 275 | + | } | |
| 276 | + | } |