Skip to main content

max / audiofiles

Describe the file list The app's main screen, and the port that is least of a rewrite: ui::file_list::describe already built makeover_layout::Columns and handed them to makeover_immediate::table, so the columns were described before this module existed. What was not described is everything below the header -- the rows, and what pressing one calls, which is Column::reorder and the row's activate address, both quasi's rather than the vocabulary's. THE FINDING, and it is a shape rather than a gap. Every port before this wrote through a handle that already took &self: the config store, the sync manager. The file list acts on the app's own in-memory UI state -- which row is selected, what is playing -- and those are &mut BrowserState. A handler is fn(&S, Request), so it cannot reach them, and the answer is not to make the state interior-mutable to suit a description. The answer is the app's own, already in this codebase: SettingsUiState has pending_action, "set by the UI, consumed by the app layer each frame". A described screen records an Intent, the panel applies it after the frame with the &mut it has anyway, and nothing needs a lock. Stated as a rule for the next port: a described screen writing to UI state records an intent; one writing to the app's data calls through a handle. The first is not a lesser port, it is what a frame boundary looks like from the description's side. A row is addressed by its own id and not by where it sits, and the adapter translates. An index is a fact about the current filter and sort, which is exactly what an address should not be. Not described, each for its own reason: the OS drag-out cooldown (a host input problem, nothing about it is a fact about a sample), the waveform, the inline rename and the context menu (each its own affordance), and virtual scrolling (renderer policy since the findings note said so). Takes quasi-immediate 0.2.0 for the table.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-14 14:02 UTC
Signed with PGP, not checked
Commit: 90c6e3e8e162c35c9d66348cc7ed5a444613bce9
Parent: 54d0e78
7 files changed, +793 insertions, -9 deletions
M Cargo.lock +1 -1
@@ -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",
M Cargo.toml +1 -1
@@ -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 + }