max / audiofiles
6 files changed,
+1203 insertions,
-81 deletions
| @@ -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 | + | } |