//! The three bulk modals, described rather than built. //! //! The sixth audiofiles port, and the first that is drawn **over** something. //! `Outcome::Over` had never been handed to `quasi-immediate` by anything until //! this: the renderer answered it and the answer was untested from an app. //! //! # Three addresses, where the last three ports had one //! //! `detail`, `sync` and `export` each answer many screens at one route, and the //! header there states why: the state is something that happened rather than //! somewhere you can go. This is the other side of that rule and it sharpens it. //! Tag, Move and Rename are three addresses, because **the user picked which //! one**. `BulkModal` being one enum in the app is an implementation of the //! modal slot, not a fact about what the user did. //! //! So the question that decides it is not "how many shapes does the state have" //! but *did the user choose this, or did they arrive here?* Chosen is an //! address; arrived is a shape at one. //! //! # What the description deletes: the whole of `BulkModal` //! //! Eleven fields across three variants, and the described side reads none of //! them. [`Bulk`](super::Bulk)'s header has the argument; the short form is that //! the type does two jobs — a buffer for what is being typed, and an argument //! list for the executor — and a described modal needs neither. The buffer is //! what a `Runtime`'s `View` is, and the arguments come off the selection. //! //! The clearest instance is the rename preview. The shipped modal keeps //! `pattern_input` and `previews` in app state and calls //! `update_rename_previews` on every keystroke to write one into the other. The //! preview is a pure function of the pattern and the selection, so here the //! typed pattern goes to a route and the route answers a screen with the //! previews in it. Same loop, and nothing is stored between two frames that was //! not stored anyway. //! //! # THE FINDINGS //! //! **1. An overlay has no described way to close.** `Runtime::dismiss` is //! private and Escape is the only thing that calls it. A modal with a Cancel //! button is the most ordinary control there is, and a description cannot say //! it: there is no `Action` meaning "close what is on top", only `Route` and //! `External`. What this port does instead is navigate back to where the modal //! was opened from, which *looks* right and is not — it clears the layer stack rather //! than revealing what was under it, so the screen underneath is rebuilt and //! whatever the user had typed into it is gone. On a touch host with no Escape //! key it is worse than cosmetic: the navigation is the only way out. //! //! **2. An overlay cannot refresh itself.** Every described screen so far //! refreshes by re-answering its own address, and `Outcome::Screen` clears the //! layers on the way in — correctly, since a navigation replaces everything. So //! an overlay that re-answers itself destroys itself, and one that answers //! `Over` again stacks a second copy. `Outcome::Fragment` is the way to change //! part of an open overlay and it works; what has no answer is "this whole //! overlay again, still over what it was over". The host feels this from the //! other side and the rule is in [`panel`](super::panel): **do not reload while //! `Runtime::overlaid`**. //! //! **3. A third consumer for the disabled-control precondition.** The Apply //! button on the tag modal is `add_enabled(false, ..)` with //! `on_disabled_hover_text("None of the selected samples have this tag.")`, //! which is the same missing fact the detail port filed on the Discovery //! buttons (`9bab759c`) and makeover-layout filed on `Choice` (`e761833e`). //! Three consumers, three members, one gap. Said as prose here for the same //! reason and with the same complaint. use quasi_router::layout::{FieldKind, Notice, Selector, Tone}; use quasi_router::{ Act, Action, Cell, Cells, Choice, Column, Field, Node, Outcome, RegionKind, Request, Response, Rest, RouteError, Router, Screen, Slot, Tag, }; use super::{Chosen, Panels}; /// The region each modal answers into. const BODY: &str = "bulk-body"; /// What a tag modal submits. const TAG: &str = "tag"; /// Whether it is adding or removing. const MODE: &str = "mode"; /// What a move modal submits. const FOLDER: &str = "folder"; /// What a rename modal submits. const PATTERN: &str = "pattern"; /// The root, as a folder value. Empty rather than an id, because the root has /// no node and `None` is what the app's `target_parent` already means. const ROOT: &str = ""; /// Where a finished or cancelled modal goes, and the whole of finding 1. /// /// The detail screen's several-shape, which is the only place these three are /// reachable from, so it is where they came from. Navigating there clears the /// layer stack, which is not what dismissing an overlay means; it is the only /// thing a description can say. const BACK: &str = "/detail"; /// The pattern a rename modal starts from, the same one the shipped modal does. const START: &str = "{name}"; /// The tokens a rename pattern may use. /// /// Named here rather than read from `audiofiles_core::rename`, which does not /// expose them: the shipped modal has the same nine-element array inline. Worth /// knowing that both copies exist, and that the core is where they should come /// from if a tenth is ever added. const TOKENS: [&str; 9] = [ "{name}", "{ext}", "{bpm}", "{key}", "{class}", "{duration}", "{n}", "{nn}", "{nnn}", ]; /// How many rows are shown before the rest become a count. /// /// The shipped modal's cap, kept for a different reason. There it is a rendering /// cost — "egui materialises every cell every frame; for a 500-row rename this /// matters" — which is renderer policy and not the description's business. Here /// it is honesty: a described list of the first fifty of five hundred is /// otherwise indistinguishable from a list of fifty, and [`Rest`] is what says /// which it is. /// /// This port was written against 0.14, where `Node::List` had `more` and /// `Node::Table` had nothing, so both overflows were sentences at the end of a /// list. **0.15 gave `Table` the same member** and both now say it properly. /// Neither carries a `forward`: the cap is a rendering budget, the operation /// acts on every chosen item either way, and there is no next page to ask for. const SHOWN: usize = 50; /// Register the three modals' routes. pub fn routes(router: Router>) -> Router> { router .get("/bulk/tag", tag_screen) .post("/bulk/tag", tag) .get("/bulk/move", move_screen) .post("/bulk/move", move_to) .get("/bulk/rename", rename_screen) .post("/bulk/rename/preview", preview) .post("/bulk/rename", rename) .post(DONE, done) } /// `GET /bulk/tag` fn tag_screen(state: &Panels<'_>, _request: Request) -> Result { let chosen = state.bulk.chosen(); if chosen.samples == 0 { return Err(RouteError::not_found("no samples are chosen")); } Ok(over(tagging(state, &chosen, None, true))) } /// `POST /bulk/tag` /// /// Answers the list rather than the modal, because the modal is done. That is /// also what closes it: see finding 2 in this module's header — a navigation is /// the only thing that takes an overlay down, and here it happens to be right. fn tag(state: &Panels<'_>, request: Request) -> Result { let typed = request.payload.get(TAG).unwrap_or_default().trim(); let adding = request.payload.get(MODE) != Some("remove"); let chosen = state.bulk.chosen(); if typed.is_empty() { return Ok(over(tagging(state, &chosen, Some(""), adding)) .toast(Tone::Danger, "Type a tag first.")); } // The same refusal the shipped Apply button makes, made by the route as // well: an address is reachable by typing, so a disabled control is an // affordance rather than a guarantee. if !adding && !state.bulk.known_tags().iter().any(|known| known == typed) { return Err(RouteError::not_found(UNKNOWN)); } state.bulk.tag(typed, adding); state.bulk.done(); Ok(Response::from(leaving()).toast( Tone::Success, format!( "{} \"{typed}\" {} {} samples.", if adding { "Adding" } else { "Removing" }, if adding { "on" } else { "from" }, chosen.samples, ), )) } /// What the shipped Apply button says when it will not run. const UNKNOWN: &str = "None of the selected samples have this tag."; /// The tag modal. fn tagging(state: &Panels<'_>, chosen: &Chosen, typed: Option<&str>, adding: bool) -> Screen { let mut body = Slot::new(BODY, RegionKind::Pane) .with(Node::page(format!("Tag {} samples", chosen.samples))); // Add or remove, as one control rather than two selectable labels. The // shipped modal draws `selectable_value(adding, true, ..)` twice, which is a // segmented control spelled out. body = body.with(Node::Select { kind: Selector::Segmented, options: vec![ (Choice::new("add", "Add tag"), None), (Choice::new("remove", "Remove tag"), None), ], chosen: Some(if adding { "add" } else { "remove" }.to_owned()), action: None, }); let mut field = Field::new(FieldKind::Text, TAG, "Tag").hint("e.g. genre.electronic"); if let Some(typed) = typed { field = field.value(typed); } body = body .with(Node::Form { fields: vec![field], submit: "Apply".to_owned(), action: Action::post("/bulk/tag"), }) .with(Node::text(if adding { "Will add to every selected sample that lacks it." } else { "Will remove from selected samples that have this tag." })); // Every tag the vault knows, as badges under the field. The shipped modal // filters this set to a substring of what is typed, caps it at twelve, and // fills the field when one is clicked -- so this is the **second consumer** // of `quasi:vocabulary:text-into-field`, the finding the export port filed // for the naming-pattern chips. A `Field` cannot say what completes it, so // the set is named and the reader types. // // The narrowing is not described and should not be: what the app knows is // the whole set, and how many of them a host shows while someone types is // the host's business. body = body.with(Node::section("Known tags")); for known in state.bulk.known_tags().iter().take(SHOWN) { body = body.with(Node::Token(Tag::badge(known.clone()))); } closing(subjects(body, &chosen.names)) } /// `GET /bulk/move` fn move_screen(state: &Panels<'_>, _request: Request) -> Result { let chosen = state.bulk.chosen(); if chosen.names.is_empty() { return Err(RouteError::not_found("nothing is chosen")); } Ok(over(moving(state, &chosen))) } /// `POST /bulk/move` /// /// The root is the empty value rather than a missing one, so "put these at the /// top" and "the form sent nothing" stay different requests. fn move_to(state: &Panels<'_>, request: Request) -> Result { let chosen = request .payload .get(FOLDER) .ok_or_else(|| RouteError::not_found("no destination named"))?; let folder = if chosen == ROOT { None } else { let id: i64 = chosen .parse() .map_err(|_| RouteError::not_found("no such folder"))?; if !state.bulk.folders().iter().any(|folder| folder.id == id) { return Err(RouteError::not_found("no such folder")); } Some(id) }; let count = state.bulk.chosen().names.len(); state.bulk.move_to(folder); state.bulk.done(); Ok(Response::from(leaving()).toast(Tone::Success, format!("Moving {count} items."))) } /// The move modal. /// /// A table of one column rather than a list, because picking a row is what this /// screen is for and `Cells::activate` is what says a row is pressable. The /// shipped modal draws `selectable_label` per directory with a substring filter /// above it; the filter is not described, on the rule the tag completions /// follow — narrowing a list while someone types is what a host does with a list /// it was handed. fn moving(state: &Panels<'_>, chosen: &Chosen) -> Screen { let folders = state.bulk.folders(); let mut rows = vec![row("/", ROOT)]; rows.extend( folders .iter() .map(|folder| row(&folder.path, &folder.id.to_string())), ); let body = Slot::new(BODY, RegionKind::Pane) .with(Node::page(format!("Move {} items", chosen.names.len()))) .with(Node::text("Choose where they go.")) .with(Node::Table { columns: vec![Column::new("Folder")], rows, // Every folder in the vault, because a destination the picker does // not show is a destination you cannot choose. more: None, }); closing(subjects(body, &chosen.names)) } /// One destination, as a row that submits itself. fn row(path: &str, value: &str) -> Cells { Cells::new(vec![Cell::new(path)]).activate(Action::post("/bulk/move").carrying(FOLDER, value)) } /// `GET /bulk/rename` fn rename_screen(state: &Panels<'_>, _request: Request) -> Result { let chosen = state.bulk.chosen(); if chosen.names.is_empty() { return Err(RouteError::not_found("nothing is chosen")); } Ok(over(renaming(state, START))) } /// `POST /bulk/rename/preview` /// /// What the shipped modal does with `update_rename_previews` on every keystroke, /// except that nothing is stored: the pattern arrives, the previews are computed /// from it, and the answer carries both. /// /// **`Outcome::Fragment` rather than a screen**, and that is finding 2 doing its /// work: this overlay is open, and answering a whole screen would take it down. /// A fragment replaces one region of whatever is showing, which is exactly what /// a live preview is. fn preview(state: &Panels<'_>, request: Request) -> Result { let pattern = request.payload.get(PATTERN).unwrap_or_default(); Ok(Response::from(Outcome::Fragment { region: PREVIEW.to_owned(), node: previewed(state, pattern), })) } /// `POST /bulk/rename` fn rename(state: &Panels<'_>, request: Request) -> Result { let pattern = request.payload.get(PATTERN).unwrap_or_default(); // Refused rather than run, because a pattern that does not parse renames // every chosen file to nothing. The shipped modal disables the button on // the same condition; this is the route saying it too. let previews = state .bulk .previews(pattern) .map_err(RouteError::not_found)?; if previews.is_empty() { return Err(RouteError::not_found("that pattern renames nothing")); } state.bulk.rename(pattern); state.bulk.done(); Ok(Response::from(leaving()) .toast(Tone::Success, format!("Renaming {} items.", previews.len()))) } /// The route that says a bulk modal is finished with, whichever of the three. /// /// `naming`'s `DONE` in a second consumer. See its header: the host's own flag /// is what keeps one of these up, so every exit has to say so as well as /// navigate. const DONE: &str = "/bulk/done"; /// `POST /bulk/done` fn done(state: &Panels<'_>, _request: Request) -> Result { state.bulk.done(); Ok(Response::from(leaving())) } /// The region the preview lands in. const PREVIEW: &str = "bulk-rename-preview"; /// The rename modal. fn renaming(state: &Panels<'_>, pattern: &str) -> Screen { let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Rename pattern")); // The tokens, as controls that put themselves in the box. // // Badges until 2026-08-22, with a comment naming the gap they were standing // in for: `quasi:vocabulary:text-into-field`, filed by the export port for // the same control. Max ruled it (`f35aafee`) and `Act::fills` landed in // quasi 0.55.0, so the workaround is over and the flip does not have to // lose the shipped palette. `Action::local` is the "no request goes out" // half: pressing one of these writes into the box and calls nothing. // // The renderer appends rather than replaces, which is what the shipped // modal's `pattern_input.push_str(token)` does, so `{name}_{bpm}` is still // built by pressing two of them. for token in TOKENS { body = body.with(Node::Act( Act::new(token, Action::local()).filling(PATTERN, token), )); } body = body.with(Node::Form { fields: vec![ Field::new(FieldKind::Text, PATTERN, "Pattern") .value(pattern) .hint("{name}_{bpm}") .changes(Action::post("/bulk/rename/preview")), ], submit: "Rename".to_owned(), action: Action::post("/bulk/rename"), }); closing(body.with(Node::Region( Slot::new(PREVIEW, RegionKind::Group).with(previewed(state, pattern)), ))) } /// The modal, with the one control a description cannot honestly say. /// /// See finding 1. `Act::key` names Escape because Escape is what actually /// dismisses an overlay, and the address is what a host with no Escape key has /// instead. fn closing(body: Slot) -> Screen { Screen::sidebar_content("Bulk") .with(body.with(Node::Act(Act::new("Cancel", Action::post(DONE)).key("esc")))) } /// What the pattern would do, old name beside new. /// /// Its own node so the preview route can answer it as a fragment, which is what /// keeps the overlay standing while it updates. fn previewed(state: &Panels<'_>, pattern: &str) -> Node { let previews = match state.bulk.previews(pattern) { Ok(previews) => previews, // The pattern is being typed, so half of it is not a pattern yet. Said // rather than drawn as an empty table, which would read as "this renames // nothing". Err(why) => { return Node::Notice { kind: Notice::Banner, tone: Tone::Danger, text: why, }; } }; if previews.is_empty() { return Node::empty("Nothing to rename."); } // Collisions counted once over the whole set rather than per row, which is // the shipped modal's own reasoning ("counting once is the whole point, // doing it per-row would be O(n^2)") and holds here for the same reason. let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new(); for (_, new) in &previews { *seen.entry(new.as_str()).or_insert(0) += 1; } let total = previews.len(); Node::Table { columns: vec![Column::new("Old"), Column::new("New")], // Said rather than implied, as of quasi 0.15: a described table of the // first fifty of five hundred was indistinguishable from a table of // fifty until `Node::Table` grew `more`, which is the gap this port // noted at 65abb3c. No `forward`, because there is nowhere to ask -- // the cap is a rendering budget and the rename acts on all of them. more: (total > SHOWN).then(|| Rest { paging: quasi_router::layout::Paging::more(SHOWN).of(total), forward: None, back: None, }), rows: previews .iter() .take(SHOWN) .map(|(old, new)| { let collides = seen.get(new.as_str()).copied().unwrap_or(0) > 1; Cells::new(vec![ Cell::new(old), // A collision is a tone on the value rather than a hover on // it, for the reason a suggestion's score is in its label in // `detail`: a reader with no pointer never sees a hover, and // this one is a warning about losing files. if collides { Cell::tag(Tag::badge(new.clone()).tone(Tone::Warning)) } else { Cell::new(new) }, ]) }) .collect(), } } /// Every name the operation touches. /// /// The shipped modals each scroll this list at a fixed height; how much of it /// fits is the host's, and how many there are is the description's. `Rest` is /// what says the second part, so the overflow is a described fact rather than a /// sentence at the end of the list. fn subjects(body: Slot, names: &[String]) -> Slot { body.with(Node::section(format!("{} chosen", names.len()))) .with(Node::List { rows: names .iter() .take(SHOWN) .map(quasi_router::Row::new) .collect(), more: (names.len() > SHOWN).then(|| Rest { paging: quasi_router::layout::Paging::more(SHOWN).of(names.len()), forward: None, back: None, }), }) } /// The screen a finished modal leaves behind. /// /// Going somewhere, because there is nothing else a description can say. See /// finding 1: this clears the layer stack rather than revealing what was under /// it, which happens to be right when the modal is done and is wrong when it is /// cancelled. fn leaving() -> Outcome { Outcome::Goto(Action::get(BACK)) } /// A screen drawn over whatever is showing. fn over(screen: Screen) -> Response { Response::from(Outcome::Over(screen)) }