Skip to main content

max / audiofiles

Describe the three bulk modals, drawn over what they cover The sixth audiofiles port, and the first thing to hand Outcome::Over to quasi-immediate: the renderer answered it and nothing had ever asked. Three addresses where the last three ports had one, and that sharpens the rule rather than breaking it. Tag, Move and Rename are separate because the user picked which one; a state machine's four screens share a route because the user arrived. Chosen is an address, arrived is a shape at one. The description deletes the whole of BulkModal -- eleven fields across three variants, none of them read. The type does two jobs: a buffer for what is being typed, which is what a Runtime's View is, and an argument list for the executor, which is derived from the selection. Clearest in the rename preview, which the shipped modal keeps in app state and rewrites on every keystroke, and which is a pure function of the pattern and the selection. Two host defects fixed on the way, both in how panel.rs asks the router: - answer() flattened every response to its Screen and rebuilt a fresh one around it, which silently dropped the notice. Every toast in settings, sync and detail was written and never shown. It returns the whole Response now, which is what Runtime::apply takes, and Goto redirects are followed rather than ignored. - the refresh loop reloads the address a screen came from, and an overlay is not a place, so that address is the screen underneath -- which answers a Screen, which clears the layer stack. An unconditional reload took the modal down on the frame after it opened. Gated on Runtime::overlaid. Two findings filed as quasicoherent problems, both from Over meeting an app: - an overlay has no described way to close, so every modal's Cancel is a navigation that rebuilds the screen underneath instead of revealing it, and on a touch host that is the only way out - an overlay cannot re-answer itself, so a live modal has no refresh; Fragment covers part of one and nothing covers the whole A third consumer for the disabled-control precondition (9bab759c): the Apply button hand-renders its own row because confirm_action_row cannot carry a disabled reason. 18 tests, 444 passing with the feature on. The default build is untouched.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 19:42 UTC
Signed with PGP, not checked
Commit: 65abb3c5544c093b9ac4fefe40b8db5cd783ab54
Parent: 31e048e
6 files changed, +1203 insertions, -81 deletions
M Cargo.lock +12 -12
@@ -7543,18 +7543,6 @@
7543 7543 "winnow 1.0.4",
7544 7544 ]
7545 7545
7546 - [[patch.unused]]
7547 - name = "kberg"
7548 - version = "0.1.0"
7549 -
7550 - [[patch.unused]]
7551 - name = "ops-status"
7552 - version = "0.1.0"
7553 -
7554 - [[patch.unused]]
7555 - name = "painhours"
7556 - version = "0.1.0"
7557 -
7558 7546 [[patch.unused]]
7559 7547 name = "quasi-axum"
7560 7548 version = "0.14.0"
@@ -7578,3 +7566,15 @@
7578 7566 [[patch.unused]]
7579 7567 name = "quasi-webview"
7580 7568 version = "0.14.0"
7569 +
7570 + [[patch.unused]]
7571 + name = "kberg"
7572 + version = "0.1.0"
7573 +
7574 + [[patch.unused]]
7575 + name = "ops-status"
7576 + version = "0.1.0"
7577 +
7578 + [[patch.unused]]
7579 + name = "painhours"
7580 + version = "0.1.0"
@@ -100,7 +100,6 @@
100 100 .post("/detail/duplicates", find_duplicates)
101 101 .post("/detail/selection/tags/{tag}/spread", spread_tag)
102 102 .post("/detail/selection/tags/{tag}/strip", strip_tag)
103 - .post("/detail/selection/edit", edit_selection)
104 103 }
105 104
106 105 /// `GET /detail`
@@ -198,12 +197,6 @@
198 197 Ok(screen(state).into())
199 198 }
200 199
201 - /// `POST /detail/selection/edit`
202 - fn edit_selection(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
203 - state.detail.edit_selection();
204 - Ok(screen(state).into())
205 - }
206 -
207 200 /// The tag a request names.
208 201 fn named(request: &Request) -> Result<String, RouteError> {
209 202 Ok(request.captures.require("tag")?.to_owned())
@@ -454,10 +447,16 @@
454 447
455 448 body = agreed(body, spread);
456 449 body = coverage(body, spread);
457 - body.with(Node::Act(Act::new(
458 - "Edit as bulk",
459 - Action::post("/detail/selection/edit"),
460 - )))
450 + // Three controls where the shipped panel has one. `draw_multi_summary`
451 + // offers "Edit as bulk" and the other two bulk operations are reached from
452 + // the file list's context menu, which is a place rather than a fact: what
453 + // they all act on is this selection, so this is where they are said.
454 + body.with(Node::Act(Act::new("Tag all", Action::get("/bulk/tag"))))
455 + .with(Node::Act(Act::new("Move all", Action::get("/bulk/move"))))
456 + .with(Node::Act(Act::new(
457 + "Rename all",
458 + Action::get("/bulk/rename"),
459 + )))
461 460 }
462 461
463 462 /// What every chosen sample says, where they say the same thing.
@@ -21,6 +21,7 @@
21 21 //! | [`Files`] | [`files`] | an [`Intent`], applied after the frame |
22 22 //! | [`Export`] | [`export`] | an [`Intent`], applied after the frame |
23 23 //! | [`Detail`] | [`detail`] | an [`Intent`], applied after the frame |
24 + //! | [`Bulk`] | [`bulk`] | an [`Intent`], applied after the frame |
24 25 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
25 26 //!
26 27 //! The themes are the settled rule from goingson's settings port applied first
@@ -29,8 +30,8 @@
29 30 //! quasi — was refused on 2026-08-09 and nothing here reopens it.
30 31 //!
31 32 //! Notably absent is anything `&mut`, and the right-hand column is why it can
32 - //! be. Two of the five write through a handle that already takes `&self`; the
33 - //! other three write to the app's own UI state, which a route cannot hold, so
33 + //! be. Two of the six write through a handle that already takes `&self`; the
34 + //! other four write to the app's own UI state, which a route cannot hold, so
34 35 //! they record an [`Intent`] and the panel applies it with the `&mut` the app
35 36 //! has anyway. See [`files`]'s header for the rule and [`export`]'s for what it
36 37 //! costs — an intent lands after the answer was built, which is what
@@ -51,6 +52,7 @@
51 52 // a choice made here.
52 53 #![allow(clippy::needless_pass_by_value)]
53 54
55 + pub mod bulk;
54 56 pub mod detail;
55 57 pub mod export;
56 58 pub mod files;
@@ -518,8 +520,12 @@
518 520 SpreadTag(String),
519 521 /// Untag every chosen sample that carries this tag.
520 522 StripTag(String),
521 - /// Open the bulk tag editor.
522 - EditSelection,
523 + /// Tag or untag every chosen sample.
524 + BulkTag(String, bool),
525 + /// Move everything chosen into this folder, or to the root.
526 + BulkMove(Option<i64>),
527 + /// Rename everything chosen by this pattern.
528 + BulkRename(String),
523 529 }
524 530
525 531 /// The app's file list, as the narrow thing a described screen borrows.
@@ -1190,9 +1196,6 @@
1190 1196
1191 1197 /// Take this tag off every chosen sample that carries it.
1192 1198 fn strip_tag(&self, tag: &str);
1193 -
1194 - /// Open the bulk tag editor over the whole selection.
1195 - fn edit_selection(&self);
1196 1199 }
1197 1200
1198 1201 /// The app's detail panel, as the narrow thing a described screen borrows.
@@ -1324,10 +1327,6 @@
1324 1327 fn strip_tag(&self, tag: &str) {
1325 1328 self.push(Intent::StripTag(tag.to_owned()));
1326 1329 }
1327 -
1328 - fn edit_selection(&self) {
1329 - self.push(Intent::EditSelection);
1330 - }
1331 1330 }
1332 1331
1333 1332 impl FromSelection<'_> {
@@ -1399,6 +1398,192 @@
1399 1398 }
1400 1399 }
1401 1400
1401 + /// What a bulk operation is about.
1402 + ///
1403 + /// The selection, reduced to what the three modals actually name. Deliberately
1404 + /// **not** the app's [`BulkModal`](crate::state::BulkModal): see [`Bulk`]'s
1405 + /// header, where that absence is the finding rather than an omission.
1406 + #[derive(Debug, Clone, PartialEq, Eq)]
1407 + pub struct Chosen {
1408 + /// What each chosen node is called, in the order they were chosen.
1409 + pub names: Vec<String>,
1410 + /// How many of them are samples rather than folders, which is what the tag
1411 + /// modal acts on and the other two do not care about.
1412 + pub samples: usize,
1413 + }
1414 +
1415 + /// A folder a bulk move may target.
1416 + #[derive(Debug, Clone, PartialEq, Eq)]
1417 + pub struct Folder {
1418 + /// The node's own id, which the address is built from.
1419 + pub id: i64,
1420 + /// The whole path, as the picker shows it.
1421 + pub path: String,
1422 + }
1423 +
1424 + /// Bulk operations over the selection, as much as a described screen needs.
1425 + ///
1426 + /// The sixth narrow trait, and the one where the port stopped copying the
1427 + /// shipped screen's state and started deleting it.
1428 + ///
1429 + /// # The app's `BulkModal` is not here, and that is the finding
1430 + ///
1431 + /// `BulkModal` is one enum with three variants holding eleven fields between
1432 + /// them, and it is doing two unrelated jobs at once:
1433 + ///
1434 + /// - **A view buffer.** `tag_input`, `adding`, `selected_idx`, `pattern_input`,
1435 + /// `previews`, `error`, and `import_wf.bulk_move_filter` beside it. Every one
1436 + /// of those is what the user has typed and picked, living in app state
1437 + /// because egui does not hold it for you.
1438 + /// - **An argument list.** `hashes`, `node_ids`, `names`, `directories`,
1439 + /// `targets`. Every one derived from the selection at the moment the modal
1440 + /// opened, so `execute_bulk_tag` has something to read.
1441 + ///
1442 + /// A described modal needs neither. The buffer is what a `Runtime`'s `View`
1443 + /// holds by definition, and the arguments are derived from the selection, which
1444 + /// this trait reads. So the whole type is view-state plumbing, and the port does
1445 + /// not reproduce it: nothing below opens a modal to find out what is in it.
1446 + ///
1447 + /// # What that costs at the commit, and why it is worth it
1448 + ///
1449 + /// The app's own executors read their arguments back out of `BulkModal`, so the
1450 + /// host has to put them there before calling one. That is three lines in
1451 + /// [`panel`] and it is the right three lines: the alternative is a second
1452 + /// implementation of bulk tagging with its own undo entry, which is exactly what
1453 + /// this port exists to avoid. **The description holds what was typed; the host
1454 + /// hands it to the command that already exists.**
1455 + ///
1456 + /// # The preview is on this trait rather than in the route
1457 + ///
1458 + /// [`previews`](Self::previews) is a pure function of a pattern and the
1459 + /// selection — `RenamePattern::parse` and `resolve_all` touch nothing — so a
1460 + /// route *could* compute it. It does not, for [`Sync::quote_cents`]'s reason:
1461 + /// naming what a pattern expands to is the app's, and a description that
1462 + /// reimplemented it would be a second answer free to disagree with the first.
1463 + pub trait Bulk {
1464 + /// What is chosen.
1465 + fn chosen(&self) -> Chosen;
1466 +
1467 + /// Every tag the vault knows, for completing what is typed.
1468 + fn known_tags(&self) -> Vec<String>;
1469 +
1470 + /// Every folder a move may target.
1471 + fn folders(&self) -> Vec<Folder>;
1472 +
1473 + /// What this pattern would rename the chosen nodes to, old beside new.
1474 + ///
1475 + /// # Errors
1476 + /// What is wrong with the pattern, as the app phrases it.
1477 + fn previews(&self, pattern: &str) -> Result<Vec<(String, String)>, String>;
1478 +
1479 + /// Put this tag on every chosen sample, or take it off every one.
1480 + fn tag(&self, tag: &str, adding: bool);
1481 +
1482 + /// Move everything chosen into this folder, or to the root.
1483 + fn move_to(&self, folder: Option<i64>);
1484 +
1485 + /// Rename everything chosen by this pattern.
1486 + fn rename(&self, pattern: &str);
1487 + }
1488 +
1489 + /// The app's selection, as the narrow thing the bulk screens borrow.
1490 + pub struct FromBulk<'a> {
1491 + /// What the app has selected.
1492 + pub state: &'a crate::state::BrowserState,
1493 + /// What the described screen asked for, applied after the frame.
1494 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
1495 + }
1496 +
1497 + impl Bulk for FromBulk<'_> {
1498 + fn chosen(&self) -> Chosen {
1499 + let nodes = self.state.selected_nodes();
1500 + Chosen {
1501 + samples: nodes
1502 + .iter()
1503 + .filter(|node| node.node.sample_hash.is_some())
1504 + .count(),
1505 + names: nodes.iter().map(|node| node.node.name.clone()).collect(),
1506 + }
1507 + }
1508 +
1509 + fn known_tags(&self) -> Vec<String> {
1510 + self.state.all_tags.iter().cloned().collect()
1511 + }
1512 +
1513 + fn folders(&self) -> Vec<Folder> {
1514 + let Some(vfs) = self.state.current_vfs_id() else {
1515 + return Vec::new();
1516 + };
1517 + self.state
1518 + .backend
1519 + .list_all_directories(vfs)
1520 + .unwrap_or_default()
1521 + .into_iter()
1522 + .map(|(id, path)| Folder {
1523 + id: id.as_i64(),
1524 + path,
1525 + })
1526 + .collect()
1527 + }
1528 +
1529 + fn previews(&self, pattern: &str) -> Result<Vec<(String, String)>, String> {
1530 + use audiofiles_core::rename::{RenameContext, RenamePattern};
1531 +
1532 + let parsed = RenamePattern::parse(pattern).map_err(|error| error.to_string())?;
1533 + let nodes = self.state.selected_nodes();
1534 + let contexts: Vec<RenameContext> = nodes
1535 + .iter()
1536 + .enumerate()
1537 + .map(|(index, node)| {
1538 + let (name, extension) = audiofiles_core::util::split_name_ext(&node.node.name);
1539 + RenameContext {
1540 + name,
1541 + extension,
1542 + bpm: node.bpm,
1543 + musical_key: node.musical_key.clone(),
1544 + duration: node.duration,
1545 + index,
1546 + }
1547 + })
1548 + .collect();
1549 + Ok(contexts
1550 + .iter()
1551 + .zip(parsed.resolve_all(&contexts))
1552 + .map(|(context, stem)| {
1553 + (
1554 + whole(&context.name, &context.extension),
1555 + whole(&stem, &context.extension),
1556 + )
1557 + })
1558 + .collect())
1559 + }
1560 +
1561 + fn tag(&self, tag: &str, adding: bool) {
1562 + self.intents
1563 + .borrow_mut()
1564 + .push(Intent::BulkTag(tag.to_owned(), adding));
1565 + }
1566 +
1567 + fn move_to(&self, folder: Option<i64>) {
1568 + self.intents.borrow_mut().push(Intent::BulkMove(folder));
1569 + }
1570 +
1571 + fn rename(&self, pattern: &str) {
1572 + self.intents
1573 + .borrow_mut()
1574 + .push(Intent::BulkRename(pattern.to_owned()));
1575 + }
1576 + }
1577 +
1578 + /// A stem and an extension as one filename.
1579 + fn whole(stem: &str, extension: &str) -> String {
1580 + if extension.is_empty() {
1581 + stem.to_owned()
1582 + } else {
1583 + format!("{stem}.{extension}")
1584 + }
1585 + }
1586 +
1402 1587 /// A theme the host resolved, as the description needs to name it.
1403 1588 ///
1404 1589 /// Three strings rather than the app's own `ThemeMeta`, so the described screen
@@ -1432,6 +1617,11 @@
1432 1617 pub export: &'a dyn Export,
1433 1618 /// The selection, for the detail screen.
1434 1619 pub detail: &'a dyn Detail,
1620 + /// The selection again, for the bulk screens. Two capabilities over one
1621 + /// selection rather than one, because they need different things of it and
1622 + /// the narrowing is the point: the detail screen may not move a file and
1623 + /// the bulk screens may not read an analysis.
1624 + pub bulk: &'a dyn Bulk,
1435 1625 /// The themes on offer, resolved by the host at startup.
1436 1626 pub themes: &'a [ThemeChoice],
1437 1627 }
@@ -1442,9 +1632,9 @@
1442 1632 /// cost is nothing, and building it fresh is what lets the state borrow.
1443 1633 #[must_use]
1444 1634 pub fn router<'a>() -> Router<Panels<'a>> {
1445 - detail::routes(export::routes(files::routes(sync::routes(
1635 + bulk::routes(detail::routes(export::routes(files::routes(sync::routes(
1446 1636 settings::routes(Router::new()),
1447 - ))))
1637 + )))))
1448 1638 }
1449 1639
1450 1640 #[cfg(test)]
@@ -28,13 +28,13 @@
28 28
29 29 use audiofiles_sync::SyncManager;
30 30 use quasi_immediate::{Immediate, Runtime, Step};
31 - use quasi_router::{Request, Response, Screen};
31 + use quasi_router::{Request, Response};
32 32
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromContents, FromExport, FromSelection, FromSyncManager, Intent, Panels, Setting,
37 - Sync, ThemeChoice, Unconfigured,
36 + FromBackend, FromBulk, FromContents, FromExport, FromSelection, FromSyncManager, Intent,
37 + Panels, Setting, Sync, ThemeChoice, Unconfigured,
38 38 };
39 39 use crate::state::BrowserState;
40 40 use crate::ui::theme;
@@ -329,7 +329,58 @@
329 329 let targets = across(state, |node| node.tags.contains(&tag));
330 330 state.remove_tag_from_hashes(&tag, &targets);
331 331 }
332 - Intent::EditSelection => state.open_bulk_tag_modal(),
332 + // The three bulk operations, each the same shape: the described
333 + // modal held what was typed, so the host opens the app's own modal
334 + // to derive the arguments from the selection, puts the typed value
335 + // where the executor reads it, and runs the executor.
336 + //
337 + // Three lines rather than a second implementation of bulk tagging.
338 + // `execute_bulk_*` owns the undo entry, the status line and the
339 + // partial-failure counting, and none of that should exist twice.
340 + // See `Bulk`'s header on why the description does not carry
341 + // `BulkModal` even though the commit path does.
342 + Intent::BulkTag(typed, adding) => {
343 + state.open_bulk_tag_modal();
344 + if let Some(crate::state::BulkModal::Tag {
345 + tag_input,
346 + adding: mode,
347 + ..
348 + }) = &mut state.bulk_modal
349 + {
350 + *tag_input = typed;
351 + *mode = adding;
352 + }
353 + state.execute_bulk_tag();
354 + state.close_bulk_modal();
355 + }
356 + Intent::BulkMove(folder) => {
357 + state.open_bulk_move_modal();
358 + if let Some(crate::state::BulkModal::Move {
359 + directories,
360 + selected_idx,
361 + ..
362 + }) = &mut state.bulk_modal
363 + {
364 + // Back from the id the address named to the index the
365 + // executor reads. The description addresses a folder by its
366 + // own id, for the reason the file list addresses a row by
367 + // one: an index is a fact about a list that was built once.
368 + *selected_idx = folder
369 + .and_then(|id| directories.iter().position(|(at, _)| at.as_i64() == id));
370 + }
371 + state.execute_bulk_move();
372 + state.close_bulk_modal();
373 + }
374 + Intent::BulkRename(pattern) => {
375 + state.open_bulk_rename_modal();
376 + if let Some(crate::state::BulkModal::Rename { pattern_input, .. }) =
377 + &mut state.bulk_modal
378 + {
379 + *pattern_input = pattern;
380 + }
381 + state.execute_bulk_rename();
382 + state.close_bulk_modal();
383 + }
333 384 }
334 385 }
335 386 }
@@ -502,7 +553,13 @@
502 553 let runtime = match runtime {
503 554 Some(runtime) => runtime,
504 555 none => match answer(host, Request::get(home)) {
505 - Ok(screen) => none.insert(Runtime::new(screen)),
556 + Ok(response) => match response.outcome {
557 + quasi_router::Outcome::Screen(screen) => none.insert(Runtime::new(screen)),
558 + other => {
559 + ui.label(format!("the home address answered {other:?}"));
560 + return;
561 + }
562 + },
506 563 Err(message) => {
507 564 ui.label(message);
508 565 return;
@@ -514,7 +571,14 @@
514 571 // than showing the previous answer for one more frame. `reload`
515 572 // re-asks the address the screen came from, and the runtime keeps
516 573 // what the user has typed and ticked across it.
517 - if refresh {
574 + //
575 + // **Never while an overlay is open.** `reload` re-asks the address
576 + // the screen came from, and an overlay is not a place, so that
577 + // address is the screen *underneath* -- which answers
578 + // `Outcome::Screen`, which clears the layer stack. An unconditional
579 + // refresh would take the modal down on the frame after it opened.
580 + // See `bulk`'s header, finding 2.
581 + if refresh && !runtime.overlaid() {
518 582 let step = runtime.reload();
519 583 perform(runtime, ui, host, step);
520 584 }
@@ -530,12 +594,7 @@
530 594 fn perform(runtime: &mut Runtime, ui: &mut egui::Ui, host: &Host<'_>, step: Step) {
531 595 match step {
532 596 Step::Idle => {}
533 - Step::Call(request) => match answer(host, request.clone()) {
534 - Ok(screen) => {
535 - runtime.apply(&request, Response::screen(screen));
536 - }
537 - Err(message) => runtime.say(message),
538 - },
597 + Step::Call(request) => call(runtime, host, request),
539 598 // A described control asked before acting. Drawn where it is asked
540 599 // rather than in a second window, since it is about the control.
541 600 Step::Ask(question) => {
@@ -555,6 +614,50 @@
555 614 }
556 615 }
557 616
617 + /// Ask the router and put the whole answer on the screen.
618 + ///
619 + /// **The whole answer, which it was not until 2026-08-16.** This used to
620 + /// flatten the response to its `Screen` and rebuild a fresh `Response` around
621 + /// it, which silently dropped two things every route can say: the `notice`, so
622 + /// every `toast` in `settings`, `sync` and `detail` was written and never shown,
623 + /// and any outcome that is not a screen, so `Outcome::Over` could not have
624 + /// worked at all. `Runtime::apply` takes a `Response` because a response is
625 + /// what it is for.
626 + ///
627 + /// The loop is `Goto`: the runtime answers a redirect with the request to make
628 + /// next rather than making it, since asking is the host's. Bounded, because a
629 + /// route that redirects to itself is a bug and a loop here would be a hang
630 + /// inside a frame.
631 + fn call(runtime: &mut Runtime, host: &Host<'_>, request: Request) {
632 + let mut request = request;
633 + for _ in 0..REDIRECTS {
634 + let response = match answer(host, request.clone()) {
635 + Ok(response) => response,
636 + Err(message) => {
637 + runtime.say(message);
638 + return;
639 + }
640 + };
641 + // Somewhere outside the app is the host's to perform, and nothing comes
642 + // back from it. The runtime would answer `None` here and the handoff
643 + // would never happen.
644 + if let quasi_router::Outcome::Goto(action) = &response.outcome
645 + && action.destination.route().is_none()
646 + {
647 + open_externally(action.destination.as_str());
648 + return;
649 + }
650 + match runtime.apply(&request, response) {
651 + Some(next) => request = next,
652 + None => return,
653 + }
654 + }
655 + runtime.say("that address kept redirecting");
656 + }
657 +
658 + /// How many `Goto`s one press may chain before the host calls it a loop.
659 + const REDIRECTS: usize = 8;
660 +
558 661 /// Hand an address to the desktop.
559 662 ///
560 663 /// The one piece of platform knowledge in the port, and it is the host's by
@@ -578,7 +681,7 @@
578 681 }
579 682
580 683 /// Ask the router, and flatten a refusal into something a user can read.
581 - fn answer(host: &Host<'_>, request: Request) -> Result<Screen, String> {
684 + fn answer(host: &Host<'_>, request: Request) -> Result<Response, String> {
582 685 let Host {
583 686 state,
584 687 sync,
@@ -596,31 +699,19 @@
596 699 let files = FromContents { state, intents };
597 700 let export = FromExport { state, intents };
598 701 let detail = FromSelection { state, intents };
702 + let bulk = FromBulk { state, intents };
599 703 let panels = Panels {
600 704 config: &config,
601 705 sync,
602 706 files: &files,
603 707 export: &export,
604 708 detail: &detail,
709 + bulk: &bulk,
605 710 themes,
606 711 };
607 - let response = super::router()
712 + super::router()
608 713 .handle(&panels, request)
609 - .map_err(|error| error.message.clone())?;
610 - match response.outcome {
611 - quasi_router::Outcome::Screen(screen) => Ok(screen),
612 - // A described control that goes somewhere the host performs. Done here
613 - // rather than propagated, because the caller asked for a screen and this
614 - // one keeps the screen it had.
615 - quasi_router::Outcome::Goto(action) => {
616 - open_externally(action.destination.as_str());
617 - Err(format!(
618 - "Opened {} in your browser.",
619 - action.destination.as_str()
620 - ))
621 - }
622 - other => Err(format!("the routes answered {other:?}")),
623 - }
714 + .map_err(|error| error.message.clone())
624 715 }
625 716
626 717 /// The themes the host has resolved, as the description names them.
@@ -12,9 +12,10 @@
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 14 use super::{
15 - Analysis, Channels, ColumnsShown, Config, Coverage, Detail, Detailed, Export, Files, Focus,
16 - Format, Panels, Phase, Pricing, ProfileChoice, Sample, Setting, Settings, Shared, Source,
17 - Spread, State, Status, Subject, Subscription, Suggested, Sync, Tagged, ThemeChoice, router,
15 + Analysis, Bulk, Channels, Chosen, ColumnsShown, Config, Coverage, Detail, Detailed, Export,
16 + Files, Focus, Folder, Format, Panels, Phase, Pricing, ProfileChoice, Sample, Setting, Settings,
17 + Shared, Source, Spread, State, Status, Subject, Subscription, Suggested, Sync, Tagged,
18 + ThemeChoice, router,
18 19 };
19 20
20 21 /// A config store in memory.
@@ -208,6 +209,7 @@
208 209 let themes = themes();
209 210 let state = Panels {
210 211 detail: &Unfocused,
212 + bulk: &Unchosen,
211 213 config: &store,
212 214 sync: &sync,
213 215 files: &files,
@@ -265,6 +267,7 @@
265 267 let themes = themes();
266 268 let state = Panels {
267 269 detail: &Unfocused,
270 + bulk: &Unchosen,
268 271 config: &store,
269 272 sync: &sync,
270 273 files,
@@ -275,15 +278,30 @@
275 278 }
276 279
277 280 /// The table on a screen.
281 + ///
282 + /// Descends into a `Node::Region`, because a region is a slot inside a node and
283 + /// `nodes` only walks the screen's own slots. The rename preview lives in one so
284 + /// that a fragment can replace it.
278 285 fn table_of(screen: &Screen) -> (Vec<quasi_router::Column>, Vec<quasi_router::Cells>) {
286 + fn find(body: &[Node]) -> Option<(Vec<quasi_router::Column>, Vec<quasi_router::Cells>)> {
287 + for node in body {
288 + match node {
289 + Node::Table { columns, rows } => return Some((columns.clone(), rows.clone())),
290 + Node::Region(slot) => {
291 + if let Some(found) = find(&slot.body) {
292 + return Some(found);
293 + }
294 + }
295 + _ => {}
296 + }
297 + }
298 + None
299 + }
300 +
279 301 screen
280 302 .slots
281 303 .iter()
282 - .flat_map(|slot| &slot.body)
283 - .find_map(|node| match node {
284 - Node::Table { columns, rows } => Some((columns.clone(), rows.clone())),
285 - _ => None,
286 - })
304 + .find_map(|slot| find(&slot.body))
287 305 .expect("the screen draws a table")
288 306 }
289 307
@@ -376,6 +394,7 @@
376 394 let files = FakeFiles::default();
377 395 let state = Panels {
378 396 detail: &Unfocused,
397 + bulk: &Unchosen,
379 398 config: &store,
380 399 sync: &sync,
381 400 files: &files,
@@ -422,6 +441,7 @@
422 441 let files = FakeFiles::default();
423 442 let state = Panels {
424 443 detail: &Unfocused,
444 + bulk: &Unchosen,
425 445 config: &store,
426 446 sync: &sync,
427 447 files: &files,
@@ -462,6 +482,7 @@
462 482 let files = FakeFiles::default();
463 483 let state = Panels {
464 484 detail: &Unfocused,
485 + bulk: &Unchosen,
465 486 config: &store,
466 487 sync: &sync,
467 488 files: &files,
@@ -488,6 +509,7 @@
488 509 let files = FakeFiles::default();
489 510 let state = Panels {
490 511 detail: &Unfocused,
512 + bulk: &Unchosen,
491 513 config: &store,
492 514 sync: &sync,
493 515 files: &files,
@@ -537,6 +559,7 @@
537 559 let files = FakeFiles::default();
538 560 let state = Panels {
539 561 detail: &Unfocused,
562 + bulk: &Unchosen,
540 563 config: &store,
541 564 sync: &sync,
542 565 files: &files,
@@ -582,6 +605,7 @@
582 605 let files = FakeFiles::default();
583 606 let state = Panels {
584 607 detail: &Unfocused,
608 + bulk: &Unchosen,
585 609 config: &store,
586 610 sync: &sync,
587 611 files: &files,
@@ -733,6 +757,7 @@
733 757 let files = FakeFiles::default();
734 758 let state = Panels {
735 759 detail: &Unfocused,
760 + bulk: &Unchosen,
736 761 config: &store,
737 762 sync,
738 763 files: &files,
@@ -1664,7 +1689,6 @@
1664 1689 fn find_duplicates(&self) {}
1665 1690 fn spread_tag(&self, _tag: &str) {}
1666 1691 fn strip_tag(&self, _tag: &str) {}
1667 - fn edit_selection(&self) {}
1668 1692 }
1669 1693
1670 1694 /// A detail panel in memory, recording what was asked of it.
@@ -1738,10 +1762,6 @@
1738 1762 fn strip_tag(&self, tag: &str) {
1739 1763 self.note(format!("strip {tag}"));
1740 1764 }
1741 -
1742 - fn edit_selection(&self) {
1743 - self.note("bulk");
1744 - }
1745 1765 }
1746 1766
1747 1767 /// A router call against this detail panel.
@@ -1756,6 +1776,7 @@
1756 1776 files: &files,
1757 1777 export: &Idle,
1758 1778 detail,
1779 + bulk: &Unchosen,
1759 1780 themes: &themes,
1760 1781 };
1761 1782 router().handle(&state, request)
@@ -2079,8 +2100,7 @@
2079 2100 let detail = FakeDetail::at(several(mixed()));
2080 2101 detailing(&detail, Request::post("/detail/selection/tags/loop/spread")).unwrap();
2081 2102 detailing(&detail, Request::post("/detail/selection/tags/drums/strip")).unwrap();
2082 - detailing(&detail, Request::post("/detail/selection/edit")).unwrap();
2083 - assert_eq!(detail.asked(), ["spread loop", "strip drums", "bulk"]);
2103 + assert_eq!(detail.asked(), ["spread loop", "strip drums"]);
2084 2104 }
2085 2105
2086 2106 #[test]
@@ -2096,7 +2116,7 @@
2096 2116 let screen = detailed(&detail);
2097 2117
2098 2118 assert!(said(&screen).contains("No sample metadata to summarize"));
2099 - assert!(acts(&screen).is_empty());
2119 + assert!(dead(&screen).is_empty());
2100 2120 }
2101 2121
2102 2122 #[test]
@@ -2150,3 +2170,469 @@
2150 2170 })
2151 2171 .expect("the screen draws a list")
2152 2172 }
2173 +
2174 + // The bulk modals.
2175 +
2176 + /// A selection with nothing in it.
2177 + ///
2178 + /// [`Unfocused`]'s peer, and here for the same reason `Offline` is.
2179 + struct Unchosen;
2180 +
2181 + impl Bulk for Unchosen {
2182 + fn chosen(&self) -> Chosen {
2183 + Chosen {
2184 + names: Vec::new(),
2185 + samples: 0,
2186 + }
2187 + }
2188 +
2189 + fn known_tags(&self) -> Vec<String> {
2190 + Vec::new()
2191 + }
2192 +
2193 + fn folders(&self) -> Vec<Folder> {
2194 + Vec::new()
2195 + }
2196 +
2197 + fn previews(&self, _pattern: &str) -> Result<Vec<(String, String)>, String> {
2198 + Ok(Vec::new())
2199 + }
2200 +
2201 + fn tag(&self, _tag: &str, _adding: bool) {}
2202 + fn move_to(&self, _folder: Option<i64>) {}
2203 + fn rename(&self, _pattern: &str) {}
2204 + }
2205 +
2206 + /// A selection in memory, recording what was asked of it.
2207 + struct FakeBulk {
2208 + chosen: Chosen,
2209 + tags: Vec<String>,
2210 + folders: Vec<Folder>,
2211 + asked: RefCell<Vec<String>>,
2212 + }
2213 +
2214 + impl FakeBulk {
2215 + fn of(names: &[&str], samples: usize) -> Self {
2216 + Self {
2217 + chosen: Chosen {
2218 + names: names.iter().map(|name| (*name).to_owned()).collect(),
2219 + samples,
2220 + },
2221 + tags: vec!["drums".to_owned(), "loop".to_owned()],
2222 + folders: vec![
2223 + Folder {
2224 + id: 3,
2225 + path: "/kits".to_owned(),
2226 + },
2227 + Folder {
2228 + id: 4,
2229 + path: "/kits/808".to_owned(),
2230 + },
2231 + ],
2232 + asked: RefCell::new(Vec::new()),
2233 + }
2234 + }
2235 +
2236 + fn asked(&self) -> Vec<String> {
2237 + self.asked.borrow().clone()
2238 + }
2239 + }
2240 +
2241 + impl Bulk for FakeBulk {
2242 + fn chosen(&self) -> Chosen {
2243 + self.chosen.clone()
2244 + }
2245 +
2246 + fn known_tags(&self) -> Vec<String> {
2247 + self.tags.clone()
2248 + }
2249 +
2250 + fn folders(&self) -> Vec<Folder> {
2251 + self.folders.clone()
2252 + }
2253 +
2254 + /// A stand-in for the app's rename engine, with the two answers the screen
2255 + /// branches on: a pattern with no `{` is a literal, which renames every file
2256 + /// to the same name, and an unclosed `{` is what half-typed looks like.
2257 + fn previews(&self, pattern: &str) -> Result<Vec<(String, String)>, String> {
2258 + if pattern.contains('{') && !pattern.contains('}') {
2259 + return Err("unclosed token".to_owned());
2260 + }
2261 + Ok(self
2262 + .chosen
2263 + .names
2264 + .iter()
2265 + .map(|name| {
2266 + let new = if pattern.contains("{name}") {
2267 + pattern.replace("{name}", name.trim_end_matches(".wav"))
2268 + } else {
2269 + pattern.to_owned()
2270 + };
2271 + (name.clone(), format!("{new}.wav"))
2272 + })
2273 + .collect())
2274 + }
2275 +
2276 + fn tag(&self, tag: &str, adding: bool) {
2277 + self.asked
2278 + .borrow_mut()
2279 + .push(format!("{} {tag}", if adding { "add" } else { "remove" }));
2280 + }
2281 +
2282 + fn move_to(&self, folder: Option<i64>) {
2283 + self.asked.borrow_mut().push(match folder {
2284 + Some(id) => format!("move {id}"),
2285 + None => "move root".to_owned(),
2286 + });
2287 + }
2288 +
2289 + fn rename(&self, pattern: &str) {
2290 + self.asked.borrow_mut().push(format!("rename {pattern}"));
2291 + }
2292 + }
2293 +
2294 + /// A router call against this selection.
2295 + fn bulking(bulk: &FakeBulk, request: Request) -> Result<Response, quasi_router::RouteError> {
2296 + let store = Store::default();
2297 + let sync = Offline;
2298 + let files = FakeFiles::default();
2299 + let themes = themes();
2300 + let state = Panels {
2301 + config: &store,
2302 + sync: &sync,
2303 + files: &files,
2304 + export: &Idle,
2305 + detail: &Unfocused,
2306 + bulk,
2307 + themes: &themes,
2308 + };
2309 + router().handle(&state, request)
2310 + }
2311 +
2312 + /// The screen a bulk address answers, and the outcome it came in.
2313 + fn overlay(response: &Response) -> &Screen {
2314 + match &response.outcome {
2315 + Outcome::Over(screen) => screen,
2316 + other => panic!("expected an overlay, got {other:?}"),
2317 + }
2318 + }
2319 +
2320 + #[test]
2321 + fn every_bulk_modal_is_drawn_over_what_is_showing() {
2322 + // THE POINT OF THIS PORT. `Outcome::Over` is what a modal is, and nothing
2323 + // had handed one to the egui renderer before this: a modal that answered
2324 + // `Screen` would replace the list underneath instead of covering it.
2325 + let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2);
2326 + for address in ["/bulk/tag", "/bulk/move", "/bulk/rename"] {
2327 + let response = bulking(&bulk, Request::get(address)).unwrap();
2328 + assert!(
2329 + matches!(response.outcome, Outcome::Over(_)),
2330 + "{address} did not answer an overlay"
2331 + );
2332 + }
2333 + }
2334 +
2335 + #[test]
2336 + fn a_bulk_modal_refuses_to_open_over_nothing() {
2337 + let empty = FakeBulk::of(&[], 0);
2338 + for address in ["/bulk/tag", "/bulk/move", "/bulk/rename"] {
2339 + assert!(bulking(&empty, Request::get(address)).is_err());
2340 + }
2341 +
2342 + // Folders can be moved and renamed but not tagged, which is the shipped
2343 + // app's rule: `open_bulk_tag_modal` returns early on an empty hash list.
2344 + let folders = FakeBulk::of(&["kits", "loops"], 0);
2345 + assert!(bulking(&folders, Request::get("/bulk/tag")).is_err());
2346 + assert!(bulking(&folders, Request::get("/bulk/move")).is_ok());
2347 + assert!(bulking(&folders, Request::get("/bulk/rename")).is_ok());
2348 + }
2349 +
2350 + #[test]
2351 + fn the_tag_modal_names_what_it_will_touch_and_what_the_vault_knows() {
2352 + let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2);
2353 + let response = bulking(&bulk, Request::get("/bulk/tag")).unwrap();
2354 + let screen = overlay(&response);
2355 +
2356 + assert!(said(screen).contains("Tag 2 samples"));
2357 + assert!(said(screen).contains("kick.wav"));
2358 +
2359 + // The known tags, as badges. THE FINDING is that a field cannot say what
2360 + // completes it, so the set is named beside it -- the same workaround the
2361 + // export port's naming tokens use, and its second consumer.
2362 + let known: Vec<String> = nodes(screen)
2363 + .iter()
2364 + .filter_map(|node| match node {
2365 + Node::Token(tag) => Some(tag.label.clone()),
2366 + _ => None,
2367 + })
2368 + .collect();
2369 + assert_eq!(known, ["drums", "loop"]);
2370 + }
2371 +
2372 + #[test]
2373 + fn tagging_carries_the_typed_tag_and_which_way_it_goes() {
2374 + let bulk = FakeBulk::of(&["kick.wav"], 1);
2375 + bulking(
2376 + &bulk,
2377 + Request::post("/bulk/tag").sending(
2378 + Params::new()
2379 + .with("tag".to_owned(), "genre.house".to_owned())
2380 + .with("mode".to_owned(), "add".to_owned()),
2381 + ),
2382 + )
2383 + .unwrap();
2384 + bulking(
2385 + &bulk,
2386 + Request::post("/bulk/tag").sending(
2387 + Params::new()
2388 + .with("tag".to_owned(), "drums".to_owned())
2389 + .with("mode".to_owned(), "remove".to_owned()),
2390 + ),
2391 + )
2392 + .unwrap();
2393 + assert_eq!(bulk.asked(), ["add genre.house", "remove drums"]);
2394 + }
2395 +
2396 + #[test]
2397 + fn removing_a_tag_the_vault_does_not_know_is_refused_by_the_route() {
2398 + // The shipped modal disables Apply on this condition; the route refuses it
2399 + // too, because an address is reachable by typing.
2400 + let bulk = FakeBulk::of(&["kick.wav"], 1);
2401 + let refused = bulking(
2402 + &bulk,
2403 + Request::post("/bulk/tag").sending(
2404 + Params::new()
2405 + .with("tag".to_owned(), "nothing-has-this".to_owned())
2406 + .with("mode".to_owned(), "remove".to_owned()),
2407 + ),
2408 + );
2409 + assert!(refused.is_err());
2410 + assert!(bulk.asked().is_empty());
2411 +
2412 + // Adding one the vault has never seen is fine: that is how a vault learns a
2413 + // tag.
2414 + bulking(
2415 + &bulk,
2416 + Request::post("/bulk/tag").sending(
2417 + Params::new()
2418 + .with("tag".to_owned(), "nothing-has-this".to_owned())
2419 + .with("mode".to_owned(), "add".to_owned()),
2420 + ),
2421 + )
2422 + .unwrap();
2423 + assert_eq!(bulk.asked(), ["add nothing-has-this"]);
2424 + }
2425 +
2426 + #[test]
2427 + fn an_empty_tag_keeps_the_modal_open_and_says_why() {
2428 + let bulk = FakeBulk::of(&["kick.wav"], 1);
2429 + let response = bulking(
2430 + &bulk,
2431 + Request::post("/bulk/tag").sending(Params::new().with("tag".to_owned(), " ".to_owned())),
2432 + )
2433 + .unwrap();
2434 +
2435 + // Still an overlay: a refusal that navigated away would take the modal down
2436 + // and lose what was typed.
2437 + assert!(matches!(response.outcome, Outcome::Over(_)));
2438 + assert!(response.notice.is_some());
2439 + assert!(bulk.asked().is_empty());
2440 + }
2441 +
2442 + #[test]
2443 + fn the_move_modal_offers_the_root_and_every_folder() {
2444 + let bulk = FakeBulk::of(&["kick.wav"], 1);
2445 + let response = bulking(&bulk, Request::get("/bulk/move")).unwrap();
2446 + let (_, rows) = table_of(overlay(&response));
2447 +
2448 + let paths: Vec<String> = rows.iter().map(|row| cell_text(row, 0)).collect();
2449 + assert_eq!(paths, ["/", "/kits", "/kits/808"]);
2450 +
2451 + // Every row submits its own destination, so picking one is the whole
2452 + // interaction. The shipped modal has a selection index and a separate Move
2453 + // button.
2454 + for row in &rows {
2455 + assert!(row.activate.is_some());
2456 + }
2457 + }
2458 +
2459 + #[test]
2460 + fn moving_names_the_folder_by_id_and_the_root_by_absence() {
2461 + let bulk = FakeBulk::of(&["kick.wav"], 1);
2462 + bulking(
2463 + &bulk,
2464 + Request::post("/bulk/move")
2465 + .sending(Params::new().with("folder".to_owned(), "4".to_owned())),
2466 + )
2467 + .unwrap();
2468 + bulking(
2469 + &bulk,
2470 + Request::post("/bulk/move").sending(Params::new().with("folder".to_owned(), String::new())),
2471 + )
2472 + .unwrap();
2473 + assert_eq!(bulk.asked(), ["move 4", "move root"]);
2474 + }
2475 +
2476 + #[test]
2477 + fn moving_somewhere_that_is_not_a_folder_is_refused() {
2478 + let bulk = FakeBulk::of(&["kick.wav"], 1);
2479 + for value in ["99", "not-a-number"] {
2480 + assert!(
2481 + bulking(
2482 + &bulk,
2483 + Request::post("/bulk/move")
2484 + .sending(Params::new().with("folder".to_owned(), value.to_owned())),
2485 + )
2486 + .is_err()
2487 + );
2488 + }
2489 + // And a request that names no destination at all is not the root.
2490 + assert!(bulking(&bulk, Request::post("/bulk/move")).is_err());
2491 + assert!(bulk.asked().is_empty());
2492 + }
2493 +
2494 + #[test]
2495 + fn the_rename_modal_previews_the_starting_pattern() {
2496 + let bulk = FakeBulk::of(&["kick.wav", "snare.wav"], 2);
2497 + let response = bulking(&bulk, Request::get("/bulk/rename")).unwrap();
2498 + let screen = overlay(&response);
2499 + let (_, rows) = table_of(screen);
2500 +
2501 + let pairs: Vec<(String, String)> = rows
2502 + .iter()
2503 + .map(|row| (cell_text(row, 0), cell_text(row, 1)))
2504 + .collect();
2505 + assert_eq!(pairs[0], ("kick.wav".to_owned(), "kick.wav".to_owned()));
2506 + assert_eq!(pairs[1], ("snare.wav".to_owned(), "snare.wav".to_owned()));
2507 + }
2508 +
2509 + #[test]
2510 + fn a_typed_pattern_answers_a_fragment_so_the_overlay_survives() {
2511 + // THE OTHER FINDING. An overlay cannot re-answer itself: `Outcome::Screen`
2512 + // clears the layer stack, so a live preview that answered a screen would
2513 + // take the modal down on every keystroke. A fragment replaces one region of
2514 + // what is showing, which is what a preview is.
2515 + let bulk = FakeBulk::of(&["kick.wav"], 1);
2516 + let response = bulking(
2517 + &bulk,
Lines truncated
@@ -1,0 +1,477 @@
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 + 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.
126 + ///
127 + /// `Node::List` has `more: Option<Rest>` for exactly this and **`Node::Table`
128 + /// has nothing**, which is why the preview table says its overflow in prose
129 + /// beside itself and the chosen-names list does the same. A small gap, noted
130 + /// rather than filed: the fix is the member `List` already has.
131 + const SHOWN: usize = 50;
132 +
133 + /// Register the three modals' routes.
134 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
135 + router
136 + .get("/bulk/tag", tag_screen)
137 + .post("/bulk/tag", tag)
138 + .get("/bulk/move", move_screen)
139 + .post("/bulk/move", move_to)
140 + .get("/bulk/rename", rename_screen)
141 + .post("/bulk/rename/preview", preview)
142 + .post("/bulk/rename", rename)
143 + }
144 +
145 + /// `GET /bulk/tag`
146 + fn tag_screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
147 + let chosen = state.bulk.chosen();
148 + if chosen.samples == 0 {
149 + return Err(RouteError::not_found("no samples are chosen"));
150 + }
151 + Ok(over(tagging(state, &chosen, None, true)))
152 + }
153 +
154 + /// `POST /bulk/tag`
155 + ///
156 + /// Answers the list rather than the modal, because the modal is done. That is
157 + /// also what closes it: see finding 2 in this module's header — a navigation is
158 + /// the only thing that takes an overlay down, and here it happens to be right.
159 + fn tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
160 + let typed = request.payload.get(TAG).unwrap_or_default().trim();
161 + let adding = request.payload.get(MODE) != Some("remove");
162 + let chosen = state.bulk.chosen();
163 +
164 + if typed.is_empty() {
165 + return Ok(over(tagging(state, &chosen, Some(""), adding))
166 + .toast(Tone::Danger, "Type a tag first."));
167 + }
168 + // The same refusal the shipped Apply button makes, made by the route as
169 + // well: an address is reachable by typing, so a disabled control is an
170 + // affordance rather than a guarantee.
171 + if !adding && !state.bulk.known_tags().iter().any(|known| known == typed) {
172 + return Err(RouteError::not_found(UNKNOWN));
173 + }
174 + state.bulk.tag(typed, adding);
175 + Ok(Response::from(leaving()).toast(
176 + Tone::Success,
177 + format!(
178 + "{} \"{typed}\" {} {} samples.",
179 + if adding { "Adding" } else { "Removing" },
180 + if adding { "on" } else { "from" },
181 + chosen.samples,
182 + ),
183 + ))
184 + }
185 +
186 + /// What the shipped Apply button says when it will not run.
187 + const UNKNOWN: &str = "None of the selected samples have this tag.";
188 +
189 + /// The tag modal.
190 + fn tagging(state: &Panels<'_>, chosen: &Chosen, typed: Option<&str>, adding: bool) -> Screen {
191 + let mut body = Slot::new(BODY, RegionKind::Pane)
192 + .with(Node::page(format!("Tag {} samples", chosen.samples)));
193 +
194 + // Add or remove, as one control rather than two selectable labels. The
195 + // shipped modal draws `selectable_value(adding, true, ..)` twice, which is a
196 + // segmented control spelled out.
197 + body = body.with(Node::Select {
198 + kind: Selector::Segmented,
199 + options: vec![
200 + (Choice::new("add", "Add tag"), None),
201 + (Choice::new("remove", "Remove tag"), None),
202 + ],
203 + chosen: Some(if adding { "add" } else { "remove" }.to_owned()),
204 + action: None,
205 + });
206 +
207 + let mut field = Field::new(FieldKind::Text, TAG, "Tag").hint("e.g. genre.electronic");
208 + if let Some(typed) = typed {
209 + field = field.value(typed);
210 + }
211 + body = body
212 + .with(Node::Form {
213 + fields: vec![field],
214 + submit: "Apply".to_owned(),
215 + action: Action::post("/bulk/tag"),
216 + })
217 + .with(Node::text(if adding {
218 + "Will add to every selected sample that lacks it."
219 + } else {
220 + "Will remove from selected samples that have this tag."
221 + }));
222 +
223 + // Every tag the vault knows, as badges under the field. The shipped modal
224 + // filters this set to a substring of what is typed, caps it at twelve, and
225 + // fills the field when one is clicked -- so this is the **second consumer**
226 + // of `quasi:vocabulary:text-into-field`, the finding the export port filed
227 + // for the naming-pattern chips. A `Field` cannot say what completes it, so
228 + // the set is named and the reader types.
229 + //
230 + // The narrowing is not described and should not be: what the app knows is
231 + // the whole set, and how many of them a host shows while someone types is
232 + // the host's business.
233 + body = body.with(Node::section("Known tags"));
234 + for known in state.bulk.known_tags().iter().take(SHOWN) {
235 + body = body.with(Node::Token(Tag::badge(known.clone())));
236 + }
237 +
238 + closing(subjects(body, &chosen.names))
239 + }
240 +
241 + /// `GET /bulk/move`
242 + fn move_screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
243 + let chosen = state.bulk.chosen();
244 + if chosen.names.is_empty() {
245 + return Err(RouteError::not_found("nothing is chosen"));
246 + }
247 + Ok(over(moving(state, &chosen)))
248 + }
249 +
250 + /// `POST /bulk/move`
251 + ///
252 + /// The root is the empty value rather than a missing one, so "put these at the
253 + /// top" and "the form sent nothing" stay different requests.
254 + fn move_to(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
255 + let chosen = request
256 + .payload
257 + .get(FOLDER)
258 + .ok_or_else(|| RouteError::not_found("no destination named"))?;
259 + let folder = if chosen == ROOT {
260 + None
261 + } else {
262 + let id: i64 = chosen
263 + .parse()
264 + .map_err(|_| RouteError::not_found("no such folder"))?;
265 + if !state.bulk.folders().iter().any(|folder| folder.id == id) {
266 + return Err(RouteError::not_found("no such folder"));
267 + }
268 + Some(id)
269 + };
270 + let count = state.bulk.chosen().names.len();
271 + state.bulk.move_to(folder);
272 + Ok(Response::from(leaving()).toast(Tone::Success, format!("Moving {count} items.")))
273 + }
274 +
275 + /// The move modal.
276 + ///
277 + /// A table of one column rather than a list, because picking a row is what this
278 + /// screen is for and `Cells::activate` is what says a row is pressable. The
279 + /// shipped modal draws `selectable_label` per directory with a substring filter
280 + /// above it; the filter is not described, on the rule the tag completions
281 + /// follow — narrowing a list while someone types is what a host does with a list
282 + /// it was handed.
283 + fn moving(state: &Panels<'_>, chosen: &Chosen) -> Screen {
284 + let folders = state.bulk.folders();
285 + let mut rows = vec![row("/", ROOT)];
286 + rows.extend(
287 + folders
288 + .iter()
289 + .map(|folder| row(&folder.path, &folder.id.to_string())),
290 + );
291 +
292 + let body = Slot::new(BODY, RegionKind::Pane)
293 + .with(Node::page(format!("Move {} items", chosen.names.len())))
294 + .with(Node::text("Choose where they go."))
295 + .with(Node::Table {
296 + columns: vec![Column::new("Folder")],
297 + rows,
298 + });
299 +
300 + closing(subjects(body, &chosen.names))
301 + }
302 +
303 + /// One destination, as a row that submits itself.
304 + fn row(path: &str, value: &str) -> Cells {
305 + Cells::new(vec![Cell::new(path)]).activate(Action::post("/bulk/move").carrying(FOLDER, value))
306 + }
307 +
308 + /// `GET /bulk/rename`
309 + fn rename_screen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
310 + let chosen = state.bulk.chosen();
311 + if chosen.names.is_empty() {
312 + return Err(RouteError::not_found("nothing is chosen"));
313 + }
314 + Ok(over(renaming(state, START)))
315 + }
316 +
317 + /// `POST /bulk/rename/preview`
318 + ///
319 + /// What the shipped modal does with `update_rename_previews` on every keystroke,
320 + /// except that nothing is stored: the pattern arrives, the previews are computed
321 + /// from it, and the answer carries both.
322 + ///
323 + /// **`Outcome::Fragment` rather than a screen**, and that is finding 2 doing its
324 + /// work: this overlay is open, and answering a whole screen would take it down.
325 + /// A fragment replaces one region of whatever is showing, which is exactly what
326 + /// a live preview is.
327 + fn preview(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
328 + let pattern = request.payload.get(PATTERN).unwrap_or_default();
329 + Ok(Response::from(Outcome::Fragment {
330 + region: PREVIEW.to_owned(),
331 + node: previewed(state, pattern),
332 + }))
333 + }
334 +
335 + /// `POST /bulk/rename`
336 + fn rename(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
337 + let pattern = request.payload.get(PATTERN).unwrap_or_default();
338 + // Refused rather than run, because a pattern that does not parse renames
339 + // every chosen file to nothing. The shipped modal disables the button on
340 + // the same condition; this is the route saying it too.
341 + let previews = state
342 + .bulk
343 + .previews(pattern)
344 + .map_err(RouteError::not_found)?;
345 + if previews.is_empty() {
346 + return Err(RouteError::not_found("that pattern renames nothing"));
347 + }
348 + state.bulk.rename(pattern);
349 + Ok(Response::from(leaving())
350 + .toast(Tone::Success, format!("Renaming {} items.", previews.len())))
351 + }
352 +
353 + /// The region the preview lands in.
354 + const PREVIEW: &str = "bulk-rename-preview";
355 +
356 + /// The rename modal.
357 + fn renaming(state: &Panels<'_>, pattern: &str) -> Screen {
358 + let mut body = Slot::new(BODY, RegionKind::Pane).with(Node::page("Rename pattern"));
359 +
360 + // The tokens, as chips that say what they are. The shipped modal appends
361 + // the token to the buffer when one is pressed, which is
362 + // `quasi:vocabulary:text-into-field` -- the finding the export port filed
363 + // for the same control, with the same workaround: name them and let the
364 + // reader type.
365 + for token in TOKENS {
366 + body = body.with(Node::Token(Tag::badge(token)));
367 + }
368 +
369 + body = body.with(Node::Form {
370 + fields: vec![
371 + Field::new(FieldKind::Text, PATTERN, "Pattern")
372 + .value(pattern)
373 + .hint("{name}_{bpm}")
374 + .changes(Action::post("/bulk/rename/preview")),
375 + ],
376 + submit: "Rename".to_owned(),
377 + action: Action::post("/bulk/rename"),
378 + });
379 +
380 + closing(body.with(Node::Region(
381 + Slot::new(PREVIEW, RegionKind::Group).with(previewed(state, pattern)),
382 + )))
383 + }
384 +
385 + /// The modal, with the one control a description cannot honestly say.
386 + ///
387 + /// See finding 1. `Act::key` names Escape because Escape is what actually
388 + /// dismisses an overlay, and the address is what a host with no Escape key has
389 + /// instead.
390 + fn closing(body: Slot) -> Screen {
391 + Screen::sidebar_content("Bulk")
392 + .with(body.with(Node::Act(Act::new("Cancel", Action::get(BACK)).key("esc"))))
393 + }
394 +
395 + /// What the pattern would do, old name beside new.
396 + ///
397 + /// Its own node so the preview route can answer it as a fragment, which is what
398 + /// keeps the overlay standing while it updates.
399 + fn previewed(state: &Panels<'_>, pattern: &str) -> Node {
400 + let previews = match state.bulk.previews(pattern) {
401 + Ok(previews) => previews,
402 + // The pattern is being typed, so half of it is not a pattern yet. Said
403 + // rather than drawn as an empty table, which would read as "this renames
404 + // nothing".
405 + Err(why) => {
406 + return Node::Notice {
407 + kind: Notice::Banner,
408 + tone: Tone::Danger,
409 + text: why,
410 + };
411 + }
412 + };
413 + if previews.is_empty() {
414 + return Node::empty("Nothing to rename.");
415 + }
416 +
417 + // Collisions counted once over the whole set rather than per row, which is
418 + // the shipped modal's own reasoning ("counting once is the whole point,
419 + // doing it per-row would be O(n^2)") and holds here for the same reason.
420 + let mut seen: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
421 + for (_, new) in &previews {
422 + *seen.entry(new.as_str()).or_insert(0) += 1;
423 + }
424 +
425 + Node::Table {
426 + columns: vec![Column::new("Old"), Column::new("New")],
427 + rows: previews
428 + .iter()
429 + .take(SHOWN)
430 + .map(|(old, new)| {
431 + let collides = seen.get(new.as_str()).copied().unwrap_or(0) > 1;
432 + Cells::new(vec![
433 + Cell::new(old),
434 + // A collision is a tone on the value rather than a hover on
435 + // it, for the reason a suggestion's score is in its label in
436 + // `detail`: a reader with no pointer never sees a hover, and
437 + // this one is a warning about losing files.
438 + if collides {
439 + Cell::tag(Tag::badge(new.clone()).tone(Tone::Warning))
440 + } else {
441 + Cell::new(new)
442 + },
443 + ])
444 + })
445 + .collect(),
446 + }
447 + }
448 +
449 + /// Every name the operation touches.
450 + ///
451 + /// The shipped modals each scroll this list at a fixed height; how much of it
452 + /// fits is the host's, and how many there are is the description's.
453 + fn subjects(body: Slot, names: &[String]) -> Slot {
454 + let mut body = body.with(Node::section(format!("{} chosen", names.len())));
455 + for name in names.iter().take(SHOWN) {
456 + body = body.with(Node::text(name));
457 + }
458 + if names.len() > SHOWN {
459 + body = body.with(Node::text(format!("...and {} more", names.len() - SHOWN)));
460 + }
461 + body
462 + }
463 +
464 + /// The screen a finished modal leaves behind.
465 + ///
466 + /// Going somewhere, because there is nothing else a description can say. See
467 + /// finding 1: this clears the layer stack rather than revealing what was under
468 + /// it, which happens to be right when the modal is done and is wrong when it is
469 + /// cancelled.
470 + fn leaving() -> Outcome {
471 + Outcome::Goto(Action::get(BACK))
472 + }
473 +
474 + /// A screen drawn over whatever is showing.
475 + fn over(screen: Screen) -> Response {
476 + Response::from(Outcome::Over(screen))
477 + }