Skip to main content

max / audiofiles

Describe the file list Six shapes: the screen, the region, the row, and the three menus a press can answer with. `menu` forked internally on whether the row was a folder, so it is two shapes now: a folder menu and a sample menu share nothing but the delete. Which of the three a row answers with is a value the read decides and the row dispatches on. `columns` is gone. A table accretes its columns, so a list of them built elsewhere had nowhere to go; the eight are written where the table is, which is what the module header already wanted -- one place where the column shape is decided, so a cell whose column is off cannot shift the ones behind it. The menus are what earned `Vec<Act>` and the empty vault is what earned a guard on a table, both in quasi-declare 0.1.5. `shell` does the read where it splices the region, as it already does for the sidebar.
Author: Max Johnson <me@maxj.phd> · 2026-09-04 23:16 UTC
Signed with PGP, not checked
Commit: aaad3e8b3a29ab212a359a50073c5683c49efe3d
Parent: d97285e
3 files changed, +145 insertions, -206 deletions
M Cargo.lock +17 -17
@@ -4257,7 +4257,7 @@
4257 4257
4258 4258 [[package]]
4259 4259 name = "quasi-declare"
4260 - version = "0.1.4"
4260 + version = "0.1.5"
4261 4261 dependencies = [
4262 4262 "proc-macro2",
4263 4263 "quote",
@@ -7566,22 +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 - [[patch.unused]]
7582 - name = "quasi-type"
7583 - version = "0.1.3"
7584 -
7585 7569 [[patch.unused]]
7586 7570 name = "quasi-axum"
7587 7571 version = "0.101.1"
@@ -7609,3 +7593,19 @@
7609 7593 [[patch.unused]]
7610 7594 name = "quasi-webview"
7611 7595 version = "0.101.1"
7596 +
7597 + [[patch.unused]]
7598 + name = "kberg"
7599 + version = "0.1.0"
7600 +
7601 + [[patch.unused]]
7602 + name = "ops-status"
7603 + version = "0.1.0"
7604 +
7605 + [[patch.unused]]
7606 + name = "painhours"
7607 + version = "0.1.0"
7608 +
7609 + [[patch.unused]]
7610 + name = "quasi-type"
7611 + version = "0.1.3"
@@ -50,9 +50,10 @@
50 50 //! that belongs to the description rather than to a renderer: one description
51 51 //! has to become a context menu, an action sheet and a key-driven menu, and no
52 52 //! single renderer can be where that is said. It is right, and the file list is
53 - //! a [`Node::Table`], whose [`Cells`] had no `menu`.
53 + //! a [`Node::Table`], whose
54 + //! [`Cells`](quasi_router::Cells) had no `menu`.
54 55 //!
55 - //! **[`Cells::menu`] exists as of quasi-router 0.20.0, and this screen describes
56 + //! **[`Cells::menu`](quasi_router::Cells::menu) exists as of quasi-router 0.20.0, and this screen describes
56 57 //! its row menu through it.** See `menu` below. A member is added when a consumer
57 58 //! asks for it, not because its sibling has one.
58 59 //!
@@ -89,7 +90,7 @@
89 90 //! on one. Picking eleven kicks out of four hundred by hand is close to what this
90 91 //! app is for, so the selection has a described shape.
91 92 //!
92 - //! It is two members. [`Cells::chosen`] is the
93 + //! It is two members. `Cells::chosen` is the
93 94 //! third state beside `current` and `selected`, three-valued for the reason the
94 95 //! tick is: a ctrl-click lands on an **unchosen** row, so a renderer has to know
95 96 //! that row can join a set before anything is in one.
@@ -101,13 +102,14 @@
101 102 //! from the start: windowing rows the app already holds is a performance
102 103 //! technique, not a described fact.
103 104
104 - use quasi_router::layout::{Priority, Sort, Tone, Width};
105 + use quasi_declare::declare;
106 + use quasi_router::layout::{Sort, Tone};
105 107 use quasi_router::{
106 - Act, Action, Cell, Cells, Choice, Choosing, Column, Field, Node, RegionKind, Request, Response,
107 - RouteError, Router, Screen, Slot, Table, Tag,
108 + Act, Action, Choice, Choosing, Node, RegionKind, Request, Response, RouteError, Router, Screen,
109 + Slot,
108 110 };
109 111
110 - use super::{Collection, Panels, Sample};
112 + use super::{Panels, Sample};
111 113
112 114 /// The region the screen answers into.
113 115 const BODY: &str = "files-body";
@@ -173,7 +175,7 @@
173 175
174 176 /// `GET /files`
175 177 fn index(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
176 - Ok(screen(state).into())
178 + Ok(showing(state))
177 179 }
178 180
179 181 /// `POST /files/{id}/open`
@@ -190,14 +192,14 @@
190 192 state
191 193 .files
192 194 .choose(id, Choosing::read(request.payload.get(Node::CHOOSING)));
193 - Ok(screen(state).into())
195 + Ok(showing(state))
194 196 }
195 197
196 198 /// `POST /files/{id}/play`
197 199 fn play(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
198 200 let id = id_of(&request)?;
199 201 state.files.play(id);
200 - Ok(screen(state).into())
202 + Ok(showing(state))
201 203 }
202 204
203 205 /// `POST /files/{id}/enter`
@@ -207,7 +209,7 @@
207 209 fn enter(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
208 210 let id = id_of(&request)?;
209 211 state.files.enter(id);
210 - Ok(screen(state).into())
212 + Ok(showing(state))
211 213 }
212 214
213 215 /// `POST /files/{id}/path/copy`
@@ -220,14 +222,14 @@
220 222 let id = id_of(&request)?;
221 223 state.files.open(id);
222 224 state.detail.copy_path();
223 - Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied."))
225 + Ok(showing(state).toast(Tone::Success, "Path copied."))
224 226 }
225 227
226 228 /// `POST /files/{id}/reveal`
227 229 fn reveal(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
228 230 let id = id_of(&request)?;
229 231 state.files.reveal(id);
230 - Ok(screen(state).into())
232 + Ok(showing(state))
231 233 }
232 234
233 235 /// `POST /files/{id}/similar`
@@ -235,7 +237,7 @@
235 237 let id = id_of(&request)?;
236 238 state.files.open(id);
237 239 state.detail.find_similar();
238 - Ok(screen(state).into())
240 + Ok(showing(state))
239 241 }
240 242
241 243 /// `POST /files/{id}/basket`
@@ -246,7 +248,7 @@
246 248 fn basket(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
247 249 let id = id_of(&request)?;
248 250 state.files.toggle_basket(id);
249 - Ok(screen(state).into())
251 + Ok(showing(state))
250 252 }
251 253
252 254 /// `POST /files/{id}/duplicates`
@@ -254,7 +256,7 @@
254 256 let id = id_of(&request)?;
255 257 state.files.open(id);
256 258 state.detail.find_duplicates();
257 - Ok(screen(state).into())
259 + Ok(showing(state))
258 260 }
259 261
260 262 /// `POST /files/{id}/edit`
@@ -262,14 +264,14 @@
262 264 let id = id_of(&request)?;
263 265 state.files.open(id);
264 266 state.detail.edit();
265 - Ok(screen(state).into())
267 + Ok(showing(state))
266 268 }
267 269
268 270 /// `POST /files/{id}/instrument`
269 271 fn instrument(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
270 272 let id = id_of(&request)?;
271 273 state.files.as_instrument(id);
272 - Ok(screen(state).into())
274 + Ok(showing(state))
273 275 }
274 276
275 277 /// `POST /files/{id}/export`
@@ -291,7 +293,7 @@
291 293 fn reanalyze(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
292 294 let id = id_of(&request)?;
293 295 state.files.reanalyze(id);
294 - Ok(screen(state).into())
296 + Ok(showing(state))
295 297 }
296 298
297 299 /// `POST /files/{id}/delete`
@@ -303,21 +305,21 @@
303 305 fn delete(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
304 306 let id = id_of(&request)?;
305 307 state.files.delete(id);
306 - Ok(screen(state).into())
308 + Ok(showing(state))
307 309 }
308 310
309 311 /// `POST /files/{id}/download`
310 312 fn download(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
311 313 let id = id_of(&request)?;
312 314 state.files.download(id);
313 - Ok(screen(state).into())
315 + Ok(showing(state))
314 316 }
315 317
316 318 /// `POST /files/{id}/collection/remove`
317 319 fn remove_from_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
318 320 let id = id_of(&request)?;
319 321 state.files.remove_from_collection(id);
320 - Ok(screen(state).into())
322 + Ok(showing(state))
321 323 }
322 324
323 325 /// `POST /files/{id}/collection/add`
@@ -341,7 +343,7 @@
341 343 .find(|collection| collection.id == chosen)
342 344 .ok_or_else(|| RouteError::not_found("no such collection"))?;
343 345 state.files.add_to_collection(id, named.id);
344 - Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name)))
346 + Ok(showing(state).toast(Tone::Success, format!("Added to {}.", named.name)))
345 347 }
346 348
347 349 // --- the selection, which is the whole set rather than a row ---
@@ -349,13 +351,13 @@
349 351 /// `POST /files/chosen/invert`
350 352 fn invert_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
351 353 state.files.invert_chosen();
352 - Ok(screen(state).into())
354 + Ok(showing(state))
353 355 }
354 356
355 357 /// `POST /files/chosen/clear`
356 358 fn deselect(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
357 359 state.files.deselect();
358 - Ok(screen(state).into())
360 + Ok(showing(state))
359 361 }
360 362
361 363 /// `POST /files/chosen/export`
@@ -373,7 +375,7 @@
373 375 /// `POST /files/chosen/reanalyze`
374 376 fn reanalyze_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
375 377 state.files.reanalyze_chosen();
376 - Ok(screen(state).into())
378 + Ok(showing(state))
377 379 }
378 380
379 381 /// `POST /files/chosen/path/copy`
@@ -385,13 +387,13 @@
385 387 /// route does not hold.
386 388 fn copy_chosen_paths(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
387 389 state.files.copy_chosen_paths();
388 - Ok(screen(state).into())
390 + Ok(showing(state))
389 391 }
390 392
391 393 /// `POST /files/chosen/tags/copy`
392 394 fn copy_tags_from_focus(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
393 395 state.files.copy_tags_from_focus();
394 - Ok(screen(state).into())
396 + Ok(showing(state))
395 397 }
396 398
397 399 /// `POST /files/chosen/delete`
@@ -401,7 +403,7 @@
401 403 /// reader needs when the subject is a set.
402 404 fn delete_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
403 405 state.files.delete_chosen();
404 - Ok(screen(state).into())
406 + Ok(showing(state))
405 407 }
406 408
407 409 /// `POST /files/chosen/collection/add`
@@ -418,7 +420,7 @@
418 420 .find(|collection| collection.id == chosen)
419 421 .ok_or_else(|| RouteError::not_found("no such collection"))?;
420 422 state.files.add_chosen_to_collection(named.id);
421 - Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name)))
423 + Ok(showing(state).toast(Tone::Success, format!("Added to {}.", named.name)))
422 424 }
423 425
424 426 /// `POST /files/chosen/collection/remove`
@@ -427,7 +429,7 @@
427 429 _request: Request,
428 430 ) -> Result<Response, RouteError> {
429 431 state.files.remove_chosen_from_collection();
430 - Ok(screen(state).into())
432 + Ok(showing(state))
431 433 }
432 434
433 435 /// `GET /files/menu/background`
@@ -511,7 +513,7 @@
511 513 return Err(RouteError::not_found("no such sort"));
512 514 }
513 515 state.files.sort_by(column);
514 - Ok(screen(state).into())
516 + Ok(showing(state))
515 517 }
516 518
517 519 /// The row a request names.
@@ -532,483 +534,548 @@
532 534 matches!(column, NAME | DUR | BPM | KEY)
533 535 }
534 536
535 - /// The whole screen.
536 - fn screen(state: &Panels<'_>) -> Screen {
537 - Screen::sidebar_content("Samples").with(body(state))
537 + /// The screen, read and then described.
538 + pub(super) fn showing(state: &Panels<'_>) -> Response {
539 + Response::from(screen(&read(state)))
538 540 }
539 541
540 - /// The list, as a region something else can hold.
542 + /// What the list draws, read off the app.
541 543 ///
542 - /// Public because [`shell`](super::shell) puts it inside the main screen rather
543 - /// than beside it: the file list is the app's central pane, so a described app
544 - /// composes this region while the standalone `/files` window answers it alone.
545 - /// Two callers, one description, which is what a region is for.
546 - pub fn body(state: &Panels<'_>) -> Slot {
547 - let shown = state.files.columns();
548 - let samples = state.files.samples();
549 - let current = state.files.current();
550 - // The collections, which decide two menu entries: which one to take a sample
551 - // out of, and the list to offer for putting one in. Read once for the table
552 - // rather than per row -- it is a fact about the screen, and every row would
553 - // otherwise ask the library the same question.
554 - let collections = state.library.collections();
555 - let listing = Listing {
556 - shown,
557 - current,
558 - collection: collections.iter().any(|collection| collection.active),
559 - collections: &collections,
560 - basket_showing: state.bar.basket_showing(),
561 - // What the selection menu hangs on. Counted here for the same reason
562 - // the collections are read here: it is a fact about the screen, and a
563 - // row asking it would ask it once per row.
564 - chosen: samples.iter().filter(|sample| sample.selected).count(),
565 - focus: current.and_then(|id| {
566 - samples
567 - .iter()
568 - .find(|sample| sample.id == id)
569 - .map(|sample| sample.name.clone())
570 - }),
571 - };
572 -
573 - if samples.is_empty() {
574 - // The sentence and the way out are both on the node rather than on the
575 - // region: `703f4cd2` settled that a region with a heading and no rows
576 - // still has content, so emptiness belongs to the thing that is empty.
577 - Slot::new(BODY, RegionKind::Pane).with(
578 - Node::empty("Nothing here yet.")
579 - .offering(Act::new("Import samples", Action::get("/import/open"))),
580 - )
581 - } else {
582 - // No `more`: everything the app has loaded and filtered is here.
583 - // Windowing rows it already holds is a renderer's job, which is the
584 - // note in this module's header.
585 - Slot::new(BODY, RegionKind::Pane).with(Node::from(
586 - Table::new(columns(state, shown))
587 - .rows(samples.iter().map(|sample| row(sample, &listing))),
588 - ))
589 - }
590 - }
591 -
592 - /// The columns, in the order the shipped list puts them.
593 - ///
594 - /// Nearly a copy of `ui::file_list::describe`, and that is the point: it already
595 - /// built `makeover_layout::Column`s. What is added is the one thing that file
596 - /// could not say — the address a heading calls — which is
597 - /// [`Column::reorder`] and is quasi's rather than the vocabulary's.
598 - fn columns(state: &Panels<'_>, shown: super::ColumnsShown) -> Vec<Column> {
599 - let (by, ascending) = state.files.sort();
600 - let sorted_by = |name: &str| {
601 - (name == by).then_some(if ascending {
602 - Sort::Ascending
603 - } else {
604 - Sort::Descending
605 - })
606 - };
607 - let data = |name: &'static str, priority: Priority| {
608 - let mut column = Column::new(name)
609 - .width(if name == NAME {
610 - Width::Fill
611 - } else {
612 - Width::Fixed
613 - })
614 - .priority(priority);
615 - column.sorted = sorted_by(name);
616 - if sortable(name) {
617 - column = column.reorder(Action::post(format!("/files/sort/{name}")));
618 - }
619 - column
620 - };
621 -
622 - let mut columns = vec![data(NAME, Priority::Essential)];
623 - // Only while a basket answer is on screen, because it is the only time the
624 - // question it answers ("which of them is this near?") can be asked. Second
625 - // rather than last: it is why the row is in the list at all, and a reason
626 - // shown after the tempo and the key reads as a detail about the row instead.
627 - if state.bar.basket_showing() {
628 - columns.push(
629 - Column::new(NEAR)
630 - .width(Width::Fixed)
631 - .priority(Priority::Secondary),
632 - );
633 - }
634 - if shown.duration {
635 - columns.push(data(DUR, Priority::Secondary));
636 - }
637 - if shown.bpm {
638 - columns.push(data(BPM, Priority::Secondary));
639 - }
640 - if shown.key {
641 - columns.push(data(KEY, Priority::Secondary));
642 - }
643 - if shown.peak_db {
644 - columns.push(data(PEAK, Priority::Optional));
645 - }
646 - if shown.tags {
647 - columns.push(data(TAGS, Priority::Optional));
648 - }
649 - // The play control is essential, because a list of samples you cannot hear
650 - // is a list of filenames.
651 - columns.push(data(PLAY, Priority::Essential));
652 - columns
653 - }
654 -
655 - /// What every row of one drawing of the list is built against.
656 - ///
657 - /// Read once in [`body`] and handed down, rather than asked per row: each of
658 - /// these is a fact about the *screen* -- which columns are on, what is chosen,
659 - /// which collection is open -- and a row that read them itself would ask the
660 - /// library the same question five hundred times.
661 - struct Listing<'a> {
544 + /// Every fact about the *screen* is read once here and handed down, rather than
545 + /// asked per row: which columns are on, what is chosen, which collection is
546 + /// open. A row that read them itself would ask the library the same question
547 + /// five hundred times.
548 + pub(super) struct Listing {
662 549 /// Which columns the reader has switched on.
663 550 shown: super::ColumnsShown,
664 - /// The row the app is pointing at.
665 - current: Option<i64>,
666 - /// Whether a collection is the thing being viewed.
667 - collection: bool,
668 - /// The collections a sample can be put into.
669 - collections: &'a [Collection],
670 551 /// Whether a basket answer is on screen, which adds the anchors column.
671 - basket_showing: bool,
672 - /// How many rows the reader has chosen.
673 - chosen: usize,
674 - /// What the row in focus is called, for the entry that spreads its tags.
675 - focus: Option<String>,
552 + basket: bool,
553 + /// Which way each sortable column is ordered, where it is the one in force.
554 + sorted: Sorted,
555 + /// The rows.
556 + rows: Vec<Listed>,
557 + /// Whether there are none.
558 + bare: bool,
559 + /// What the collection and copy-tags entries need.
560 + shared: Shared,
561 + }
562 +
563 + /// Which way each sortable column is ordered.
564 + ///
565 + /// One field per sortable column rather than the name of the column in force
566 + /// beside its direction, because the description asks each column its own
567 + /// question and a name it would have to compare against is not an answer.
568 + struct Sorted {
569 + name: Option<Sort>,
570 + duration: Option<Sort>,
571 + bpm: Option<Sort>,
572 + key: Option<Sort>,
676 573 }
677 574
678 575 /// One sample as a row.
679 576 ///
680 577 /// Every cell names its column, and six of the eight are conditional: the
681 578 /// anchors cell appears with a basket answer and five more follow the reader's
682 - /// switched-on columns. Built by position, this function and [`columns`] were
683 - /// two lists of conditionals that had to agree in the same order, in two places,
684 - /// with nothing checking that they did. Naming them makes [`columns`] the only
685 - /// place the shape is decided, and a cell whose column is off is dropped rather
686 - /// than shifting everything after it one column left.
687 - fn row(sample: &Sample, listing: &Listing<'_>) -> Cells {
688 - let shown = listing.shown;
689 - let basket_showing = listing.basket_showing;
690 - let mut values = Cells::default().at(NAME, Cell::new(&sample.name));
691 - if basket_showing {
692 - // A badge per anchor, in basket order, the way the tags cell holds a run
693 - // of tags: each anchor keeps its own edges rather than becoming a comma
694 - // in a sentence. Three badges beside one badge is the whole point --
695 - // "near all of these" and "near one of these" are different answers and
696 - // must not look alike.
697 - let mut cell = match sample.matched.first() {
698 - Some(first) => Cell::tag(Tag::badge(first.clone())),
699 - None => Cell::new(""),
700 - };
701 - for anchor in sample.matched.iter().skip(1) {
702 - cell = cell.token(Tag::badge(anchor.clone()));
579 + /// switched-on columns. Built by position, this and the column list were two
580 + /// lists of conditionals that had to agree in the same order, in two places,
581 + /// with nothing checking that they did. Naming them makes the column list the
582 + /// only place the shape is decided, and a cell whose column is off is dropped
583 + /// rather than shifting everything after it one column left.
584 + struct Listed {
585 + /// The row's own id, which is what its addresses are built from.
586 + id: String,
587 + /// What it is called.
588 + name: String,
589 + /// The anchors it answered to, while a basket answer is on screen.
590 + near: Option<Vec<String>>,
591 + /// How long it runs, where the column is on.
592 + duration: Option<String>,
593 + /// Beats per minute, where the column is on.
594 + bpm: Option<String>,
595 + /// The musical key, where the column is on.
596 + key: Option<String>,
597 + /// Peak level, where the column is on.
598 + peak: Option<String>,
599 + /// Whatever it is tagged with, where the column is on.
600 + tags: Option<Vec<String>>,
601 + /// Whether there is anything to play.
602 + ///
603 + /// A folder has nothing. The cell is said anyway rather than left out: the
604 + /// Play column is always on, and empty rather than absent is the same answer
605 + /// the analysis cells already give for a folder.
606 + playable: bool,
607 + /// Whether the reader has chosen this row.
608 + chosen: bool,
609 + /// Whether the app is pointing at it.
610 + current: bool,
611 + /// Which menu a press on it answers with.
612 + ///
613 + /// Which menu a row offers is which subject the press is about. A row inside
614 + /// a multi-selection is not being asked about itself, and the shipped list
615 + /// said so at the same fork.
616 + menu: Menu,
617 + /// Whether the bytes are only in the cloud.
618 + cloud_only: bool,
619 + /// What the basket entry reads, which says which way it goes.
620 + basket: &'static str,
621 + /// What reveal is called on this host.
622 + reveal: &'static str,
623 + /// What deleting it asks before it runs.
624 + confirm: String,
625 + }
626 +
627 + /// Which menu a row answers a press with.
628 + enum Menu {
629 + /// The row's own, for a folder.
630 + Folder,
631 + /// The row's own, for a sample.
632 + Sample,
633 + /// What the chosen set offers, for a row inside a multi-selection.
634 + Chosen,
635 + }
636 +
637 + /// What the collection and copy-tags entries need.
638 + struct Shared {
639 + /// The collections a sample can be put into.
640 + collections: Vec<Choice>,
641 + /// Whether there are any.
Lines truncated
@@ -345,7 +345,7 @@
345 345 screen sidebar_content "audiofiles" {
346 346 include super::toolbar::body(state);
347 347 include super::library::body(&super::library::read(state)) when window.sidebar;
348 - include super::files::body(state);
348 + include super::files::body(&super::files::read(state));
349 349
350 350 for moving in window.moving.iter() {
351 351 include migrating(moving);