//! The file list, described rather than built. //! //! The app's main screen and the third port. It is the one that needed //! `quasi-immediate` to grow a table first, and the one where the port is //! *least* of a rewrite: `ui::file_list::describe` already builds //! `makeover_layout::Column`s and hands 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. //! //! # What is different about this screen, and it is not the table //! //! Every port before this one 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 a described file list cannot select a row //! by reaching for the field, and the answer is not to make the state //! interior-mutable to suit a description. //! //! The answer is the app's own, already in the codebase: `SettingsUiState` has //! `pending_action: Option`, documented as "set by the UI, consumed //! by the app layer each frame". [`Intents`] is that pattern for described //! screens. The route records what the user asked for, the panel applies it to //! `&mut BrowserState` after the frame is drawn, and nothing needs a lock. //! //! Worth stating as a rule for the next port that meets this: **a described //! screen writing to UI state records an intent; a described screen writing to //! the app's data calls through a handle.** The first is not a lesser kind of //! port, it is what a frame boundary looks like from the description's side. //! //! # What is deliberately not described //! //! - **Drag and drop out of the app.** `draw_file_list` carries a macOS/Windows //! drag-cooldown state machine so an OS drag that ends outside the window does //! not leave egui's pointer state stale. That is a host input problem and //! nothing about it is a fact about a sample. //! - **The waveform and the inline rename.** Each is its own affordance; //! folding them in here would make the port about size rather than about //! shape. //! //! # The context menus, measured 2026-08-17, and they split three ways //! //! `ui/file_list_menus.rs` is 545 lines of three menus — one for the row under //! the cursor (14 entries), one for the selection (11), one for empty space (5) //! — and the question asked of them was whether a context menu can be described //! at all. It can, partly, and where it cannot is two members rather than one //! vague gap. //! //! **`Row::menu` is exactly the first one, on the wrong container.** Its own //! header says what it is for — "what it *offers*, reached by right-click on a //! pointer host, long-press on a touch one, and a key in a terminal" — and why //! that belongs to the description rather than to a renderer: one description //! has to become a context menu, an action sheet and a key-driven menu, and no //! single renderer can be where that is said. It is right, and the file list is //! a [`Node::Table`], whose [`Cells`] had no `menu`. //! //! **[`Cells::menu`] exists as of quasi-router 0.20.0, and this screen describes //! its row menu through it.** See `menu` below. The asymmetry had been corrected once //! before in this exact place: `Cells::selected` says it was deliberately absent //! "on the grounds that no table asked for one and a member added because its //! sibling has it is a member with no consumer to tell us what it should mean", //! and that it "was correct until 2026-08-15". This was the same sentence about //! the next field along, and the consumer that ended it is here. //! //! Two things came out of describing it that were not about the member. The //! description needs to know whether a row is a folder and whether its bytes are //! only in the cloud — `draw_context_menu` branches on both and [`Sample`] said //! neither, so every row was getting the sample columns and a Play control, //! folders included. And `quasi-immediate` drew no menu at all, for `Row` either: //! the detail pane's tag rows have offered one since they were described and it //! has never opened, because that renderer had no arm for the member. Both fixed //! in the same pass. //! //! The one entry still not described is **Add to Collection**, the only nested //! one. `Cells::menu` is flat, matching `Row::menu`, and a submenu wants a second //! consumer before [`Act`] grows a child list. Flattening it reads "Add to //! Kicks", "Add to Breaks" for as many collections as exist, which is honest and //! gets long. //! //! **The other two have no container at all.** A menu over the *selection* and a //! menu over the *surface* are not per-row, and nothing in the vocabulary holds //! acts back until a host asks for them except `Row::menu`. Described as //! [`Outcome::Over`](quasi_router::Outcome::Over) they become app-modal //! overlays, which is what [`toolbar`](super::toolbar) already recorded of the //! save-as-collection popover — "near enough and not exact" — and what //! [`importing`](super::importing) took for the Import menu as its second //! consumer. These are the third and fourth, and they are the ones that make the //! shape clear: an anchored menu is not a modal, and its subject is whatever it //! opened over. Filed as `quasi:vocabulary:anchored-menu`. //! //! Two of the eleven selection entries and three of the five background entries //! are described already, at addresses of their own — //! [`bulk`](super::bulk)'s three modals, [`naming`](super::naming)'s New Folder, //! [`importing`](super::importing)'s two doors. So what is missing is never the //! contents. It is the gesture and the anchor, both times. //! - **Virtual scrolling.** Recorded in the findings note as renderer policy //! from the start: windowing rows the app already holds is a performance //! technique, not a described fact. use quasi_router::layout::{Priority, Sort, Tone, Width}; use quasi_router::{ Act, Action, Cell, Cells, Choice, Column, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, Tag, }; use super::{Collection, Panels, Sample}; /// The region the screen answers into. const BODY: &str = "files-body"; /// The columns, by the name the sort routes know them by. const NAME: &str = "Name"; const DUR: &str = "Duration"; const BPM: &str = "BPM"; const KEY: &str = "Key"; const PEAK: &str = "Peak dB"; const TAGS: &str = "Tags"; const PLAY: &str = "Play"; /// What the Add to Collection act asks for, and what its handler reads back. const COLLECTION: &str = "collection"; /// Register this screen's routes. pub fn routes(router: Router>) -> Router> { router .get("/files", index) .post("/files/{id}/open", open) .post("/files/{id}/play", play) .post("/files/sort/{column}", sort) // The row menu. Five of these hold no capability of their own: they // select the row and then call the handle that already does the act for // the sample in focus, which is why `Files` grew seven methods for // thirteen entries. See `Files`'s own note. .post("/files/{id}/enter", enter) .post("/files/{id}/path/copy", copy_path) .post("/files/{id}/reveal", reveal) .post("/files/{id}/similar", find_similar) .post("/files/{id}/duplicates", find_duplicates) .post("/files/{id}/edit", edit) .post("/files/{id}/instrument", instrument) .post("/files/{id}/export", export) .post("/files/{id}/reanalyze", reanalyze) .post("/files/{id}/delete", delete) .post("/files/{id}/download", download) .post("/files/{id}/collection/remove", remove_from_collection) .post("/files/{id}/collection/add", add_to_collection) } /// `GET /files` fn index(state: &Panels<'_>, _request: Request) -> Result { Ok(screen(state).into()) } /// `POST /files/{id}/open` fn open(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.open(id); Ok(screen(state).into()) } /// `POST /files/{id}/play` fn play(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.play(id); Ok(screen(state).into()) } /// `POST /files/{id}/enter` /// /// A folder row's Open, which is not [`open`]'s Open: selecting a folder and /// going into it are two acts, and the shipped menu offers the second. fn enter(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.enter(id); Ok(screen(state).into()) } /// `POST /files/{id}/path/copy` /// /// The first of the five that borrow a capability rather than growing one: /// select the row, then call what the detail pane already calls on the sample in /// focus. The clipboard is `Detail`'s because the detail pane needed it first, /// and a second way to copy a path would be a second way for it to be wrong. fn copy_path(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.open(id); state.detail.copy_path(); Ok(Response::from(screen(state)).toast(Tone::Success, "Path copied.")) } /// `POST /files/{id}/reveal` fn reveal(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.reveal(id); Ok(screen(state).into()) } /// `POST /files/{id}/similar` fn find_similar(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.open(id); state.detail.find_similar(); Ok(screen(state).into()) } /// `POST /files/{id}/duplicates` fn find_duplicates(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.open(id); state.detail.find_duplicates(); Ok(screen(state).into()) } /// `POST /files/{id}/edit` fn edit(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.open(id); state.detail.edit(); Ok(screen(state).into()) } /// `POST /files/{id}/instrument` fn instrument(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.as_instrument(id); Ok(screen(state).into()) } /// `POST /files/{id}/export` /// /// The flow opens on whatever is chosen, so this selects the row and then asks /// for it. Same two steps the shipped menu takes, and the answer is the same /// `Goto` [`export`](super::export)'s own `begin` gives, because the flow is a /// screen rather than a change to this one. fn export(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.open(id); state.export.open(); Ok(Response::from(quasi_router::Outcome::Goto(Action::get( "/export", )))) } /// `POST /files/{id}/reanalyze` fn reanalyze(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.reanalyze(id); Ok(screen(state).into()) } /// `POST /files/{id}/delete` /// /// The act carries the question, so arriving here means it was answered. The app /// raises its own counted dialog after this, which is the shipped behaviour and /// is not a duplicate of the same question: one asks whether to delete this row /// and the other says how much is about to go. fn delete(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.delete(id); Ok(screen(state).into()) } /// `POST /files/{id}/download` fn download(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.download(id); Ok(screen(state).into()) } /// `POST /files/{id}/collection/remove` fn remove_from_collection(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; state.files.remove_from_collection(id); Ok(screen(state).into()) } /// `POST /files/{id}/collection/add` /// /// The collection arrives in the payload because the act asked for it, so this /// reads a submitted value the same way a form's handler does. An id that names /// no collection is a not-found rather than a silent no-op: the list the act /// offered was built from `Library::collections`, so a value outside it means /// the collection went away between the menu opening and the press. fn add_to_collection(state: &Panels<'_>, request: Request) -> Result { let id = id_of(&request)?; let chosen = request .payload .get(COLLECTION) .and_then(|value| value.parse::().ok()) .ok_or_else(|| RouteError::not_found("no collection named"))?; let named = state .library .collections() .into_iter() .find(|collection| collection.id == chosen) .ok_or_else(|| RouteError::not_found("no such collection"))?; state.files.add_to_collection(id, named.id); Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name))) } /// `POST /files/sort/{column}` /// /// The heading a user pressed. Which way it then sorts is the app's: pressing /// the column already in force reverses it, and the description says only which /// column was named. fn sort(state: &Panels<'_>, request: Request) -> Result { let column = request.captures.require("column")?; if !sortable(column) { return Err(RouteError::not_found("no such sort")); } state.files.sort_by(column); Ok(screen(state).into()) } /// The row a request names. fn id_of(request: &Request) -> Result { request .captures .require("id")? .parse() .map_err(|_| RouteError::not_found("no such sample")) } /// Whether a column can be ordered by. /// /// Peak and Tags have no sort of their own and never had one, which is what /// `Column::sortable` says when it is false: they are headings rather than /// controls. fn sortable(column: &str) -> bool { matches!(column, NAME | DUR | BPM | KEY) } /// The whole screen. fn screen(state: &Panels<'_>) -> Screen { Screen::sidebar_content("Samples").with(body(state)) } /// The list, as a region something else can hold. /// /// Public because [`shell`](super::shell) puts it inside the main screen rather /// than beside it: the file list is the app's central pane, so a described app /// composes this region while the standalone `/files` window answers it alone. /// Two callers, one description, which is what a region is for. pub fn body(state: &Panels<'_>) -> Slot { let shown = state.files.columns(); let samples = state.files.samples(); let current = state.files.current(); // The collections, which decide two menu entries: which one to take a sample // out of, and the list to offer for putting one in. Read once for the table // rather than per row -- it is a fact about the screen, and every row would // otherwise ask the library the same question. let collections = state.library.collections(); let collection = collections.iter().any(|collection| collection.active); if samples.is_empty() { // The sentence and the way out are both on the node rather than on the // region: `703f4cd2` settled that a region with a heading and no rows // still has content, so emptiness belongs to the thing that is empty. Slot::new(BODY, RegionKind::Pane).with( Node::empty("Nothing here yet.") .offering(Act::new("Import samples", Action::get("/import/open"))), ) } else { Slot::new(BODY, RegionKind::Pane).with(Node::Table { columns: columns(state, shown), rows: samples .iter() .map(|sample| row(sample, shown, current, collection, &collections)) .collect(), // Everything the app has loaded and filtered is here. Windowing // rows it already holds is a renderer's job, which is the note in // this module's header. more: None, }) } } /// The columns, in the order the shipped list puts them. /// /// Nearly a copy of `ui::file_list::describe`, and that is the point: it already /// built `makeover_layout::Column`s. What is added is the one thing that file /// could not say — the address a heading calls — which is /// [`Column::reorder`] and is quasi's rather than the vocabulary's. fn columns(state: &Panels<'_>, shown: super::ColumnsShown) -> Vec { let (by, ascending) = state.files.sort(); let sorted_by = |name: &str| { (name == by).then_some(if ascending { Sort::Ascending } else { Sort::Descending }) }; let data = |name: &'static str, priority: Priority| { let mut column = Column::new(name) .width(if name == NAME { Width::Fill } else { Width::Fixed }) .priority(priority); column.sorted = sorted_by(name); if sortable(name) { column = column.reorder(Action::post(format!("/files/sort/{name}"))); } column }; let mut columns = vec![data(NAME, Priority::Essential)]; if shown.duration { columns.push(data(DUR, Priority::Secondary)); } if shown.bpm { columns.push(data(BPM, Priority::Secondary)); } if shown.key { columns.push(data(KEY, Priority::Secondary)); } if shown.peak_db { columns.push(data(PEAK, Priority::Optional)); } if shown.tags { columns.push(data(TAGS, Priority::Optional)); } // The play control is essential, because a list of samples you cannot hear // is a list of filenames. columns.push(data(PLAY, Priority::Essential)); columns } /// One sample as a row. fn row( sample: &Sample, shown: super::ColumnsShown, current: Option, collection: bool, collections: &[Collection], ) -> Cells { let mut values = vec![Cell::new(&sample.name)]; if shown.duration { values.push(Cell::new(seconds(sample.duration))); } if shown.bpm { values.push(Cell::new( sample .bpm .map_or_else(String::new, |bpm| format!("{bpm:.0}")), )); } if shown.key { values.push(Cell::new(sample.key.clone().unwrap_or_default())); } if shown.peak_db { values.push(Cell::new( sample .peak_db .map_or_else(String::new, |db| format!("{db:.1}")), )); } if shown.tags { // Tags as tokens rather than as joined prose, which is what // `RowPart::Tokens` was added for one level down: a tag keeps its own // edges instead of becoming a comma in a sentence. // `Cell::tag` for the first and `token` for the rest: a cell holds a // run, and a tag keeps its own edges rather than becoming a comma in a // sentence, which is what `RowPart::Tokens` was added for one level // down. let mut cell = match sample.tags.first() { Some(first) => Cell::tag(Tag::badge(first.clone())), None => Cell::new(""), }; for tag in sample.tags.iter().skip(1) { cell = cell.token(Tag::badge(tag.clone())); } values.push(cell); } // A folder has nothing to play, and the cell stays because cells are // positional against the columns: dropping it would shift every value after // it one column left. Empty rather than absent is the same answer the // analysis cells already give for a folder. values.push(if sample.directory || sample.cloud_only { Cell::new("") } else { Cell::acts([Act::new( "Play", Action::post(format!("/files/{}/play", sample.id)), )]) }); let mut row = Cells::new(values).activate(Action::post(format!("/files/{}/open", sample.id))); row.current = current == Some(sample.id); row.menu = menu(sample, collection, collections); row } /// What a row offers without showing it. /// /// `ui/file_list_menus.rs::draw_context_menu`, said as a description. The /// branching is the shipped menu's: a folder and a sample offer different things, /// and a cloud-only sample withholds the four acts that need the bytes on disk. /// /// # What is not here, and why each one is not a gap /// /// - **The selection menu and the background menu.** Eleven entries and five, /// neither of them per-row: `draw_multi_context_menu` acts on the ticked set /// and the empty-space menu on the folder being shown. Neither has a container /// in the vocabulary -- there is no menu over a selection and none over a /// surface -- and described as [`Outcome::Over`](quasi_router::Outcome::Over) /// they become app-modal overlays, which is near enough and not exact. Filed as /// `quasi:vocabulary:anchored-menu`; see this module's header. /// # Add to Collection, which needed no submenu after all /// /// This entry was the module's one measured hole and was filed as a vocabulary /// question with three options, all of them bad: flatten it and the menu grows /// by one line per collection, grow [`Act`] a child list and every renderer /// learns nesting for one consumer, or spend a screen and a gesture on a /// chooser. /// /// The premise was stale. [`Act::asking`] landed after that was written, and it /// is exactly this shape: a control that wants a value before it fires, carried /// by every renderer already. So the entry is one act with one /// [`FieldKind::Select`](quasi_router::layout::FieldKind::Select) on it, the /// menu stays one line however many collections exist, and nothing new was /// added to the vocabulary. A submenu was the wrong question — the nesting was /// never the point, picking one of a list was. fn menu(sample: &Sample, collection: bool, collections: &[Collection]) -> Vec { let id = sample.id; let at = |verb: &str| Action::post(format!("/files/{id}/{verb}")); if sample.directory { return vec![ Act::new("Open", at("enter")), // Both already described, at addresses of their own: the folder // modals are `naming`'s, and pointing the menu at them is the whole // benefit of a description having addresses. Act::new("New Folder", Action::get("/folders/new")), Act::new("Rename", Action::get(format!("/folders/{id}/rename"))), Act::new("Export...", at("export")), Act::new("Delete", at("delete")) .tone(Tone::Danger) .confirm(format!("Delete {}?", sample.name)), ]; } let mut acts = Vec::new(); // A sample nobody has fetched yet: the one act it does offer, and then // nothing that needs the file. if sample.cloud_only { acts.push(Act::new("Download", at("download"))); } else { acts.push(Act::new("Preview", at("play"))); } acts.push(Act::new("Copy Path", at("path/copy"))); if !sample.cloud_only { acts.push(Act::new( crate::ui::file_list_menus::reveal_label(), at("reveal"), )); } // The two searches carry the keys the detail pane already binds for them, so // one screen does not teach a different chord for the same act. acts.push(Act::new("Find Similar", at("similar")).key("shift+f")); acts.push(Act::new("Find Duplicates", at("duplicates")).key("shift+d")); // A collection to put it in, if there is one. Offered for a cloud-only // sample too: membership is a fact about the sample rather than about the // bytes, which is the same reason the shipped menu guards this on the hash // and not on `cloud_only`. if !collections.is_empty() { acts.push( Act::new("Add to Collection", at("collection/add")).asking(Field::select( COLLECTION, "Collection", collections .iter() .map(|it| Choice::new(it.id.to_string(), it.name.clone())) .collect(), )), ); } if collection { acts.push(Act::new("Remove from Collection", at("collection/remove")).tone(Tone::Danger)); } if !sample.cloud_only { acts.push(Act::new("Edit...", at("edit")).key("e")); acts.push(Act::new("Play as Instrument", at("instrument"))); acts.push(Act::new("Export...", at("export"))); acts.push(Act::new("Re-analyze...", at("reanalyze"))); } acts.push( Act::new("Delete", at("delete")) .tone(Tone::Danger) .confirm(format!("Delete {}?", sample.name)), ); acts } /// A duration as the list writes it. fn seconds(duration: Option) -> String { let Some(seconds) = duration else { return String::new(); }; if seconds < 60.0 { format!("{seconds:.1}s") } else { #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "a sample's length in minutes is small and positive" )] let minutes = (seconds / 60.0) as u32; #[expect( clippy::cast_possible_truncation, clippy::cast_sign_loss, reason = "the remainder is under sixty" )] let rest = (seconds % 60.0) as u32; format!("{minutes}:{rest:02}") } }