Skip to main content

max / audiofiles

21.3 KB · 527 lines History Blame Raw
1 //! The three bulk modals, described rather than built.
2 //!
3 //! The sixth audiofiles port, and the first that is drawn **over** something.
4 //! `Outcome::Over` had never been handed to `quasi-immediate` by anything until
5 //! this: the renderer answered it and the answer was untested from an app.
6 //!
7 //! # Three addresses, where the last three ports had one
8 //!
9 //! `detail`, `sync` and `export` each answer many screens at one route, and the
10 //! header there states why: the state is something that happened rather than
11 //! somewhere you can go. This is the other side of that rule and it sharpens it.
12 //! Tag, Move and Rename are three addresses, because **the user picked which
13 //! one**. `BulkModal` being one enum in the app is an implementation of the
14 //! modal slot, not a fact about what the user did.
15 //!
16 //! So the question that decides it is not "how many shapes does the state have"
17 //! but *did the user choose this, or did they arrive here?* Chosen is an
18 //! address; arrived is a shape at one.
19 //!
20 //! # What the description deletes: the whole of `BulkModal`
21 //!
22 //! Eleven fields across three variants, and the described side reads none of
23 //! them. [`Bulk`](super::Bulk)'s header has the argument; the short form is that
24 //! the type does two jobs — a buffer for what is being typed, and an argument
25 //! list for the executor — and a described modal needs neither. The buffer is
26 //! what a `Runtime`'s `View` is, and the arguments come off the selection.
27 //!
28 //! The clearest instance is the rename preview. The shipped modal keeps
29 //! `pattern_input` and `previews` in app state and calls
30 //! `update_rename_previews` on every keystroke to write one into the other. The
31 //! preview is a pure function of the pattern and the selection, so here the
32 //! typed pattern goes to a route and the route answers a screen with the
33 //! previews in it. Same loop, and nothing is stored between two frames that was
34 //! not stored anyway.
35 //!
36 //! # THE FINDINGS
37 //!
38 //! **1. An overlay has no described way to close.** `Runtime::dismiss` is
39 //! private and Escape is the only thing that calls it. A modal with a Cancel
40 //! button is the most ordinary control there is, and a description cannot say
41 //! it: there is no `Action` meaning "close what is on top", only `Route` and
42 //! `External`. What this port does instead is navigate back to where the modal
43 //! was opened from, which *looks* right and is not — it clears the layer stack rather
44 //! than revealing what was under it, so the screen underneath is rebuilt and
45 //! whatever the user had typed into it is gone. On a touch host with no Escape
46 //! key it is worse than cosmetic: the navigation is the only way out.
47 //!
48 //! **2. An overlay cannot refresh itself.** Every described screen so far
49 //! refreshes by re-answering its own address, and `Outcome::Screen` clears the
50 //! layers on the way in — correctly, since a navigation replaces everything. So
51 //! an overlay that re-answers itself destroys itself, and one that answers
52 //! `Over` again stacks a second copy. `Outcome::Fragment` is the way to change
53 //! part of an open overlay and it works; what has no answer is "this whole
54 //! overlay again, still over what it was over". The host feels this from the
55 //! other side and the rule is in [`panel`](super::panel): **do not reload while
56 //! `Runtime::overlaid`**.
57 //!
58 //! **3. A third consumer for the disabled-control precondition.** The Apply
59 //! button on the tag modal is `add_enabled(false, ..)` with
60 //! `on_disabled_hover_text("None of the selected samples have this tag.")`,
61 //! which is the same missing fact the detail port filed on the Discovery
62 //! buttons (`9bab759c`) and makeover-layout filed on `Choice` (`e761833e`).
63 //! Three consumers, three members, one gap. Said as prose here for the same
64 //! reason and with the same complaint.
65
66 use quasi_router::layout::{FieldKind, Notice, Selector, Tone};
67 use quasi_router::{
68 Act, Action, Cell, Cells, Choice, Column, Field, Node, Outcome, RegionKind, Request, Response,
69 Rest, RouteError, Router, Screen, Slot, Tag,
70 };
71
72 use super::{Chosen, Panels};
73
74 /// The region each modal answers into.
75 const BODY: &str = "bulk-body";
76
77 /// What a tag modal submits.
78 const TAG: &str = "tag";
79 /// Whether it is adding or removing.
80 const MODE: &str = "mode";
81 /// What a move modal submits.
82 const FOLDER: &str = "folder";
83 /// What a rename modal submits.
84 const PATTERN: &str = "pattern";
85
86 /// The root, as a folder value. Empty rather than an id, because the root has
87 /// no node and `None` is what the app's `target_parent` already means.
88 const ROOT: &str = "";
89
90 /// Where a finished or cancelled modal goes, and the whole of finding 1.
91 ///
92 /// The detail screen's several-shape, which is the only place these three are
93 /// reachable from, so it is where they came from. Navigating there clears the
94 /// layer stack, which is not what dismissing an overlay means; it is the only
95 /// thing a description can say.
96 const BACK: &str = "/detail";
97
98 /// The pattern a rename modal starts from, the same one the shipped modal does.
99 const START: &str = "{name}";
100
101 /// The tokens a rename pattern may use.
102 ///
103 /// Named here rather than read from `audiofiles_core::rename`, which does not
104 /// expose them: the shipped modal has the same nine-element array inline. Worth
105 /// knowing that both copies exist, and that the core is where they should come
106 /// from if a tenth is ever added.
107 const TOKENS: [&str; 9] = [
108 "{name}",
109 "{ext}",
110 "{bpm}",
111 "{key}",
112 "{class}",
113 "{duration}",
114 "{n}",
115 "{nn}",
116 "{nnn}",
117 ];
118
119 /// How many rows are shown before the rest become a count.
120 ///
121 /// The shipped modal's cap, kept for a different reason. There it is a rendering
122 /// cost — "egui materialises every cell every frame; for a 500-row rename this
123 /// matters" — which is renderer policy and not the description's business. Here
124 /// it is honesty: a described list of the first fifty of five hundred is
125 /// otherwise indistinguishable from a list of fifty, and [`Rest`] is what says
126 /// which it is.
127 ///
128 /// This port was written against 0.14, where `Node::List` had `more` and
129 /// `Node::Table` had nothing, so both overflows were sentences at the end of a
130 /// list. **0.15 gave `Table` the same member** and both now say it properly.
131 /// Neither carries a `forward`: the cap is a rendering budget, the operation
132 /// acts on every chosen item either way, and there is no next page to ask for.
133 const SHOWN: usize = 50;
134
135 /// Register the three modals' routes.
136 pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
137 router
138 .get("/bulk/tag", tag_screen)
139 .post("/bulk/tag", tag)
140 .get("/bulk/move", move_screen)
141 .post("/bulk/move", move_to)
142 .get("/bulk/rename", rename_screen)
143 .post("/bulk/rename/preview", preview)
144 .post("/bulk/rename", rename)
145 .post(DONE, done)
146 }
147
148 /// `GET /bulk/tag`
149 fn tag_screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
150 let chosen = state.bulk.chosen();
151 if chosen.samples == 0 {
152 return Err(RouteError::not_found("no samples are chosen"));
153 }
154 Ok(over(tagging(state, &chosen, None, true)))
155 }
156
157 /// `POST /bulk/tag`
158 ///
159 /// Answers the list rather than the modal, because the modal is done. That is
160 /// also what closes it: see finding 2 in this module's header — a navigation is
161 /// the only thing that takes an overlay down, and here it happens to be right.
162 fn tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
163 let typed = request.payload.get(TAG).unwrap_or_default().trim();
164 let adding = request.payload.get(MODE) != Some("remove");
165 let chosen = state.bulk.chosen();
166
167 if typed.is_empty() {
168 return Ok(over(tagging(state, &chosen, Some(""), adding))
169 .toast(Tone::Danger, "Type a tag first."));
170 }
171 // The same refusal the shipped Apply button makes, made by the route as
172 // well: an address is reachable by typing, so a disabled control is an
173 // affordance rather than a guarantee.
174 if !adding && !state.bulk.known_tags().iter().any(|known| known == typed) {
175 return Err(RouteError::not_found(UNKNOWN));
176 }
177 state.bulk.tag(typed, adding);
178 state.bulk.done();
179 Ok(Response::from(leaving()).toast(
180 Tone::Success,
181 format!(
182 "{} \"{typed}\" {} {} samples.",
183 if adding { "Adding" } else { "Removing" },
184 if adding { "on" } else { "from" },
185 chosen.samples,
186 ),
187 ))
188 }
189
190 /// What the shipped Apply button says when it will not run.
191 const UNKNOWN: &str = "None of the selected samples have this tag.";
192
193 /// The tag modal.
194 fn tagging(state: &Panels<'_>, chosen: &Chosen, typed: Option<&str>, adding: bool) -> Screen {
195 let mut body = Slot::new(BODY, RegionKind::Pane)
196 .with(Node::page(format!("Tag {} samples", chosen.samples)));
197
198 // Add or remove, as one control rather than two selectable labels. The
199 // shipped modal draws `selectable_value(adding, true, ..)` twice, which is a
200 // segmented control spelled out.
201 body = body.with(Node::Select {
202 kind: Selector::Segmented,
203 options: vec![
204 (Choice::new("add", "Add tag"), None),
205 (Choice::new("remove", "Remove tag"), None),
206 ],
207 chosen: Some(if adding { "add" } else { "remove" }.to_owned()),
208 action: None,
209 });
210
211 let mut field = Field::new(FieldKind::Text, TAG, "Tag").hint("e.g. genre.electronic");
212 if let Some(typed) = typed {
213 field = field.value(typed);
214 }
215 body = body
216 .with(Node::Form {
217 fields: vec![field],
218 submit: "Apply".to_owned(),
219 action: Action::post("/bulk/tag"),
220 })
221 .with(Node::text(if adding {
222 "Will add to every selected sample that lacks it."
223 } else {
224 "Will remove from selected samples that have this tag."
225 }));
226
227 // Every tag the vault knows, as badges under the field. The shipped modal
228 // filters this set to a substring of what is typed, caps it at twelve, and
229 // fills the field when one is clicked -- so this is the **second consumer**
230 // of `quasi:vocabulary:text-into-field`, the finding the export port filed
231 // for the naming-pattern chips. A `Field` cannot say what completes it, so
232 // the set is named and the reader types.
233 //
234 // The narrowing is not described and should not be: what the app knows is
235 // the whole set, and how many of them a host shows while someone types is
236 // the host's business.
237 body = body.with(Node::section("Known tags"));
238 for known in state.bulk.known_tags().iter().take(SHOWN) {
239 body = body.with(Node::Token(Tag::badge(known.clone())));
240 }
241
242 closing(subjects(body, &chosen.names))
243 }
244
245 /// `GET /bulk/move`
246 fn move_screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
247 let chosen = state.bulk.chosen();
248 if chosen.names.is_empty() {
249 return Err(RouteError::not_found("nothing is chosen"));
250 }
251 Ok(over(moving(state, &chosen)))
252 }
253
254 /// `POST /bulk/move`
255 ///
256 /// The root is the empty value rather than a missing one, so "put these at the
257 /// top" and "the form sent nothing" stay different requests.
258 fn move_to(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
259 let chosen = request
260 .payload
261 .get(FOLDER)
262 .ok_or_else(|| RouteError::not_found("no destination named"))?;
263 let folder = if chosen == ROOT {
264 None
265 } else {
266 let id: i64 = chosen
267 .parse()
268 .map_err(|_| RouteError::not_found("no such folder"))?;
269 if !state.bulk.folders().iter().any(|folder| folder.id == id) {
270 return Err(RouteError::not_found("no such folder"));
271 }
272 Some(id)
273 };
274 let count = state.bulk.chosen().names.len();
275 state.bulk.move_to(folder);
276 state.bulk.done();
277 Ok(Response::from(leaving()).toast(Tone::Success, format!("Moving {count} items.")))
278 }
279
280 /// The move modal.
281 ///
282 /// A table of one column rather than a list, because picking a row is what this
283 /// screen is for and `Cells::activate` is what says a row is pressable. The
284 /// shipped modal draws `selectable_label` per directory with a substring filter
285 /// above it; the filter is not described, on the rule the tag completions
286 /// follow — narrowing a list while someone types is what a host does with a list
287 /// it was handed.
288 fn moving(state: &Panels<'_>, chosen: &Chosen) -> Screen {
289 let folders = state.bulk.folders();
290 let mut rows = vec![row("/", ROOT)];
291 rows.extend(
292 folders
293 .iter()
294 .map(|folder| row(&folder.path, &folder.id.to_string())),
295 );
296
297 let body = Slot::new(BODY, RegionKind::Pane)
298 .with(Node::page(format!("Move {} items", chosen.names.len())))
299 .with(Node::text("Choose where they go."))
300 .with(Node::Table {
301 columns: vec![Column::new("Folder")],
302 rows,
303 // Every folder in the vault, because a destination the picker does
304 // not show is a destination you cannot choose.
305 more: None,
306 });
307
308 closing(subjects(body, &chosen.names))
309 }
310
311 /// One destination, as a row that submits itself.
312 fn row(path: &str, value: &str) -> Cells {
313 Cells::new(vec![Cell::new(path)]).activate(Action::post("/bulk/move").carrying(FOLDER, value))
314 }
315
316 /// `GET /bulk/rename`
317 fn rename_screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
318 let chosen = state.bulk.chosen();
319 if chosen.names.is_empty() {
320 return Err(RouteError::not_found("nothing is chosen"));
321 }
322 Ok(over(renaming(state, START)))
323 }
324
325 /// `POST /bulk/rename/preview`
326 ///
327 /// What the shipped modal does with `update_rename_previews` on every keystroke,
328 /// except that nothing is stored: the pattern arrives, the previews are computed
329 /// from it, and the answer carries both.
330 ///
331 /// **`Outcome::Fragment` rather than a screen**, and that is finding 2 doing its
332 /// work: this overlay is open, and answering a whole screen would take it down.
333 /// A fragment replaces one region of whatever is showing, which is exactly what
334 /// a live preview is.
335 fn preview(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
336 let pattern = request.payload.get(PATTERN).unwrap_or_default();
337 Ok(Response::from(Outcome::Fragment {
338 region: PREVIEW.to_owned(),
339 node: previewed(state, pattern),
340 }))
341 }
342
343 /// `POST /bulk/rename`
344 fn rename(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
345 let pattern = request.payload.get(PATTERN).unwrap_or_default();
346 // Refused rather than run, because a pattern that does not parse renames
347 // every chosen file to nothing. The shipped modal disables the button on
348 // the same condition; this is the route saying it too.
349 let previews = state
350 .bulk
351 .previews(pattern)
352 .map_err(RouteError::not_found)?;
353 if previews.is_empty() {
354 return Err(RouteError::not_found("that pattern renames nothing"));
355 }
356 state.bulk.rename(pattern);
357 state.bulk.done();
358 Ok(Response::from(leaving())
359 .toast(Tone::Success, format!("Renaming {} items.", previews.len())))
360 }
361
362 /// The route that says a bulk modal is finished with, whichever of the three.
363 ///
364 /// `naming`'s `DONE` in a second consumer. See its header: the host's own flag
365 /// is what keeps one of these up, so every exit has to say so as well as
366 /// navigate.
367 const DONE: &str = "/bulk/done";
368
369 /// `POST /bulk/done`
370 fn done(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
371 state.bulk.done();
372 Ok(Response::from(leaving()))
373 }
374
375 /// The region the preview lands in.
376 const PREVIEW: &str = "bulk-rename-preview";
377
378 /// The rename modal.
379 fn renaming(state: &Panels<'_>, pattern: &str) -> Screen {
380 let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Rename pattern"));
381
382 // The tokens, as controls that put themselves in the box.
383 //
384 // Badges until 2026-08-22, with a comment naming the gap they were standing
385 // in for: `quasi:vocabulary:text-into-field`, filed by the export port for
386 // the same control. Max ruled it (`f35aafee`) and `Act::fills` landed in
387 // quasi 0.55.0, so the workaround is over and the flip does not have to
388 // lose the shipped palette. `Action::local` is the "no request goes out"
389 // half: pressing one of these writes into the box and calls nothing.
390 //
391 // The renderer appends rather than replaces, which is what the shipped
392 // modal's `pattern_input.push_str(token)` does, so `{name}_{bpm}` is still
393 // built by pressing two of them.
394 for token in TOKENS {
395 body = body.with(Node::Act(
396 Act::new(token, Action::local()).filling(PATTERN, token),
397 ));
398 }
399
400 body = body.with(Node::Form {
401 fields: vec![
402 Field::new(FieldKind::Text, PATTERN, "Pattern")
403 .value(pattern)
404 .hint("{name}_{bpm}")
405 .changes(Action::post("/bulk/rename/preview")),
406 ],
407 submit: "Rename".to_owned(),
408 action: Action::post("/bulk/rename"),
409 });
410
411 closing(body.with(Node::Region(
412 Slot::new(PREVIEW, RegionKind::Group).with(previewed(state, pattern)),
413 )))
414 }
415
416 /// The modal, with the one control a description cannot honestly say.
417 ///
418 /// See finding 1. `Act::key` names Escape because Escape is what actually
419 /// dismisses an overlay, and the address is what a host with no Escape key has
420 /// instead.
421 fn closing(body: Slot) -> Screen {
422 Screen::sidebar_content("Bulk")
423 .with(body.with(Node::Act(Act::new("Cancel", Action::post(DONE)).key("esc"))))
424 }
425
426 /// What the pattern would do, old name beside new.
427 ///
428 /// Its own node so the preview route can answer it as a fragment, which is what
429 /// keeps the overlay standing while it updates.
430 fn previewed(state: &Panels<'_>, pattern: &str) -> Node {
431 let previews = match state.bulk.previews(pattern) {
432 Ok(previews) => previews,
433 // The pattern is being typed, so half of it is not a pattern yet. Said
434 // rather than drawn as an empty table, which would read as "this renames
435 // nothing".
436 Err(why) => {
437 return Node::Notice {
438 kind: Notice::Banner,
439 tone: Tone::Danger,
440 text: why,
441 };
442 }
443 };
444 if previews.is_empty() {
445 return Node::empty("Nothing to rename.");
446 }
447
448 // Collisions counted once over the whole set rather than per row, which is
449 // the shipped modal's own reasoning ("counting once is the whole point,
450 // doing it per-row would be O(n^2)") and holds here for the same reason.
451 let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
452 for (_, new) in &previews {
453 *seen.entry(new.as_str()).or_insert(0) += 1;
454 }
455
456 let total = previews.len();
457 Node::Table {
458 columns: vec![Column::new("Old"), Column::new("New")],
459 // Said rather than implied, as of quasi 0.15: a described table of the
460 // first fifty of five hundred was indistinguishable from a table of
461 // fifty until `Node::Table` grew `more`, which is the gap this port
462 // noted at 65abb3c. No `forward`, because there is nowhere to ask --
463 // the cap is a rendering budget and the rename acts on all of them.
464 more: (total > SHOWN).then(|| Rest {
465 paging: quasi_router::layout::Paging::more(SHOWN).of(total),
466 forward: None,
467 back: None,
468 }),
469 rows: previews
470 .iter()
471 .take(SHOWN)
472 .map(|(old, new)| {
473 let collides = seen.get(new.as_str()).copied().unwrap_or(0) > 1;
474 Cells::new(vec![
475 Cell::new(old),
476 // A collision is a tone on the value rather than a hover on
477 // it, for the reason a suggestion's score is in its label in
478 // `detail`: a reader with no pointer never sees a hover, and
479 // this one is a warning about losing files.
480 if collides {
481 Cell::tag(Tag::badge(new.clone()).tone(Tone::Warning))
482 } else {
483 Cell::new(new)
484 },
485 ])
486 })
487 .collect(),
488 }
489 }
490
491 /// Every name the operation touches.
492 ///
493 /// The shipped modals each scroll this list at a fixed height; how much of it
494 /// fits is the host's, and how many there are is the description's. `Rest` is
495 /// what says the second part, so the overflow is a described fact rather than a
496 /// sentence at the end of the list.
497 fn subjects(body: Slot, names: &[String]) -> Slot {
498 body.with(Node::section(format!("{} chosen", names.len())))
499 .with(Node::List {
500 rows: names
501 .iter()
502 .take(SHOWN)
503 .map(quasi_router::Row::new)
504 .collect(),
505 more: (names.len() > SHOWN).then(|| Rest {
506 paging: quasi_router::layout::Paging::more(SHOWN).of(names.len()),
507 forward: None,
508 back: None,
509 }),
510 })
511 }
512
513 /// The screen a finished modal leaves behind.
514 ///
515 /// Going somewhere, because there is nothing else a description can say. See
516 /// finding 1: this clears the layer stack rather than revealing what was under
517 /// it, which happens to be right when the modal is done and is wrong when it is
518 /// cancelled.
519 fn leaving() -> Outcome {
520 Outcome::Goto(Action::get(BACK))
521 }
522
523 /// A screen drawn over whatever is showing.
524 fn over(screen: Screen) -> Response {
525 Response::from(Outcome::Over(screen))
526 }
527