max / audiofiles
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_0153yKAYUmoC9bZz2eV6cA88
6 files changed,
+967 insertions,
-395 deletions
| @@ -7556,22 +7556,6 @@ | |||
| 7556 | 7556 | "winnow 1.0.4", | |
| 7557 | 7557 | ] | |
| 7558 | 7558 | ||
| 7559 | - | [[patch.unused]] | |
| 7560 | - | name = "quasi-type" | |
| 7561 | - | version = "0.1.2" | |
| 7562 | - | ||
| 7563 | - | [[patch.unused]] | |
| 7564 | - | name = "kberg" | |
| 7565 | - | version = "0.1.0" | |
| 7566 | - | ||
| 7567 | - | [[patch.unused]] | |
| 7568 | - | name = "ops-status" | |
| 7569 | - | version = "0.1.0" | |
| 7570 | - | ||
| 7571 | - | [[patch.unused]] | |
| 7572 | - | name = "painhours" | |
| 7573 | - | version = "0.1.0" | |
| 7574 | - | ||
| 7575 | 7559 | [[patch.unused]] | |
| 7576 | 7560 | name = "quasi-axum" | |
| 7577 | 7561 | version = "0.77.0" | |
| @@ -7599,3 +7583,19 @@ | |||
| 7599 | 7583 | [[patch.unused]] | |
| 7600 | 7584 | name = "quasi-webview" | |
| 7601 | 7585 | version = "0.77.0" | |
| 7586 | + | ||
| 7587 | + | [[patch.unused]] | |
| 7588 | + | name = "quasi-type" | |
| 7589 | + | version = "0.1.2" | |
| 7590 | + | ||
| 7591 | + | [[patch.unused]] | |
| 7592 | + | name = "kberg" | |
| 7593 | + | version = "0.1.0" | |
| 7594 | + | ||
| 7595 | + | [[patch.unused]] | |
| 7596 | + | name = "ops-status" | |
| 7597 | + | version = "0.1.0" | |
| 7598 | + | ||
| 7599 | + | [[patch.unused]] | |
| 7600 | + | name = "painhours" | |
| 7601 | + | version = "0.1.0" |
| @@ -83,22 +83,35 @@ | |||
| 83 | 83 | //! Kicks", "Add to Breaks" for as many collections as exist, which is honest and | |
| 84 | 84 | //! gets long. | |
| 85 | 85 | //! | |
| 86 | - | //! **The other two have no container at all.** A menu over the *selection* and a | |
| 87 | - | //! menu over the *surface* are not per-row, and nothing in the vocabulary holds | |
| 88 | - | //! acts back until a host asks for them except `Row::menu`. Described as | |
| 89 | - | //! [`Outcome::Over`](quasi_router::Outcome::Over) they become app-modal | |
| 90 | - | //! overlays, which is what [`toolbar`](super::toolbar) already recorded of the | |
| 91 | - | //! save-as-collection popover — "near enough and not exact" — and what | |
| 92 | - | //! [`importing`](super::importing) took for the Import menu as its second | |
| 93 | - | //! consumer. These are the third and fourth, and they are the ones that make the | |
| 94 | - | //! shape clear: an anchored menu is not a modal, and its subject is whatever it | |
| 95 | - | //! opened over. Filed as `quasi:vocabulary:anchored-menu`. | |
| 86 | + | //! **The other two had no container at all, and now have two different ones.** | |
| 87 | + | //! Restored 2026-08-29 (audiofiles `41996627`) after they had been unreachable | |
| 88 | + | //! since the flip. What was missing was never the contents — five of their | |
| 89 | + | //! sixteen entries are addresses this app already answered, [`bulk`](super::bulk)'s three | |
| 90 | + | //! modals and [`naming`](super::naming)'s and [`importing`](super::importing)'s doors — it was the gesture and | |
| 91 | + | //! the subject, and the two menus turn out to differ in both. | |
| 96 | 92 | //! | |
| 97 | - | //! Two of the eleven selection entries and three of the five background entries | |
| 98 | - | //! are described already, at addresses of their own — | |
| 99 | - | //! [`bulk`](super::bulk)'s three modals, [`naming`](super::naming)'s New Folder, | |
| 100 | - | //! [`importing`](super::importing)'s two doors. So what is missing is never the | |
| 101 | - | //! contents. It is the gesture and the anchor, both times. | |
| 93 | + | //! - **The selection menu hangs off the row the press landed on**, as | |
| 94 | + | //! `chosen_menu` below. quasi 0.77 ships | |
| 95 | + | //! [`Anchor::Selection`](quasi_router::Anchor::Selection) for exactly this and | |
| 96 | + | //! it does not fit here: that member resolves against the *description's* | |
| 97 | + | //! selection — `Screen::selection` named, rows ticked — and this list's | |
| 98 | + | //! selection is the app's own, live and unticked, for the reason `row` below | |
| 99 | + | //! records. So the anchor would resolve to nothing and the menu would open | |
| 100 | + | //! away from its subject. Ruled by Max 2026-08-29 on the measurement; the row | |
| 101 | + | //! the press landed on is the anchor, which is what the shipped list did. | |
| 102 | + | //! - **The empty-space menu is anchored at the region**, as `background_menu`, | |
| 103 | + | //! because a press on nothing has no row. It is the app's first | |
| 104 | + | //! [`Outcome::Anchored`](quasi_router::Outcome::Anchored), and its gesture is | |
| 105 | + | //! the host's — `panel::menued` — since the absence of a row is not a | |
| 106 | + | //! described thing for a description to answer about. | |
| 107 | + | //! | |
| 108 | + | //! What is still owed, and it is the reason the first of those went the way it | |
| 109 | + | //! did: **a live multi-selection has no described shape.** `Cells` marks the | |
| 110 | + | //! current row and holds a tick, a tick is a staged set awaiting a commit | |
| 111 | + | //! control, and this list's selection is neither — so a five-hundred-row Cmd+A | |
| 112 | + | //! draws the same as a one-row click, and [`Sample::selected`] is read by the | |
| 113 | + | //! menu fork and shown nowhere. Measured 2026-08-29 and filed against | |
| 114 | + | //! quasicoherent. | |
| 102 | 115 | //! - **Virtual scrolling.** Recorded in the findings note as renderer policy | |
| 103 | 116 | //! from the start: windowing rows the app already holds is a performance | |
| 104 | 117 | //! technique, not a described fact. | |
| @@ -155,6 +168,22 @@ | |||
| 155 | 168 | .post("/files/{id}/download", download) | |
| 156 | 169 | .post("/files/{id}/collection/remove", remove_from_collection) | |
| 157 | 170 | .post("/files/{id}/collection/add", add_to_collection) | |
| 171 | + | // The selection menu and the empty-space menu, restored 2026-08-29. | |
| 172 | + | // Every address under `/files/chosen` acts on the set rather than on a | |
| 173 | + | // row, which is why none of them carries an id. | |
| 174 | + | .post("/files/chosen/invert", invert_chosen) | |
| 175 | + | .post("/files/chosen/clear", deselect) | |
| 176 | + | .post("/files/chosen/export", export_chosen) | |
| 177 | + | .post("/files/chosen/reanalyze", reanalyze_chosen) | |
| 178 | + | .post("/files/chosen/path/copy", copy_chosen_paths) | |
| 179 | + | .post("/files/chosen/tags/copy", copy_tags_from_focus) | |
| 180 | + | .post("/files/chosen/delete", delete_chosen) | |
| 181 | + | .post("/files/chosen/collection/add", add_chosen_to_collection) | |
| 182 | + | .post( | |
| 183 | + | "/files/chosen/collection/remove", | |
| 184 | + | remove_chosen_from_collection, | |
| 185 | + | ) | |
| 186 | + | .get("/files/menu/background", background_menu) | |
| 158 | 187 | } | |
| 159 | 188 | ||
| 160 | 189 | /// `GET /files` | |
| @@ -320,6 +349,162 @@ | |||
| 320 | 349 | Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name))) | |
| 321 | 350 | } | |
| 322 | 351 | ||
| 352 | + | // --- the selection, which is the whole set rather than a row --- | |
| 353 | + | ||
| 354 | + | /// `POST /files/chosen/invert` | |
| 355 | + | fn invert_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 356 | + | state.files.invert_chosen(); | |
| 357 | + | Ok(screen(state).into()) | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | /// `POST /files/chosen/clear` | |
| 361 | + | fn deselect(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 362 | + | state.files.deselect(); | |
| 363 | + | Ok(screen(state).into()) | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | /// `POST /files/chosen/export` | |
| 367 | + | /// | |
| 368 | + | /// [`export`](fn@export)'s answer for a set: the flow opens on what is chosen | |
| 369 | + | /// and the screen it opens is a place, so this goes there rather than answering | |
| 370 | + | /// a changed file list. | |
| 371 | + | fn export_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 372 | + | state.files.export_chosen(); | |
| 373 | + | Ok(Response::from(quasi_router::Outcome::Goto(Action::get( | |
| 374 | + | "/export", | |
| 375 | + | )))) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | /// `POST /files/chosen/reanalyze` | |
| 379 | + | fn reanalyze_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 380 | + | state.files.reanalyze_chosen(); | |
| 381 | + | Ok(screen(state).into()) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | /// `POST /files/chosen/path/copy` | |
| 385 | + | /// | |
| 386 | + | /// The clipboard is the host's, so what the toast can honestly say is that the | |
| 387 | + | /// paths went. Which paths, and how many, is the status line the host writes | |
| 388 | + | /// when it applies the intent -- the shipped menu names the first one so a | |
| 389 | + | /// reader can recognise what they are about to paste, and that is a fact this | |
| 390 | + | /// route does not hold. | |
| 391 | + | fn copy_chosen_paths(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 392 | + | state.files.copy_chosen_paths(); | |
| 393 | + | Ok(screen(state).into()) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | /// `POST /files/chosen/tags/copy` | |
| 397 | + | fn copy_tags_from_focus(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 398 | + | state.files.copy_tags_from_focus(); | |
| 399 | + | Ok(screen(state).into()) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | /// `POST /files/chosen/delete` | |
| 403 | + | /// | |
| 404 | + | /// [`delete`](fn@delete)'s reading, one level up: the act asks nothing and the | |
| 405 | + | /// app raises its own counted dialog, because how much is about to go is what a | |
| 406 | + | /// reader needs when the subject is a set. | |
| 407 | + | fn delete_chosen(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 408 | + | state.files.delete_chosen(); | |
| 409 | + | Ok(screen(state).into()) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | /// `POST /files/chosen/collection/add` | |
| 413 | + | fn add_chosen_to_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 414 | + | let chosen = request | |
| 415 | + | .payload | |
| 416 | + | .get(COLLECTION) | |
| 417 | + | .and_then(|value| value.parse::<i64>().ok()) | |
| 418 | + | .ok_or_else(|| RouteError::not_found("no collection named"))?; | |
| 419 | + | let named = state | |
| 420 | + | .library | |
| 421 | + | .collections() | |
| 422 | + | .into_iter() | |
| 423 | + | .find(|collection| collection.id == chosen) | |
| 424 | + | .ok_or_else(|| RouteError::not_found("no such collection"))?; | |
| 425 | + | state.files.add_chosen_to_collection(named.id); | |
| 426 | + | Ok(Response::from(screen(state)).toast(Tone::Success, format!("Added to {}.", named.name))) | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | /// `POST /files/chosen/collection/remove` | |
| 430 | + | fn remove_chosen_from_collection( | |
| 431 | + | state: &Panels<'_>, | |
| 432 | + | _request: Request, | |
| 433 | + | ) -> Result<Response, RouteError> { | |
| 434 | + | state.files.remove_chosen_from_collection(); | |
| 435 | + | Ok(screen(state).into()) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | /// `GET /files/menu/background` | |
| 439 | + | /// | |
| 440 | + | /// The empty-space menu, and the one of the two that really is | |
| 441 | + | /// [`Outcome::Anchored`](quasi_router::Outcome::Anchored): its subject is the | |
| 442 | + | /// region under the pointer rather than anything in it, so | |
| 443 | + | /// [`Anchor::Region`](quasi_router::Anchor::Region) names the list's own slot | |
| 444 | + | /// and every renderer resolves it with geometry it already has. | |
| 445 | + | /// | |
| 446 | + | /// Raised by `panel::menued`, which is the host half. A press on nothing is not | |
| 447 | + | /// a described gesture and cannot become one: a row answers a menu because a row | |
| 448 | + | /// is a described thing, and the absence of a row is not. | |
| 449 | + | /// | |
| 450 | + | /// Three of the five entries are addresses the app already answers, which is the | |
| 451 | + | /// same accounting the row menu keeps: New Folder is `naming`'s and the two | |
| 452 | + | /// import doors are `importing`'s. Only the two that act on the selection are | |
| 453 | + | /// this module's, and they are the ones the shipped menu withheld when nothing | |
| 454 | + | /// was chosen. | |
| 455 | + | fn background_menu(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 456 | + | let chosen = state | |
| 457 | + | .files | |
| 458 | + | .samples() | |
| 459 | + | .iter() | |
| 460 | + | .filter(|sample| sample.selected) | |
| 461 | + | .count(); | |
| 462 | + | ||
| 463 | + | let mut body = Slot::new("files-menu", RegionKind::Pane) | |
| 464 | + | .with(Node::Act(Act::new( | |
| 465 | + | "New Folder", | |
| 466 | + | Action::get("/folders/new"), | |
| 467 | + | ))) | |
| 468 | + | .with(Node::Act(Act::new( | |
| 469 | + | "Import files...", | |
| 470 | + | Action::post("/import/open/files"), | |
| 471 | + | ))) | |
| 472 | + | // The wizard door rather than the quick one, which is the shipped menu's | |
| 473 | + | // own choice and its reason: the toolbar offers all three, and a context | |
| 474 | + | // menu that offered them too would be the same fork asked twice. | |
| 475 | + | .with(Node::Act(Act::new( | |
| 476 | + | "Import folder...", | |
| 477 | + | Action::post("/import/open/folder"), | |
| 478 | + | ))); | |
| 479 | + | ||
| 480 | + | if chosen > 0 { | |
| 481 | + | body = body | |
| 482 | + | // The hint stays in the label and the binding does not: Escape | |
| 483 | + | // clears the selection from anywhere, which is `editor.rs`'s, and | |
| 484 | + | // an act claiming it here would take it off the Close below. | |
| 485 | + | .with(Node::Act(Act::new( | |
| 486 | + | format!("Deselect ({chosen}) (Esc)"), | |
| 487 | + | Action::post("/files/chosen/clear"), | |
| 488 | + | ))) | |
| 489 | + | .with(Node::Act(Act::new( | |
| 490 | + | "Invert Selection (Cmd+Shift+I)", | |
| 491 | + | Action::post("/files/chosen/invert"), | |
| 492 | + | ))); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // The way out, which an anchored screen has no described way to say: nothing | |
| 496 | + | // in the vocabulary means "close what is on top", so this navigates to the | |
| 497 | + | // window's own home and the layer goes with the screen. `bulk`'s finding 1, | |
| 498 | + | // met again one screen up and answered the same way `importing`'s menu | |
| 499 | + | // answers it. | |
| 500 | + | body = body.with(Node::Act(Act::new("Close", Action::get("/")).key("esc"))); | |
| 501 | + | ||
| 502 | + | Ok(Response::anchored( | |
| 503 | + | Screen::sidebar_content("Here").with(body), | |
| 504 | + | quasi_router::Anchor::Region(BODY.to_owned()), | |
| 505 | + | )) | |
| 506 | + | } | |
| 507 | + | ||
| 323 | 508 | /// `POST /files/sort/{column}` | |
| 324 | 509 | /// | |
| 325 | 510 | /// The heading a user pressed. Which way it then sorts is the app's: pressing | |
| @@ -372,7 +557,23 @@ | |||
| 372 | 557 | // rather than per row -- it is a fact about the screen, and every row would | |
| 373 | 558 | // otherwise ask the library the same question. | |
| 374 | 559 | let collections = state.library.collections(); | |
| 375 | - | let collection = collections.iter().any(|collection| collection.active); | |
| 560 | + | let listing = Listing { | |
| 561 | + | shown, | |
| 562 | + | current, | |
| 563 | + | collection: collections.iter().any(|collection| collection.active), | |
| 564 | + | collections: &collections, | |
| 565 | + | basket_showing: state.bar.basket_showing(), | |
| 566 | + | // What the selection menu hangs on. Counted here for the same reason | |
| 567 | + | // the collections are read here: it is a fact about the screen, and a | |
| 568 | + | // row asking it would ask it once per row. | |
| 569 | + | chosen: samples.iter().filter(|sample| sample.selected).count(), | |
| 570 | + | focus: current.and_then(|id| { | |
| 571 | + | samples | |
| 572 | + | .iter() | |
| 573 | + | .find(|sample| sample.id == id) | |
| 574 | + | .map(|sample| sample.name.clone()) | |
| 575 | + | }), | |
| 576 | + | }; | |
| 376 | 577 | ||
| 377 | 578 | if samples.is_empty() { | |
| 378 | 579 | // The sentence and the way out are both on the node rather than on the | |
| @@ -385,19 +586,7 @@ | |||
| 385 | 586 | } else { | |
| 386 | 587 | Slot::new(BODY, RegionKind::Pane).with(Node::Table { | |
| 387 | 588 | columns: columns(state, shown), | |
| 388 | - | rows: samples | |
| 389 | - | .iter() | |
| 390 | - | .map(|sample| { | |
| 391 | - | row( | |
| 392 | - | sample, | |
| 393 | - | shown, | |
| 394 | - | current, | |
| 395 | - | collection, | |
| 396 | - | &collections, | |
| 397 | - | state.bar.basket_showing(), | |
| 398 | - | ) | |
| 399 | - | }) | |
| 400 | - | .collect(), | |
| 589 | + | rows: samples.iter().map(|sample| row(sample, &listing)).collect(), | |
| 401 | 590 | // Everything the app has loaded and filtered is here. Windowing | |
| 402 | 591 | // rows it already holds is a renderer's job, which is the note in | |
| 403 | 592 | // this module's header. | |
| @@ -469,15 +658,33 @@ | |||
| 469 | 658 | columns | |
| 470 | 659 | } | |
| 471 | 660 | ||
| 472 | - | /// One sample as a row. | |
| 473 | - | fn row( | |
| 474 | - | sample: &Sample, | |
| 661 | + | /// What every row of one drawing of the list is built against. | |
| 662 | + | /// | |
| 663 | + | /// Read once in [`body`] and handed down, rather than asked per row: each of | |
| 664 | + | /// these is a fact about the *screen* -- which columns are on, what is chosen, | |
| 665 | + | /// which collection is open -- and a row that read them itself would ask the | |
| 666 | + | /// library the same question five hundred times. | |
| 667 | + | struct Listing<'a> { | |
| 668 | + | /// Which columns the reader has switched on. | |
| 475 | 669 | shown: super::ColumnsShown, | |
| 670 | + | /// The row the app is pointing at. | |
| 476 | 671 | current: Option<i64>, | |
| 672 | + | /// Whether a collection is the thing being viewed. | |
| 477 | 673 | collection: bool, | |
| 478 | - | collections: &[Collection], | |
| 674 | + | /// The collections a sample can be put into. | |
| 675 | + | collections: &'a [Collection], | |
| 676 | + | /// Whether a basket answer is on screen, which adds the anchors column. | |
| 479 | 677 | basket_showing: bool, | |
| 480 | - | ) -> Cells { | |
| 678 | + | /// How many rows the reader has chosen. | |
| 679 | + | chosen: usize, | |
| 680 | + | /// What the row in focus is called, for the entry that spreads its tags. | |
| 681 | + | focus: Option<String>, | |
| 682 | + | } | |
| 683 | + | ||
| 684 | + | /// One sample as a row. | |
| 685 | + | fn row(sample: &Sample, listing: &Listing<'_>) -> Cells { | |
| 686 | + | let shown = listing.shown; | |
| 687 | + | let basket_showing = listing.basket_showing; | |
| 481 | 688 | let mut values = vec![Cell::new(&sample.name)]; | |
| 482 | 689 | if basket_showing { | |
| 483 | 690 | // A badge per anchor, in basket order, the way the tags cell holds a run | |
| @@ -556,8 +763,16 @@ | |||
| 556 | 763 | let mut row = Cells::new(values) | |
| 557 | 764 | .identified(sample.id.to_string()) | |
| 558 | 765 | .activate(Action::post(format!("/files/{}/open", sample.id))); | |
| 559 | - | row.current = current == Some(sample.id); | |
| 560 | - | row.menu = menu(sample, collection, collections); | |
| 766 | + | row.current = listing.current == Some(sample.id); | |
| 767 | + | // Which menu a row offers is which subject the press is about. A row inside | |
| 768 | + | // a multi-selection is not being asked about itself, and the shipped list | |
| 769 | + | // said so at the same fork: `selection.count() > 1 && contains(row_idx)` | |
| 770 | + | // drew the selection menu and everything else drew the row's own. | |
| 771 | + | row.menu = if listing.chosen > 1 && sample.selected { | |
| 772 | + | chosen_menu(listing) | |
| 773 | + | } else { | |
| 774 | + | menu(sample, listing.collection, listing.collections) | |
| 775 | + | }; | |
| 561 | 776 | row | |
| 562 | 777 | } | |
| 563 | 778 | ||
| @@ -567,15 +782,13 @@ | |||
| 567 | 782 | /// branching is the shipped menu's: a folder and a sample offer different things, | |
| 568 | 783 | /// and a cloud-only sample withholds the four acts that need the bytes on disk. | |
| 569 | 784 | /// | |
| 570 | - | /// # What is not here, and why each one is not a gap | |
| 785 | + | /// # What is not here, and why it is not a gap | |
| 786 | + | /// | |
| 787 | + | /// **What a chosen set offers**, which is [`chosen_menu`] and is reached from | |
| 788 | + | /// the same press: a row inside a multi-selection answers with that instead of | |
| 789 | + | /// with this. See [`row`], which is where the fork is, and [`chosen_menu`] for | |
| 790 | + | /// why the fork is on the row rather than on an anchor. | |
| 571 | 791 | /// | |
| 572 | - | /// - **The selection menu and the background menu.** Eleven entries and five, | |
| 573 | - | /// neither of them per-row: `draw_multi_context_menu` acts on the ticked set | |
| 574 | - | /// and the empty-space menu on the folder being shown. Neither has a container | |
| 575 | - | /// in the vocabulary -- there is no menu over a selection and none over a | |
| 576 | - | /// surface -- and described as [`Outcome::Over`](quasi_router::Outcome::Over) | |
| 577 | - | /// they become app-modal overlays, which is near enough and not exact. Filed as | |
| 578 | - | /// `quasi:vocabulary:anchored-menu`; see this module's header. | |
| 579 | 792 | /// # Add to Collection, which needed no submenu after all | |
| 580 | 793 | /// | |
| 581 | 794 | /// This entry was the module's one measured hole and was filed as a vocabulary | |
| @@ -684,6 +897,123 @@ | |||
| 684 | 897 | acts | |
| 685 | 898 | } | |
| 686 | 899 | ||
| 900 | + | /// What a set of chosen rows offers, from any one of them. | |
| 901 | + | /// | |
| 902 | + | /// `ui/file_list_menus.rs::draw_multi_context_menu`, said as a description and | |
| 903 | + | /// restored 2026-08-29 after `49b7429` left it with no caller. Eleven entries, | |
| 904 | + | /// in the shipped order. | |
| 905 | + | /// | |
| 906 | + | /// # Why this hangs off a row and not off the selection | |
| 907 | + | /// | |
| 908 | + | /// The task filed it as [`Outcome::Anchored`](quasi_router::Outcome::Anchored) | |
| 909 | + | /// with [`Anchor::Selection`](quasi_router::Anchor::Selection), and that member | |
| 910 | + | /// resolves against the *description's* selection: `Screen::anchors` wants | |
| 911 | + | /// [`Screen::selection`] set, and `quasi_immediate::geometry` unions the rects | |
| 912 | + | /// of the rows the view holds **ticked**. This list has neither and cannot get | |
| 913 | + | /// them cheaply. Its selection is the app's own -- live, set by a click, by | |
| 914 | + | /// Cmd+A and by shift-arrow -- where a tick is a staged set awaiting a commit | |
| 915 | + | /// control, drawn as a checkbox column, and seeded once on arrival rather than | |
| 916 | + | /// re-read on the refresh this screen does every frame. So `Anchor::Selection` | |
| 917 | + | /// would resolve to nothing here and the menu would open away from its subject. | |
| 918 | + | /// | |
| 919 | + | /// Ruled by Max 2026-08-29, on the measurement: the row the press landed on is | |
| 920 | + | /// the anchor, which is what the shipped list did and what every file manager | |
| 921 | + | /// does. The empty-space menu is still anchored, at | |
| 922 | + | /// [`Anchor::Region`](quasi_router::Anchor::Region) -- see | |
| 923 | + | /// [`background_menu`](fn@background_menu) -- because a press on nothing has no | |
| 924 | + | /// row to hang off. | |
| 925 | + | /// | |
| 926 | + | /// What is left over, and it is small: a menu is a `Vec<Act>` and holds no | |
| 927 | + | /// prose, so the shipped header line "N items selected" has nowhere to go. The | |
| 928 | + | /// count survives where it does the most work -- the app's own counted delete | |
| 929 | + | /// dialog, and the Deselect entry on the background menu. | |
| 930 | + | /// | |
| 931 | + | /// Five of the eleven are addresses answered elsewhere: three bulk modals, and | |
| 932 | + | /// the collection acts that borrow this module's own. Same accounting the row | |
| 933 | + | /// menu keeps, and the same benefit. | |
| 934 | + | fn chosen_menu(listing: &Listing<'_>) -> Vec<Act> { | |
| 935 | + | let mut acts = vec![ | |
| 936 | + | Act::new( | |
| 937 | + | "Invert Selection (Cmd+Shift+I)", | |
| 938 | + | Action::post("/files/chosen/invert"), | |
| 939 | + | ), | |
| 940 | + | // The three bulk modals, at their own addresses. Each acts on the | |
| 941 | + | // selection by reading it, so nothing about the set travels in the link. | |
| 942 | + | Act::new("Tag... (Cmd+T)", Action::get("/bulk/tag")), | |
| 943 | + | // m-16: the label advertises Cmd+Shift+M because Cmd+M is the macOS | |
| 944 | + | // minimize-window chord. The binding is `editor.rs`'s and has to move | |
| 945 | + | // with this label if it ever changes. | |
| 946 | + | Act::new("Move to... (Cmd+Shift+M)", Action::get("/bulk/move")), | |
| 947 | + | Act::new("Rename... (F2)", Action::get("/bulk/rename")), | |
| 948 | + | Act::new("Export...", Action::post("/files/chosen/export")), | |
| 949 | + | ]; | |
| 950 | + | ||
| 951 | + | // The one entry the shipped menu nested. It flattens the way the row menu's | |
| 952 | + | // did and for the same reason: `Act::asking` carries the question, so the | |
| 953 | + | // entry stays one line however many collections exist. | |
| 954 | + | if !listing.collections.is_empty() { | |
| 955 | + | acts.push( | |
| 956 | + | Act::new( | |
| 957 | + | "Add to Collection", | |
| 958 | + | Action::post("/files/chosen/collection/add"), | |
| 959 | + | ) | |
| 960 | + | .asking(Field::select( | |
| 961 | + | COLLECTION, | |
| 962 | + | "Collection", | |
| 963 | + | listing | |
| 964 | + | .collections | |
| 965 | + | .iter() | |
| 966 | + | .map(|it| Choice::new(it.id.to_string(), it.name.clone())) | |
| 967 | + | .collect(), | |
| 968 | + | )), | |
| 969 | + | ); | |
| 970 | + | } | |
| 971 | + | ||
| 972 | + | if listing.collection { | |
| 973 | + | acts.push( | |
| 974 | + | Act::new( | |
| 975 | + | "Remove from Collection", | |
| 976 | + | Action::post("/files/chosen/collection/remove"), | |
| 977 | + | ) | |
| 978 | + | .tone(Tone::Danger), | |
| 979 | + | ); | |
| 980 | + | } | |
| 981 | + | ||
| 982 | + | acts.push(Act::new( | |
| 983 | + | "Re-analyze...", | |
| 984 | + | Action::post("/files/chosen/reanalyze"), | |
| 985 | + | )); | |
| 986 | + | ||
| 987 | + | // Withheld when nothing is in focus, which is what the shipped menu does by | |
| 988 | + | // asking for the focused node first: there is no sample to copy tags from. | |
| 989 | + | if let Some(name) = &listing.focus { | |
| 990 | + | acts.push(Act::new( | |
| 991 | + | format!("Copy Tags from \"{}\"", truncated(name, 20)), | |
| 992 | + | Action::post("/files/chosen/tags/copy"), | |
| 993 | + | )); | |
| 994 | + | } | |
| 995 | + | ||
| 996 | + | acts.push(Act::new( | |
| 997 | + | "Copy Path", | |
| 998 | + | Action::post("/files/chosen/path/copy"), | |
| 999 | + | )); | |
| 1000 | + | acts.push(Act::new("Delete", Action::post("/files/chosen/delete")).tone(Tone::Danger)); | |
| 1001 | + | acts | |
| 1002 | + | } | |
| 1003 | + | ||
| 1004 | + | /// A name cut down to fit a menu entry. | |
| 1005 | + | /// | |
| 1006 | + | /// The shipped menu's `truncate_name`, brought over with the entry that is its | |
| 1007 | + | /// only caller. It stayed behind in `ui/file_list_menus.rs` when the rest of | |
| 1008 | + | /// that file's menus were described, and the file goes with this. | |
| 1009 | + | fn truncated(name: &str, most: usize) -> String { | |
| 1010 | + | if name.chars().count() <= most { | |
| 1011 | + | return name.to_owned(); | |
| 1012 | + | } | |
| 1013 | + | let kept: String = name.chars().take(most.saturating_sub(3)).collect(); | |
| 1014 | + | format!("{kept}...") | |
| 1015 | + | } | |
| 1016 | + | ||
| 687 | 1017 | /// A duration as the list writes it. | |
| 688 | 1018 | fn seconds(duration: Option<f64>) -> String { | |
| 689 | 1019 | let Some(seconds) = duration else { |
| @@ -602,6 +602,22 @@ | |||
| 602 | 602 | /// acts that need the file on disk. A sample nobody has fetched can still be | |
| 603 | 603 | /// listed, named and tagged, so this is not the same fact as absence. | |
| 604 | 604 | pub cloud_only: bool, | |
| 605 | + | /// Whether this row is one of the rows the reader has chosen. | |
| 606 | + | /// | |
| 607 | + | /// `41996627`. Not the same fact as [`Files::current`], which is the one row | |
| 608 | + | /// the app is pointing at: the file list carries a live multi-selection -- | |
| 609 | + | /// `state.nav.selection`, set by a click, by Cmd+A and by shift-arrow -- and | |
| 610 | + | /// until this landed a description could not tell one chosen row from an | |
| 611 | + | /// unchosen one. What reads it is `files::menu`, which offers a row inside a | |
| 612 | + | /// multi-selection what can be done to the *set* rather than to the row. | |
| 613 | + | /// | |
| 614 | + | /// Worth knowing where this stops: nothing **shows** it. `Cells` marks the | |
| 615 | + | /// current row and holds a tick, and a tick is a staged set awaiting a | |
| 616 | + | /// commit control rather than a selection that is already in force, so the | |
| 617 | + | /// list draws a five-hundred-row Cmd+A the same as a one-row click. Measured | |
| 618 | + | /// 2026-08-29 and filed; it is why `Anchor::Selection` resolves against | |
| 619 | + | /// nothing here and why this menu hangs off the row instead. | |
| 620 | + | pub selected: bool, | |
| 605 | 621 | /// Whether this sample is in the basket -- one of the samples being looked | |
| 606 | 622 | /// *for*, rather than one of the answers. | |
| 607 | 623 | /// | |
| @@ -705,6 +721,51 @@ | |||
| 705 | 721 | ||
| 706 | 722 | /// Put this sample in the basket, or take it out if it is already in. | |
| 707 | 723 | fn toggle_basket(&self, id: i64); | |
| 724 | + | ||
| 725 | + | /// Choose every row that is not chosen, and unchoose every row that is. | |
| 726 | + | /// | |
| 727 | + | /// The selection menu, 2026-08-29, and the block below it. What tells these | |
| 728 | + | /// from the block above is their subject: each acts on **what is chosen** | |
| 729 | + | /// rather than on a row, so none of them takes an id. The shipped | |
| 730 | + | /// `draw_multi_context_menu` is where they come from, and five of its eleven | |
| 731 | + | /// entries are not here for the reason five of the row menu's are not: the | |
| 732 | + | /// three bulk modals are addresses [`Bulk`] answers, and New Folder and the | |
| 733 | + | /// import doors are [`Naming`]'s and [`Importing`]'s. | |
| 734 | + | fn invert_chosen(&self); | |
| 735 | + | ||
| 736 | + | /// Choose nothing. | |
| 737 | + | fn deselect(&self); | |
| 738 | + | ||
| 739 | + | /// Open the export flow on what is chosen. | |
| 740 | + | /// | |
| 741 | + | /// Not [`Export::open`], which is the same door opened on the folder being | |
| 742 | + | /// shown. The difference is the argument the app's own `start_export_flow` | |
| 743 | + | /// takes, and it is the whole of why this is a method rather than a link to | |
| 744 | + | /// `/export/begin`. | |
| 745 | + | fn export_chosen(&self); | |
| 746 | + | ||
| 747 | + | /// Analyse every chosen sample again. | |
| 748 | + | fn reanalyze_chosen(&self); | |
| 749 | + | ||
| 750 | + | /// Put every chosen sample's path on the clipboard, one per line. | |
| 751 | + | fn copy_chosen_paths(&self); | |
| 752 | + | ||
| 753 | + | /// Give every other chosen sample the tags the sample in focus carries. | |
| 754 | + | fn copy_tags_from_focus(&self); | |
| 755 | + | ||
| 756 | + | /// Delete what is chosen. | |
| 757 | + | /// | |
| 758 | + | /// The asking is the app's rather than the act's, which is the reading | |
| 759 | + | /// [`delete`](Self::delete) already records: `confirm_delete_selected` | |
| 760 | + | /// counts what is about to go, and a count is what the reader needs when the | |
| 761 | + | /// subject is a set. | |
| 762 | + | fn delete_chosen(&self); | |
| 763 | + | ||
| 764 | + | /// Put every chosen sample into that collection. | |
| 765 | + | fn add_chosen_to_collection(&self, collection: i64); | |
| 766 | + | ||
| 767 | + | /// Take every chosen sample out of the collection being viewed. | |
| 768 | + | fn remove_chosen_from_collection(&self); | |
| 708 | 769 | } | |
| 709 | 770 | ||
| 710 | 771 | /// What a described screen asked the app to do to itself. | |
| @@ -809,6 +870,24 @@ | |||
| 809 | 870 | WalkBack(usize), | |
| 810 | 871 | /// Put a sample in the basket, or take it out of it. | |
| 811 | 872 | ToggleBasket(i64), | |
| 873 | + | /// Choose what is not chosen and unchoose what is. | |
| 874 | + | InvertChosen, | |
| 875 | + | /// Choose nothing. | |
| 876 | + | Deselect, | |
| 877 | + | /// Open the export flow on what is chosen. | |
| 878 | + | ExportChosen, | |
| 879 | + | /// Analyse every chosen sample again. | |
| 880 | + | ReanalyzeChosen, | |
| 881 | + | /// Put every chosen sample's path on the clipboard. | |
| 882 | + | CopyChosenPaths, | |
| 883 | + | /// Spread the focused sample's tags across the rest of what is chosen. | |
| 884 | + | CopyTagsFromFocus, | |
| 885 | + | /// Delete what is chosen, the counted asking still to come. | |
| 886 | + | DeleteChosen, | |
| 887 | + | /// Put every chosen sample into this collection. | |
| 888 | + | AddChosenToCollection(i64), | |
| 889 | + | /// Take every chosen sample out of the collection being viewed. | |
| 890 | + | RemoveChosenFromCollection, | |
| 812 | 891 | /// Take the anchor at this position out of the basket. | |
| 813 | 892 | RemoveFromBasket(usize), | |
| 814 | 893 | /// Show what is near every sample in the basket. | |
| @@ -1180,11 +1259,13 @@ | |||
| 1180 | 1259 | ||
| 1181 | 1260 | impl Files for FromContents<'_> { | |
| 1182 | 1261 | fn samples(&self) -> Vec<Sample> { | |
| 1262 | + | let offset = usize::from(self.state.nav.current_dir.is_some()); | |
| 1183 | 1263 | self.state | |
| 1184 | 1264 | .nav | |
| 1185 | 1265 | .contents | |
| 1186 | 1266 | .iter() | |
| 1187 | - | .map(|node| Sample { | |
| 1267 | + | .enumerate() | |
| 1268 | + | .map(|(at, node)| Sample { | |
| 1188 | 1269 | id: node.node.id.as_i64(), | |
| 1189 | 1270 | name: node.node.name.clone(), | |
| 1190 | 1271 | duration: node.duration, | |
| @@ -1202,6 +1283,13 @@ | |||
| 1202 | 1283 | .sample_hash | |
| 1203 | 1284 | .as_deref() | |
| 1204 | 1285 | .is_some_and(|hash| self.state.search.basket_position(hash).is_some()), | |
| 1286 | + | // The offset is the `..` entry's: `nav.selection` indexes the | |
| 1287 | + | // rows the *list* shows and the parent entry is one of them, | |
| 1288 | + | // while `nav.contents` holds only what is in the folder. Every | |
| 1289 | + | // other reader of the selection makes the same adjustment -- | |
| 1290 | + | // `selected_nodes`, `selected_node`, `panel::collapse_onto` -- | |
| 1291 | + | // and one that did not would be off by one inside every folder. | |
| 1292 | + | selected: self.state.nav.selection.selected.contains(&(at + offset)), | |
| 1205 | 1293 | // Anchor hashes are what the state holds, because a hash is what | |
| 1206 | 1294 | // a query is about. A name is what a row can show, so the | |
| 1207 | 1295 | // translation happens here, at the edge, rather than being | |
| @@ -1306,6 +1394,46 @@ | |||
| 1306 | 1394 | .borrow_mut() | |
| 1307 | 1395 | .push(Intent::AddToCollection(id, collection)); | |
| 1308 | 1396 | } | |
| 1397 | + | ||
| 1398 | + | fn invert_chosen(&self) { | |
| 1399 | + | self.intents.borrow_mut().push(Intent::InvertChosen); | |
| 1400 | + | } | |
| 1401 | + | ||
| 1402 | + | fn deselect(&self) { | |
| 1403 | + | self.intents.borrow_mut().push(Intent::Deselect); | |
| 1404 | + | } | |
| 1405 | + | ||
| 1406 | + | fn export_chosen(&self) { | |
| 1407 | + | self.intents.borrow_mut().push(Intent::ExportChosen); | |
| 1408 | + | } | |
| 1409 | + | ||
| 1410 | + | fn reanalyze_chosen(&self) { | |
| 1411 | + | self.intents.borrow_mut().push(Intent::ReanalyzeChosen); | |
| 1412 | + | } | |
| 1413 | + | ||
| 1414 | + | fn copy_chosen_paths(&self) { | |
| 1415 | + | self.intents.borrow_mut().push(Intent::CopyChosenPaths); | |
| 1416 | + | } | |
| 1417 | + | ||
| 1418 | + | fn copy_tags_from_focus(&self) { | |
| 1419 | + | self.intents.borrow_mut().push(Intent::CopyTagsFromFocus); | |
| 1420 | + | } | |
| 1421 | + | ||
| 1422 | + | fn delete_chosen(&self) { | |
| 1423 | + | self.intents.borrow_mut().push(Intent::DeleteChosen); | |
| 1424 | + | } | |
| 1425 | + | ||
| 1426 | + | fn add_chosen_to_collection(&self, collection: i64) { | |
| 1427 | + | self.intents | |
| 1428 | + | .borrow_mut() | |
| 1429 | + | .push(Intent::AddChosenToCollection(collection)); | |
| 1430 | + | } | |
| 1431 | + | ||
| 1432 | + | fn remove_chosen_from_collection(&self) { | |
| 1433 | + | self.intents | |
| 1434 | + | .borrow_mut() | |
| 1435 | + | .push(Intent::RemoveChosenFromCollection); | |
| 1436 | + | } | |
| 1309 | 1437 | } | |
| 1310 | 1438 | ||
| 1311 | 1439 | /// Where the export flow has got to. |
| @@ -86,20 +86,26 @@ | |||
| 86 | 86 | /// moves with no intent at all. So the answer is `quasi` 0.12.0's | |
| 87 | 87 | /// `Runtime::reload`, and this is the flag that says when to call it. | |
| 88 | 88 | stale: bool, | |
| 89 | - | /// Calls a host picker answered, waiting for the frame that can make them. | |
| 89 | + | /// Calls the host wants made, waiting for the frame that can make them. | |
| 90 | 90 | /// | |
| 91 | - | /// `Outcome::Locate`'s host half. A picker runs off the frame thread and | |
| 92 | - | /// answers through a handler that holds `&mut BrowserState` and no runtime, | |
| 93 | - | /// so what it can do is write the call down; [`drive`] makes it on the next | |
| 94 | - | /// frame of the screen that asked. Keyed by that screen's home address, | |
| 95 | - | /// because one of these is shared by every described window and a call | |
| 96 | - | /// belongs to the runtime that raised it. | |
| 91 | + | /// Two producers, and what they have in common is the frame boundary: each | |
| 92 | + | /// knows a call is wanted at a moment when there is no runtime to make it. | |
| 93 | + | /// | |
| 94 | + | /// - **`Outcome::Locate`'s host half.** A picker runs off the frame thread | |
| 95 | + | /// and answers through a handler that holds `&mut BrowserState` and no | |
| 96 | + | /// runtime, so what it can do is write the call down. | |
| 97 | + | /// - **A gesture the description cannot carry.** [`menued`] reads a press on | |
| 98 | + | /// the part of the list that is not a row, which no described thing can | |
| 99 | + | /// answer, and raises the empty-space menu's address. | |
| 100 | + | /// | |
| 101 | + | /// [`drive`] makes them on the next frame of the screen that asked. Keyed by | |
| 102 | + | /// that screen's home address, because one of these is shared by every | |
| 103 | + | /// described window and a call belongs to the runtime that raised it. | |
| 97 | 104 | /// | |
| 98 | 105 | /// A queue rather than a slot: several windows can have a picker out at | |
| 99 | - | /// once and each one's call belongs to the runtime that raised it, so a | |
| 100 | - | /// slot would keep the last of them. One ask still puts one call here, | |
| 101 | - | /// however many files came back, which is [`write_down`]'s job. | |
| 102 | - | located: parking_lot::Mutex<Vec<(String, Request)>>, | |
| 106 | + | /// once, so a slot would keep the last of them. One ask still puts one call | |
| 107 | + | /// here, however many files came back, which is [`write_down`]'s job. | |
| 108 | + | raised: parking_lot::Mutex<Vec<(String, Request)>>, | |
| 103 | 109 | /// How much room is left where an export would write. | |
| 104 | 110 | /// | |
| 105 | 111 | /// A host fact the export description does arithmetic with, and the cache | |
| @@ -192,11 +198,58 @@ | |||
| 192 | 198 | state.described.shell = runtime; | |
| 193 | 199 | apply(ui.ctx(), state, sync, intents.into_inner()); | |
| 194 | 200 | ||
| 195 | - | // The one gesture the description does not carry, after the intents rather | |
| 201 | + | // The two gestures the description does not carry, after the intents rather | |
| 196 | 202 | // than before them: what a press chose is what a drag should carry out. | |
| 197 | 203 | if dragging_out(ui, state) { | |
| 198 | 204 | crate::ui::file_list_menus::start_os_drag(state); | |
| 199 | 205 | } | |
| 206 | + | menued(ui, state); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /// Raise the empty-space menu when a press lands on no row. | |
| 210 | + | /// | |
| 211 | + | /// The second host gesture, and the smaller half of audiofiles `41996627`. A | |
| 212 | + | /// row answers a menu because a row is a described thing and `Cells::menu` says | |
| 213 | + | /// what it offers; **the absence of a row is not a described thing**, so nothing | |
| 214 | + | /// in a description can answer a press on the part of the list that is not one. | |
| 215 | + | /// The renderer is the only side that knows -- it drew the rows and it knows | |
| 216 | + | /// where they went -- and `quasi_immediate::row_at` is where it says so. | |
| 217 | + | /// | |
| 218 | + | /// So the host reads the press and asks for the address; the route answers | |
| 219 | + | /// `Outcome::Anchored` at the region, and the renderer resolves that back to the | |
| 220 | + | /// rect it drew. The description still never carries a point. | |
| 221 | + | /// | |
| 222 | + | /// Three guards, and each is the answer to a way this could be wrong: | |
| 223 | + | /// | |
| 224 | + | /// - **The press landed on no row.** A press on one is the renderer's own | |
| 225 | + | /// context menu, which is the row's or -- when several rows are chosen and | |
| 226 | + | /// this is one of them -- the selection's. Both are already open by the time | |
| 227 | + | /// this runs, and raising a second menu over them is the failure this guard | |
| 228 | + | /// exists to stop. | |
| 229 | + | /// - **The press landed in the room the shell was given.** Which leaves out the | |
| 230 | + | /// filter panel, the detail panel and every floating window, the same way | |
| 231 | + | /// [`dragging_out`] does. | |
| 232 | + | /// - **The call is raised rather than made.** There is no runtime here: this | |
| 233 | + | /// runs after `inline` has given it back. [`Described::raised`] is the queue | |
| 234 | + | /// the next frame drains, which is the same road a picker's answer takes. | |
| 235 | + | fn menued(ui: &egui::Ui, state: &BrowserState) { | |
| 236 | + | let Some(origin) = ui.ctx().input(|input| { | |
| 237 | + | input | |
| 238 | + | .pointer | |
| 239 | + | .button_clicked(egui::PointerButton::Secondary) | |
| 240 | + | .then(|| input.pointer.interact_pos()) | |
| 241 | + | .flatten() | |
| 242 | + | }) else { | |
| 243 | + | return; | |
| 244 | + | }; | |
| 245 | + | if !ui.max_rect().contains(origin) || quasi_immediate::row_at(ui.ctx(), origin).is_some() { | |
| 246 | + | return; | |
| 247 | + | } | |
| 248 | + | state | |
| 249 | + | .described | |
| 250 | + | .raised | |
| 251 | + | .lock() | |
| 252 | + | .push(("/".to_owned(), Request::get("/files/menu/background"))); | |
| 200 | 253 | } | |
| 201 | 254 | ||
| 202 | 255 | /// Draw the described sample editor, and act on whatever was pressed. | |
| @@ -666,6 +719,152 @@ | |||
| 666 | 719 | } | |
| 667 | 720 | } | |
| 668 | 721 | } | |
| 722 | + | // The selection menu and the empty-space menu, 2026-08-29. Every | |
| 723 | + | // arm below is `ui/file_list_menus.rs`'s own body, moved rather than | |
| 724 | + | // rewritten: the described side says what is offered and the app | |
| 725 | + | // does what it always did. | |
| 726 | + | Intent::InvertChosen => state.invert_selection(), | |
| 727 | + | Intent::Deselect => { | |
| 728 | + | state.nav.selection.clear(); | |
| 729 | + | state.refresh_selected_tags(); | |
| 730 | + | state.refresh_selected_detail(); | |
| 731 | + | } | |
| 732 | + | Intent::ExportChosen => { | |
| 733 | + | let chosen = state.selected_node_ids(); | |
| 734 | + | state.start_export_flow(Some(chosen)); | |
| 735 | + | } | |
| 736 | + | // The overwrite branch is the shipped menu's, and it is the same | |
| 737 | + | // question `Reanalyze` asks one row at a time: analysis that would | |
| 738 | + | // replace numbers already there asks first, and the count is what | |
| 739 | + | // makes the asking worth anything over a set. | |
| 740 | + | Intent::ReanalyzeChosen => { | |
| 741 | + | let chosen = state.selected_nodes(); | |
| 742 | + | let hashes: Vec<(String, String)> = chosen | |
| 743 | + | .iter() | |
| 744 | + | .filter_map(|node| { | |
| 745 | + | let hash = node.node.sample_hash.as_ref()?; | |
| 746 | + | let ext = state.backend.sample_extension(hash).ok()?; | |
| 747 | + | Some((hash.to_string(), ext)) | |
| 748 | + | }) | |
| 749 | + | .collect(); | |
| 750 | + | let overwriting = chosen | |
| 751 | + | .iter() | |
| 752 | + | .filter(|node| node.bpm.is_some() || node.musical_key.is_some()) | |
| 753 | + | .count(); | |
| 754 | + | if overwriting > 0 { | |
| 755 | + | state.overlay.pending_confirm = | |
| 756 | + | Some(crate::state::ConfirmAction::ReanalyzeOverwrite { | |
| 757 | + | sample_hashes: hashes, | |
| 758 | + | overwrite_count: overwriting, | |
| 759 | + | }); | |
| 760 | + | } else { | |
| 761 | + | state.start_analysis_flow(hashes); | |
| 762 | + | } | |
| 763 | + | } | |
| 764 | + | // The status line names the first path and counts the rest, which is | |
| 765 | + | // the shipped line and its reason: a bare "Copied N paths" gives a | |
| 766 | + | // reader no way to tell which selection won the race when they | |
| 767 | + | // copied, changed their mind, and then pasted into a DAW. | |
| 768 | + | Intent::CopyChosenPaths => { | |
| 769 | + | let paths: Vec<String> = state | |
| 770 | + | .selected_nodes() | |
| 771 | + | .iter() | |
| 772 | + | .filter_map(|node| { | |
| 773 | + | let hash = node.node.sample_hash.as_ref()?; | |
| 774 | + | let ext = state.backend.sample_extension(hash).ok()?; | |
| 775 | + | Some( | |
| 776 | + | state | |
| 777 | + | .backend | |
| 778 | + | .sample_path(hash, &ext) | |
| 779 | + | .ok()? | |
| 780 | + | .to_string_lossy() | |
| 781 | + | .into_owned(), | |
| 782 | + | ) | |
| 783 | + | }) | |
| 784 | + | .collect(); | |
| 785 | + | if let Some(first) = paths.first() { | |
| 786 | + | state.status = if paths.len() == 1 { | |
| 787 | + | format!("Copied: {first}") | |
| 788 | + | } else { | |
| 789 | + | format!("Copied: {first} (+{} more)", paths.len() - 1) | |
| 790 | + | }; | |
| 791 | + | ctx.copy_text(paths.join("\n")); | |
| 792 | + | } | |
| 793 | + | } | |
| 794 | + | Intent::CopyTagsFromFocus => { | |
| 795 | + | let focused = state | |
| 796 | + | .selected_node() | |
| 797 | + | .and_then(|node| node.node.sample_hash.clone()); | |
| 798 | + | if let Some(source) = focused | |
| 799 | + | && let Ok(tags) = state.backend.get_sample_tags(&source) | |
| 800 | + | { | |
| 801 | + | let source = source.to_string(); | |
| 802 | + | let mut applied = 0usize; | |
| 803 | + | let mut failed = 0usize; | |
| 804 | + | for hash in &state.selected_sample_hashes() { | |
| 805 | + | if *hash == source { | |
| 806 | + | continue; | |
| 807 | + | } | |
| 808 | + | if tags | |
| 809 | + | .iter() | |
| 810 | + | .all(|tag| state.backend.add_tag(hash, tag).is_ok()) | |
| 811 | + | { | |
| 812 | + | applied += 1; | |
| 813 | + | } else { | |
| 814 | + | failed += 1; | |
| 815 | + | } | |
| 816 | + | } | |
| 817 | + | state.status = if failed == 0 { | |
| 818 | + | format!("Copied {} tags to {applied} samples", tags.len()) | |
| 819 | + | } else { | |
| 820 | + | format!( | |
| 821 | + | "Copied {} tags to {applied} samples ({failed} failed)", | |
| 822 | + | tags.len() | |
| 823 | + | ) | |
| 824 | + | }; | |
| 825 | + | state.refresh_selected_tags(); | |
| 826 | + | } | |
| 827 | + | } | |
| 828 | + | Intent::DeleteChosen => state.confirm_delete_selected(), | |
| 829 | + | // Directories are skipped rather than counted: a folder has no hash, | |
| 830 | + | // so it was never going into the collection and reporting it as | |
| 831 | + | // added would over-count what happened. | |
| 832 | + | Intent::AddChosenToCollection(collection) => { | |
| 833 | + | let named = state | |
| 834 | + | .collections_ui | |
| 835 | + | .collections | |
| 836 | + | .iter() | |
| 837 | + | .find(|it| it.id.as_i64() == collection) | |
| 838 | + | .map(|it| (it.id, it.name.clone())); | |
| 839 | + | if let Some((collection, name)) = named { | |
| 840 | + | let (mut added, mut failed) = (0usize, 0usize); | |
| 841 | + | for node in &state.selected_nodes() { | |
| 842 | + | if let Some(hash) = &node.node.sample_hash { | |
| 843 | + | match state.backend.add_to_collection(collection, hash) { | |
| 844 | + | Ok(()) => added += 1, | |
| 845 | + | Err(_) => failed += 1, | |
| 846 | + | } | |
| 847 | + | } | |
| 848 | + | } | |
| 849 | + | state.refresh_collections(); | |
| 850 | + | state.status = if failed == 0 { | |
| 851 | + | format!("Added {added} items to {name}") | |
| 852 | + | } else { | |
| 853 | + | format!("Added {added} items to {name} ({failed} failed)") | |
| 854 | + | }; | |
| 855 | + | } | |
| 856 | + | } | |
| 857 | + | Intent::RemoveChosenFromCollection => { | |
| 858 | + | if let Some(active) = state.collections_ui.active_collection { | |
| 859 | + | for node in &state.selected_nodes() { | |
| 860 | + | if let Some(hash) = &node.node.sample_hash { | |
| 861 | + | let _ = state.backend.remove_from_collection(active, hash); | |
| 862 | + | } | |
| 863 | + | } | |
| 864 | + | state.refresh_collections(); | |
| 865 | + | state.activate_collection(active); | |
| 866 | + | } | |
| 867 | + | } | |
| 669 | 868 | Intent::SortBy(column) => { | |
| 670 | 869 | let key = match column.as_str() { | |
| 671 | 870 | "Name" => crate::state::SortColumn::Name, | |
| @@ -2043,11 +2242,18 @@ | |||
| 2043 | 2242 | /// an index is a fact about the current filter and sort, which is exactly the | |
| 2044 | 2243 | /// kind of thing an address should not be. | |
| 2045 | 2244 | fn index_of(state: &BrowserState, id: i64) -> Option<usize> { | |
| 2245 | + | // The `..` entry's offset, which every other reader of the selection applies | |
| 2246 | + | // -- `selected_nodes`, `selected_node`, `collapse_onto`. Without it every | |
| 2247 | + | // row menu inside a folder acted on the row above the one pressed: this | |
| 2248 | + | // returns a position in `nav.contents` and `nav.selection` indexes the rows | |
| 2249 | + | // the list shows, which is one longer while there is a parent to go back to. | |
| 2250 | + | let offset = usize::from(state.nav.current_dir.is_some()); | |
| 2046 | 2251 | state | |
| 2047 | 2252 | .nav | |
| 2048 | 2253 | .contents | |
| 2049 | 2254 | .iter() | |
| 2050 | 2255 | .position(|node| node.node.id.as_i64() == id) | |
| 2256 | + | .map(|at| at + offset) | |
| 2051 | 2257 | } | |
| 2052 | 2258 | ||
| 2053 | 2259 | /// Everything a described screen is answered out of. | |
| @@ -2118,7 +2324,7 @@ | |||
| 2118 | 2324 | // What a host picker answered, called now that there is a runtime to answer | |
| 2119 | 2325 | // into. Before the refresh below, so the frame that shows the new | |
| 2120 | 2326 | // destination is the frame the reader's choice lands on. See `locate`. | |
| 2121 | - | for request in answered(host.state, home) { | |
| 2327 | + | for request in raised_for(host.state, home) { | |
| 2122 | 2328 | call(runtime, host, request); | |
| 2123 | 2329 | } | |
| 2124 | 2330 | ||
| @@ -2575,7 +2781,7 @@ | |||
| 2575 | 2781 | /// **Nothing is drawn and nothing navigates.** `ui::dialog` runs the picker off | |
| 2576 | 2782 | /// the frame thread and answers frames later through a handler that holds | |
| 2577 | 2783 | /// `&mut BrowserState`, so the call the ask names cannot be made where it is | |
| 2578 | - | /// raised. The handler writes it into [`Described::located`] and [`drive`] makes | |
| 2784 | + | /// raised. The handler writes it into [`Described::raised`] and [`drive`] makes | |
| 2579 | 2785 | /// it on the next frame of the screen that asked. | |
| 2580 | 2786 | /// | |
| 2581 | 2787 | /// A reader who backs out has answered nothing: `ui::dialog` skips the handler | |
| @@ -2655,18 +2861,18 @@ | |||
| 2655 | 2861 | if let Some(request) = asking.answered(picks) { | |
| 2656 | 2862 | state | |
| 2657 | 2863 | .described | |
| 2658 | - | .located | |
| 2864 | + | .raised | |
| 2659 | 2865 | .lock() | |
| 2660 | 2866 | .push((home.to_owned(), request)); | |
| 2661 | 2867 | } | |
| 2662 | 2868 | } | |
| 2663 | 2869 | ||
| 2664 | - | /// The calls this screen's pickers have answered, in the order they were picked. | |
| 2870 | + | /// The calls waiting for this screen, in the order they were raised. | |
| 2665 | 2871 | /// | |
| 2666 | 2872 | /// Keyed by home address because one queue serves every described window, and a | |
| 2667 | 2873 | /// call raised by the export flow is not the settings screen's to make. | |
| 2668 | - | fn answered(state: &BrowserState, home: &str) -> Vec<Request> { | |
| 2669 | - | let mut waiting = state.described.located.lock(); | |
| 2874 | + | fn raised_for(state: &BrowserState, home: &str) -> Vec<Request> { | |
| 2875 | + | let mut waiting = state.described.raised.lock(); | |
| 2670 | 2876 | let (mine, theirs) = waiting | |
| 2671 | 2877 | .drain(..) | |
| 2672 | 2878 | .partition::<Vec<_>, _>(|(asked, _)| asked == home); |
| @@ -150,6 +150,35 @@ | |||
| 150 | 150 | fn toggle_basket(&self, id: i64) { | |
| 151 | 151 | self.asked.borrow_mut().push(format!("basket:{id}")); | |
| 152 | 152 | } | |
| 153 | + | fn invert_chosen(&self) { | |
| 154 | + | self.asked.borrow_mut().push("invert".to_owned()); | |
| 155 | + | } | |
| 156 | + | fn deselect(&self) { | |
| 157 | + | self.asked.borrow_mut().push("deselect".to_owned()); | |
| 158 | + | } | |
| 159 | + | fn export_chosen(&self) { | |
| 160 | + | self.asked.borrow_mut().push("export-chosen".to_owned()); | |
| 161 | + | } | |
| 162 | + | fn reanalyze_chosen(&self) { | |
| 163 | + | self.asked.borrow_mut().push("reanalyze-chosen".to_owned()); | |
| 164 | + | } | |
| 165 | + | fn copy_chosen_paths(&self) { | |
| 166 | + | self.asked.borrow_mut().push("copy-paths".to_owned()); | |
| 167 | + | } | |
| 168 | + | fn copy_tags_from_focus(&self) { | |
| 169 | + | self.asked.borrow_mut().push("copy-tags".to_owned()); | |
| 170 | + | } | |
| 171 | + | fn delete_chosen(&self) { | |
| 172 | + | self.asked.borrow_mut().push("delete-chosen".to_owned()); | |
| 173 | + | } | |
| 174 | + | fn add_chosen_to_collection(&self, collection: i64) { | |
| 175 | + | self.asked | |
| 176 | + | .borrow_mut() | |
| 177 | + | .push(format!("add-chosen:{collection}")); | |
| 178 | + | } | |
| 179 | + | fn remove_chosen_from_collection(&self) { | |
| 180 | + | self.asked.borrow_mut().push("remove-chosen".to_owned()); | |
| 181 | + | } | |
| 153 | 182 | fn add_to_collection(&self, id: i64, collection: i64) { | |
| 154 | 183 | self.asked | |
| 155 | 184 | .borrow_mut() | |
| @@ -377,10 +406,19 @@ | |||
| 377 | 406 | directory: false, | |
| 378 | 407 | cloud_only: false, | |
| 379 | 408 | basket: false, | |
| 409 | + | selected: false, | |
| 380 | 410 | matched: Vec::new(), | |
| 381 | 411 | } | |
| 382 | 412 | } | |
| 383 | 413 | ||
| 414 | + | /// A sample the reader has chosen, which is what the selection menu hangs on. | |
| 415 | + | fn chosen(id: i64, name: &str) -> Sample { | |
| 416 | + | Sample { | |
| 417 | + | selected: true, | |
| 418 | + | ..sample(id, name) | |
| 419 | + | } | |
| 420 | + | } | |
| 421 | + | ||
| 384 | 422 | /// A folder row, which offers a different menu. | |
| 385 | 423 | fn folder(id: i64, name: &str) -> Sample { | |
| 386 | 424 | Sample { | |
| @@ -2240,6 +2278,177 @@ | |||
| 2240 | 2278 | ); | |
| 2241 | 2279 | } | |
| 2242 | 2280 | ||
| 2281 | + | #[test] | |
| 2282 | + | fn a_row_inside_a_multi_selection_offers_the_selection_menu() { | |
| 2283 | + | // `41996627`. The subject of a press is what the press is about, and a row | |
| 2284 | + | // the reader has chosen among several is not being asked about itself. The | |
| 2285 | + | // shipped list forked at exactly this test -- `selection.count() > 1 && | |
| 2286 | + | // contains(row_idx)` -- and the assertion is against what it drew there. | |
| 2287 | + | let files = FakeFiles::with(vec![chosen(7, "kick.wav"), chosen(9, "snare.wav")]); | |
| 2288 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 2289 | + | let (_, rows) = table_of(screen_of(&response)); | |
| 2290 | + | ||
| 2291 | + | let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect(); | |
| 2292 | + | assert_eq!( | |
| 2293 | + | labels, | |
| 2294 | + | [ | |
| 2295 | + | "Invert Selection (Cmd+Shift+I)", | |
| 2296 | + | "Tag... (Cmd+T)", | |
| 2297 | + | "Move to... (Cmd+Shift+M)", | |
| 2298 | + | "Rename... (F2)", | |
| 2299 | + | "Export...", | |
| 2300 | + | "Re-analyze...", | |
| 2301 | + | "Copy Path", | |
| 2302 | + | "Delete", | |
| 2303 | + | ], | |
| 2304 | + | "the described selection menu drifted from the shipped one" | |
| 2305 | + | ); | |
| 2306 | + | // Every chosen row offers the same menu, because the subject is the set | |
| 2307 | + | // rather than the row the pointer happens to be over. | |
| 2308 | + | let second: Vec<&str> = rows[1].menu.iter().map(|act| act.label.as_str()).collect(); | |
| 2309 | + | assert_eq!(labels, second); | |
| 2310 | + | ||
| 2311 | + | // The three bulk modals are addresses that already answer, which is the | |
| 2312 | + | // accounting the row menu keeps: nothing about the set travels in the link. | |
| 2313 | + | let bulk: Vec<&str> = rows[0].menu[1..4] | |
| 2314 | + | .iter() | |
| 2315 | + | .map(|act| act.action.destination.as_str()) | |
| 2316 | + | .collect(); | |
| 2317 | + | assert_eq!(bulk, ["/bulk/tag", "/bulk/move", "/bulk/rename"]); | |
| 2318 | + | ||
| 2319 | + | let delete = rows[0].menu.last().expect("the menu ends in Delete"); | |
| 2320 | + | assert_eq!(delete.action.destination.as_str(), "/files/chosen/delete"); | |
| 2321 | + | assert_eq!(delete.tone, quasi_router::layout::Tone::Danger); | |
| 2322 | + | // No `confirm`, and that is not an omission: the app raises its own dialog | |
| 2323 | + | // and counts what is about to go, which is what a reader needs when the | |
| 2324 | + | // subject is a set rather than one named file. | |
| 2325 | + | assert_eq!(delete.confirm, None); | |
| 2326 | + | } | |
| 2327 | + | ||
| 2328 | + | #[test] | |
| 2329 | + | fn one_chosen_row_still_offers_its_own_menu() { | |
| 2330 | + | // The other side of the fork. Choosing a row and then right-clicking it is | |
| 2331 | + | // asking about that row, and the count is what tells the two apart. | |
| 2332 | + | let files = FakeFiles::with(vec![chosen(7, "kick.wav"), sample(9, "snare.wav")]); | |
| 2333 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 2334 | + | let (_, rows) = table_of(screen_of(&response)); | |
| 2335 | + | ||
| 2336 | + | let labels: Vec<&str> = rows[0].menu.iter().map(|act| act.label.as_str()).collect(); | |
| 2337 | + | assert_eq!(labels.first(), Some(&"Preview"), "{labels:?}"); | |
| 2338 | + | assert!(!labels.contains(&"Invert Selection (Cmd+Shift+I)")); | |
| 2339 | + | } | |
| 2340 | + | ||
| 2341 | + | #[test] | |
| 2342 | + | fn the_selection_menu_names_the_sample_whose_tags_would_spread() { | |
| 2343 | + | // Withheld when nothing is in focus, which is what the shipped entry does by | |
| 2344 | + | // asking for the focused node first: there is no sample to copy tags from. | |
| 2345 | + | let mut files = FakeFiles::with(vec![chosen(7, "kick.wav"), chosen(9, "snare.wav")]); | |
| 2346 | + | files.current = Some(9); | |
| 2347 | + | let response = listing(&files, Request::get("/files")).expect("answered"); | |
| 2348 | + | let (_, rows) = table_of(screen_of(&response)); | |
| 2349 | + | ||
| 2350 | + | let copy = rows[0] | |
| 2351 | + | .menu | |
| 2352 | + | .iter() | |
| 2353 | + | .find(|act| act.label.starts_with("Copy Tags")) | |
| 2354 | + | .expect("the entry is offered when a row is in focus"); | |
| 2355 | + | assert_eq!(copy.label, "Copy Tags from \"snare.wav\""); | |
| 2356 | + | assert_eq!(copy.action.destination.as_str(), "/files/chosen/tags/copy"); | |
| 2357 | + | } | |
| 2358 | + | ||
| 2359 | + | #[test] | |
| 2360 | + | fn every_selection_entry_reaches_the_app_over_the_whole_set() { | |
| 2361 | + | // None of these addresses carries an id, which is the whole of what tells | |
| 2362 | + | // them from the row menu: the subject is what is chosen, and the app reads | |
| 2363 | + | // its own selection rather than being handed one. | |
| 2364 | + | let files = FakeFiles::with(vec![chosen(7, "kick.wav"), chosen(9, "snare.wav")]); | |
| 2365 | + | for verb in [ | |
| 2366 | + | "invert", | |
| 2367 | + | "clear", | |
| 2368 | + | "export", | |
| 2369 | + | "reanalyze", | |
| 2370 | + | "path/copy", | |
| 2371 | + | "tags/copy", | |
| 2372 | + | "delete", | |
| 2373 | + | "collection/remove", | |
| 2374 | + | ] { | |
| 2375 | + | listing(&files, Request::post(format!("/files/chosen/{verb}"))).expect("answered"); | |
| 2376 | + | } | |
| 2377 | + | let add = Request::post("/files/chosen/collection/add") | |
| 2378 | + | .sending(Params::new().with("collection".to_owned(), "3".to_owned())); | |
| 2379 | + | listing_in_collection(&files, add).expect("answered"); | |
| 2380 | + | ||
| 2381 | + | assert_eq!( | |
| 2382 | + | files.asked(), | |
| 2383 | + | [ | |
| 2384 | + | "invert", | |
| 2385 | + | "deselect", | |
| 2386 | + | "export-chosen", | |
| 2387 | + | "reanalyze-chosen", | |
| 2388 | + | "copy-paths", | |
| 2389 | + | "copy-tags", | |
| 2390 | + | "delete-chosen", | |
| 2391 | + | "remove-chosen", | |
| 2392 | + | "add-chosen:3", | |
| 2393 | + | ] | |
| 2394 | + | ); | |
| 2395 | + | } | |
| 2396 | + | ||
| 2397 | + | #[test] | |
| 2398 | + | fn the_empty_space_menu_is_anchored_at_the_list_it_was_opened_on() { | |
| 2399 | + | // The half of `41996627` that really is `Outcome::Anchored`: a press on the | |
| 2400 | + | // part of the list that is not a row has no row to hang a menu off, so the | |
| 2401 | + | // anchor names the region and the renderer resolves it against the rect it | |
| 2402 | + | // drew. | |
| 2403 | + | let files = FakeFiles::with(vec![sample(7, "kick.wav")]); | |
| 2404 | + | let response = listing(&files, Request::get("/files/menu/background")).expect("answered"); | |
| 2405 | + | ||
| 2406 | + | let quasi_router::Outcome::Anchored { screen, anchor } = &response.outcome else { | |
| 2407 | + | panic!( | |
| 2408 | + | "the empty-space menu is not anchored: {:?}", | |
| 2409 | + | response.outcome | |
| 2410 | + | ); | |
| 2411 | + | }; | |
| 2412 | + | assert_eq!( | |
| 2413 | + | anchor, | |
| 2414 | + | &quasi_router::Anchor::Region("files-body".to_owned()) | |
| 2415 | + | ); | |
| 2416 | + | ||
| 2417 | + | let labels = acts(screen); | |
| 2418 | + | assert_eq!( | |
| 2419 | + | labels, | |
| 2420 | + | ["New Folder", "Import files...", "Import folder...", "Close"], | |
| 2421 | + | "the described empty-space menu drifted from the shipped one" | |
| 2422 | + | ); | |
| 2423 | + | } | |
| 2424 | + | ||
| 2425 | + | #[test] | |
| 2426 | + | fn the_empty_space_menu_offers_the_selection_only_when_there_is_one() { | |
| 2427 | + | // The shipped menu withholds both entries when nothing is chosen, which is | |
| 2428 | + | // the same judgement the row menu makes about Remove from Collection: an act | |
| 2429 | + | // with no subject is not drawn dead. | |
| 2430 | + | let files = FakeFiles::with(vec![chosen(7, "kick.wav"), sample(9, "snare.wav")]); | |
| 2431 | + | let response = listing(&files, Request::get("/files/menu/background")).expect("answered"); | |
| 2432 | + | let quasi_router::Outcome::Anchored { screen, .. } = &response.outcome else { | |
| 2433 | + | panic!("the empty-space menu is not anchored"); | |
| 2434 | + | }; | |
| 2435 | + | ||
| 2436 | + | let labels = acts(screen); | |
| 2437 | + | assert_eq!( | |
| 2438 | + | labels, | |
| 2439 | + | [ | |
| 2440 | + | "New Folder", | |
| 2441 | + | "Import files...", | |
| 2442 | + | "Import folder...", | |
| 2443 | + | // The count is here rather than in a header, because a menu is a | |
| 2444 | + | // list of acts and holds no prose. | |
| 2445 | + | "Deselect (1) (Esc)", | |
| 2446 | + | "Invert Selection (Cmd+Shift+I)", | |
| 2447 | + | "Close", | |
| 2448 | + | ] | |
| 2449 | + | ); | |
| 2450 | + | } | |
| 2451 | + | ||
| 2243 | 2452 | #[test] | |
| 2244 | 2453 | fn a_column_with_no_sort_is_refused_rather_than_ordered_by() { | |
| 2245 | 2454 | let files = FakeFiles::with(vec![sample(1, "kick.wav")]); |
| @@ -1,32 +1,24 @@ | |||
| 1 | - | //! What is left of the file list's context menus, plus the drag-out handler. | |
| 1 | + | //! The two acts the file list borrows from the operating system. | |
| 2 | 2 | //! | |
| 3 | - | //! **Two of the four things in here have no caller**, and that is a regression | |
| 4 | - | //! rather than dead code: the file-list flip (`49b7429`) replaced the shipped | |
| 5 | - | //! list with `quasi::files`, which describes the *row* menu through | |
| 6 | - | //! [`Cells::menu`](quasi_router::Cells) and does not describe the other two | |
| 7 | - | //! menus. So the selection menu and the empty-space menu are both unreachable in | |
| 8 | - | //! the running app. | |
| 3 | + | //! What is left of a file that used to hold four context menus. Each of the four | |
| 4 | + | //! is described now: the row menu since 2026-08-17 (`quasi::files::menu`), and | |
| 5 | + | //! the selection and empty-space menus since 2026-08-29 (`chosen_menu` and | |
| 6 | + | //! `background_menu` beside it, audiofiles `41996627`). The last two had gone | |
| 7 | + | //! unreachable in `49b7429` and were kept rather than deleted while they waited, | |
| 8 | + | //! which is the `da48cb6d` bargain: losing a feature quietly is allowed | |
| 9 | + | //! temporarily and only while it has a task. | |
| 9 | 10 | //! | |
| 10 | - | //! They are kept rather than deleted, because deleting them is losing the | |
| 11 | - | //! features quietly, and what they wait on is `quasi:vocabulary:anchored-menu`: | |
| 12 | - | //! a menu over a selection and a menu over a surface have no container in the | |
| 13 | - | //! vocabulary. Filed as audiofiles `41996627`. | |
| 11 | + | //! What could never be described is here, and the three are one kind of thing -- | |
| 12 | + | //! **the system, reached from the app**. Showing a file in Finder and handing a | |
| 13 | + | //! set of files to a drag are not facts about a sample, so no description says | |
| 14 | + | //! them; the row menu offers [`reveal_label`]'s entry and the host performs it. | |
| 14 | 15 | //! | |
| 15 | - | //! [`start_os_drag`] was the third and has a caller again as of 2026-08-25. | |
| 16 | - | //! Nothing about it was ever going to be described. `quasi::files` records the | |
| 17 | - | //! drag as deliberately undescribed and the reason holds, so what it was missing | |
| 18 | - | //! was a host half, and `quasi::panel::dragging_out` is now that half. | |
| 19 | - | //! Ruled by Max on audiofiles `edd4d1d8`. | |
| 20 | - | //! | |
| 21 | - | //! `draw_context_menu` was the fourth and is deleted (2026-08-25): its | |
| 22 | - | //! replacement ships, so it was the one thing in here that really was dead. | |
| 23 | - | ||
| 24 | - | use egui; | |
| 16 | + | //! [`start_os_drag`] is called by `quasi::panel::dragging_out`, which is where | |
| 17 | + | //! the gesture is read. `quasi::files` records the drag as deliberately | |
| 18 | + | //! undescribed and the reason holds. Ruled by Max on audiofiles `edd4d1d8`. | |
| 25 | 19 | ||
| 26 | 20 | use crate::state::BrowserState; | |
| 27 | 21 | ||
| 28 | - | use super::widgets; | |
| 29 | - | ||
| 30 | 22 | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 31 | 23 | use crate::drag_out; | |
| 32 | 24 | ||
| @@ -77,274 +69,6 @@ | |||
| 77 | 69 | } | |
| 78 | 70 | } | |
| 79 | 71 | ||
| 80 | - | /// Context menu when multiple items are selected. | |
| 81 | - | pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 82 | - | let count = state.nav.selection.count(); | |
| 83 | - | ui.label(egui::RichText::new(format!("{count} items selected")).strong()); | |
| 84 | - | ui.separator(); | |
| 85 | - | ||
| 86 | - | if ui.button("Invert Selection (Cmd+Shift+I)").clicked() { | |
| 87 | - | state.invert_selection(); | |
| 88 | - | ui.close(); | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | ui.separator(); | |
| 92 | - | ||
| 93 | - | if ui.button("Tag... (Cmd+T)").clicked() { | |
| 94 | - | state.open_bulk_tag_modal(); | |
| 95 | - | ui.close(); | |
| 96 | - | } | |
| 97 | - | // m-16: Cmd+M conflicts with the macOS minimize-window shortcut. Label | |
| 98 | - | // advertises Cmd+Shift+M; the actual key binding lives in | |
| 99 | - | // `editor.rs` (search for "Cmd+M: bulk move") and must be updated | |
| 100 | - | // there to match. | |
| 101 | - | if ui.button("Move to... (Cmd+Shift+M)").clicked() { | |
| 102 | - | state.open_bulk_move_modal(); | |
| 103 | - | ui.close(); | |
| 104 | - | } | |
| 105 | - | if ui.button("Rename... (F2)").clicked() { | |
| 106 | - | state.open_bulk_rename_modal(); | |
| 107 | - | ui.close(); | |
| 108 | - | } | |
| 109 | - | if ui.button("Export...").clicked() { | |
| 110 | - | let node_ids = state.selected_node_ids(); | |
| 111 | - | state.start_export_flow(Some(node_ids)); | |
| 112 | - | ui.close(); | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // Add to Collection submenu (bulk) | |
| 116 | - | let collections = state.collections_ui.collections.clone(); | |
| 117 | - | if !collections.is_empty() { | |
| 118 | - | ui.menu_button("Add to Collection", |ui| { | |
| 119 | - | for coll in collections.iter() { | |
| 120 | - | if ui.button(&coll.name).clicked() { | |
| 121 | - | let nodes = state.selected_nodes(); | |
| 122 | - | // Count only samples actually added, directories (no hash) | |
| 123 | - | // are skipped, so reporting nodes.len() over-counts. | |
| 124 | - | let mut added = 0usize; | |
| 125 | - | let mut failed = 0usize; | |
| 126 | - | for n in &nodes { | |
| 127 | - | if let Some(hash) = &n.node.sample_hash { | |
| 128 | - | match state.backend.add_to_collection(coll.id, hash) { | |
| 129 | - | Ok(()) => added += 1, | |
| 130 | - | Err(_) => failed += 1, | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | state.refresh_collections(); | |
| 135 | - | state.status = if failed == 0 { | |
| 136 | - | format!("Added {added} items to {}", coll.name) | |
| 137 | - | } else { | |
| 138 | - | format!("Added {added} items to {} ({failed} failed)", coll.name) | |
| 139 | - | }; | |
| 140 | - | ui.close(); | |
| 141 | - | } | |
| 142 | - | } | |
| 143 | - | }); | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // Remove from Collection (when viewing a collection) | |
| 147 | - | if let Some(active_id) = state.collections_ui.active_collection | |
| 148 | - | && widgets::danger_button(ui, "Remove from Collection").clicked() | |
| 149 | - | { | |
| 150 | - | let nodes = state.selected_nodes(); | |
| 151 | - | for n in &nodes { | |
| 152 | - | if let Some(hash) = &n.node.sample_hash { | |
| 153 | - | let _ = state.backend.remove_from_collection(active_id, hash); | |
| 154 | - | } | |
| 155 | - | } | |
| 156 | - | state.refresh_collections(); | |
| 157 | - | state.activate_collection(active_id); | |
| 158 | - | ui.close(); | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | ui.separator(); | |
| 162 | - | ||
| 163 | - | if ui | |
| 164 | - | .button("Re-analyze...") | |
| 165 | - | .on_hover_text("Run analysis again on selected samples") | |
| 166 | - | .clicked() | |
| 167 | - | { | |
| 168 | - | let selected = state.selected_nodes(); | |
| 169 | - | let hashes: Vec<(String, String)> = selected | |
| 170 | - | .iter() | |
| 171 | - | .filter_map(|n| { | |
| 172 | - | let hash = n.node.sample_hash.as_ref()?; | |
| 173 | - | let ext = state.backend.sample_extension(hash).ok()?; | |
| 174 | - | Some((hash.to_string(), ext)) | |
| 175 | - | }) | |
| 176 | - | .collect(); | |
| 177 | - | // Count how many of the selected samples already have computed values | |
| 178 | - | //, re-analyzing those will overwrite the previous result, which a user | |
| 179 | - | // who hand-tuned the analysis would lose silently otherwise. | |
| 180 | - | let overwrite_count = selected | |
| 181 | - | .iter() | |
| 182 | - | .filter(|n| n.bpm.is_some() || n.musical_key.is_some()) | |
| 183 | - | .count(); | |
| 184 | - | if overwrite_count > 0 { | |
| 185 | - | state.overlay.pending_confirm = Some(crate::state::ConfirmAction::ReanalyzeOverwrite { | |
| 186 | - | sample_hashes: hashes, | |
| 187 | - | overwrite_count, | |
| 188 | - | }); | |
| 189 | - | } else { | |
| 190 | - | state.start_analysis_flow(hashes); | |
| 191 | - | } | |
| 192 | - | ui.close(); | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | // Copy tags from focused sample to all selected | |
| 196 | - | if let Some(focused) = state.selected_node() | |
| 197 | - | && let Some(ref src_hash) = focused.node.sample_hash | |
| 198 | - | { | |
| 199 | - | let src_hash = src_hash.clone(); | |
| 200 | - | let src_name = focused.node.name.clone(); | |
| 201 | - | if ui | |
| 202 | - | .button(format!( | |
| 203 | - | "Copy Tags from \"{}\"", | |
| 204 | - | truncate_name(&src_name, 20) | |
| 205 | - | )) | |
| 206 | - | .on_hover_text("Apply this sample's tags to all other selected samples") | |
| 207 | - | .clicked() | |
| 208 | - | { | |
| 209 | - | let src_hash_str = src_hash.to_string(); | |
| 210 | - | if let Ok(src_tags) = state.backend.get_sample_tags(&src_hash) { | |
| 211 | - | let target_hashes = state.selected_sample_hashes(); | |
| 212 | - | let mut applied = 0; | |
| 213 | - | let mut failed = 0usize; | |
| 214 | - | for hash in &target_hashes { | |
| 215 | - | if *hash == src_hash_str { | |
| 216 | - | continue; | |
| 217 | - | } | |
| 218 | - | let mut ok = true; | |
| 219 | - | for tag in &src_tags { | |
| 220 | - | if state.backend.add_tag(hash, tag).is_err() { | |
| 221 | - | ok = false; | |
| 222 | - | } | |
| 223 | - | } | |
| 224 | - | if ok { | |
| 225 | - | applied += 1; | |
| 226 | - | } else { | |
| 227 | - | failed += 1; | |
| 228 | - | } | |
| 229 | - | } | |
| 230 | - | state.status = if failed == 0 { | |
| 231 | - | format!("Copied {} tags to {applied} samples", src_tags.len()) | |
| 232 | - | } else { | |
| 233 | - | format!( | |
| 234 | - | "Copied {} tags to {applied} samples ({failed} failed)", | |
| 235 | - | src_tags.len() | |
| 236 | - | ) | |
| 237 | - | }; | |
| 238 | - | state.refresh_selected_tags(); | |
| 239 | - | } | |
| 240 | - | ui.close(); | |
| 241 | - | } | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | ui.separator(); | |
| 245 | - | ||
| 246 | - | if ui.button("Copy Path").clicked() { | |
| 247 | - | let nodes = state.selected_nodes(); | |
| 248 | - | let paths: Vec<String> = nodes | |
| 249 | - | .iter() | |
| 250 | - | .filter_map(|n| { | |
| 251 | - | n.node.sample_hash.as_ref().and_then(|hash| { | |
| 252 | - | let ext = state.backend.sample_extension(hash).ok()?; | |
| 253 | - | Some( | |
| 254 | - | state | |
| 255 | - | .backend | |
| 256 | - | .sample_path(hash, &ext) | |
| 257 | - | .ok()? | |
| 258 | - | .to_string_lossy() | |
| 259 | - | .into_owned(), | |
| 260 | - | ) | |
| 261 | - | }) | |
| 262 | - | }) | |
| 263 | - | .collect(); | |
| 264 | - | if !paths.is_empty() { | |
| 265 | - | // Include the first path in the status so the user can recognise the | |
| 266 | - | // clipboard contents at a glance, the bare count "Copied N paths" | |
| 267 | - | // gave no way to verify which selection won the race when the user | |
| 268 | - | // copied, then changed selection, then pasted into a DAW. | |
| 269 | - | let first = &paths[0]; | |
| 270 | - | let count = paths.len(); | |
| 271 | - | state.status = if count == 1 { | |
| 272 | - | format!("Copied: {first}") | |
| 273 | - | } else { | |
| 274 | - | format!("Copied: {first} (+{} more)", count - 1) | |
| 275 | - | }; | |
| 276 | - | ui.ctx().copy_text(paths.join("\n")); | |
| 277 | - | } | |
| 278 | - | ui.close(); | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | if widgets::danger_button(ui, "Delete").clicked() { | |
| 282 | - | state.confirm_delete_selected(); | |
| 283 | - | ui.close(); | |
| 284 | - | } | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | /// Context menu for right-clicking empty space in the file list. | |
| 288 | - | pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 289 | - | if ui.button("New Folder").clicked() { | |
| 290 | - | state.vfs_modal.show_dir_create = true; | |
| 291 | - | state.vfs_modal.dir_create_input.clear(); | |
| 292 | - | ui.close(); | |
| 293 | - | } | |
| 294 | - | if ui.button("Import files...").clicked() { | |
| 295 | - | state.dialogs.pick_files( | |
| 296 | - | "Import files", | |
| 297 | - | &[("Audio", audiofiles_core::util::AUDIO_EXTENSIONS)], | |
| 298 | - | |s, paths| { | |
| 299 | - | // Batch through the background worker so a large multi-select | |
| 300 | - | // doesn't hash on the GUI thread (fuzz B3). | |
| 301 | - | if let Some(vfs_id) = s.current_vfs_id() { | |
| 302 | - | let strategy = crate::import::ImportStrategy::MergeIntoVfs { | |
| 303 | - | vfs_id, | |
| 304 | - | parent_id: s.nav.current_dir, | |
| 305 | - | }; | |
| 306 | - | s.start_files_import(&paths, strategy); | |
| 307 | - | } | |
| 308 | - | }, | |
| 309 | - | ); | |
| 310 | - | ui.close(); | |
| 311 | - | } | |
| 312 | - | // C-2: matches the toolbar's "Import folder..." (wizard path). The quick | |
| 313 | - | // import shortcut is only offered from the toolbar to keep this menu | |
| 314 | - | // short; users who want quick-import find it there. | |
| 315 | - | if ui.button("Import folder...").clicked() { | |
| 316 | - | state.dialogs.pick_folder( | |
| 317 | - | "Import folder", | |
| 318 | - | super::super::state::BrowserState::show_import_options, | |
| 319 | - | ); | |
| 320 | - | ui.close(); | |
| 321 | - | } | |
| 322 | - | if state.nav.selection.count() > 0 { | |
| 323 | - | ui.separator(); | |
| 324 | - | let label = format!("Deselect ({}) (Esc)", state.nav.selection.count()); | |
| 325 | - | if ui.button(label).clicked() { | |
| 326 | - | state.nav.selection.clear(); | |
| 327 | - | state.refresh_selected_tags(); | |
| 328 | - | state.refresh_selected_detail(); | |
| 329 | - | ui.close(); | |
| 330 | - | } | |
| 331 | - | if ui.button("Invert Selection (Cmd+Shift+I)").clicked() { | |
| 332 | - | state.invert_selection(); | |
| 333 | - | ui.close(); | |
| 334 | - | } | |
| 335 | - | } | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | /// Truncate a name for display in menus (avoids excessively wide menu items). | |
| 339 | - | fn truncate_name(name: &str, max_len: usize) -> String { | |
| 340 | - | if name.chars().count() <= max_len { | |
| 341 | - | name.to_string() | |
| 342 | - | } else { | |
| 343 | - | let truncated: String = name.chars().take(max_len.saturating_sub(3)).collect(); | |
| 344 | - | format!("{truncated}...") | |
| 345 | - | } | |
| 346 | - | } | |
| 347 | - | ||
| 348 | 72 | /// Hand what is chosen to the operating system as a drag. | |
| 349 | 73 | /// | |
| 350 | 74 | /// Called by `quasi::panel::dragging_out`, which is where the gesture is read. | |
| @@ -390,28 +114,3 @@ | |||
| 390 | 114 | /// the library when `drag_out` grows a backend for it, not before. | |
| 391 | 115 | #[cfg(not(any(target_os = "macos", target_os = "windows")))] | |
| 392 | 116 | pub fn start_os_drag(_state: &mut BrowserState) {} | |
| 393 | - | ||
| 394 | - | #[cfg(test)] | |
| 395 | - | mod tests { | |
| 396 | - | use super::*; | |
| 397 | - | ||
| 398 | - | #[test] | |
| 399 | - | fn truncate_name_keeps_short_names() { | |
| 400 | - | assert_eq!(truncate_name("short", 10), "short"); | |
| 401 | - | assert_eq!(truncate_name("abcdefghij", 10), "abcdefghij"); // exactly max | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | #[test] | |
| 405 | - | fn truncate_name_trims_long_names_with_ellipsis() { | |
| 406 | - | assert_eq!(truncate_name("abcdefghijk", 10), "abcdefg..."); | |
| 407 | - | } | |
| 408 | - | ||
| 409 | - | #[test] | |
| 410 | - | fn truncate_name_counts_unicode_scalars() { | |
| 411 | - | // 5 multibyte chars, max 4 -> take(1) + "..." | |
| 412 | - | assert_eq!( | |
| 413 | - | truncate_name("\u{3b1}\u{3b1}\u{3b1}\u{3b1}\u{3b1}", 4), | |
| 414 | - | "\u{3b1}..." | |
| 415 | - | ); | |
| 416 | - | } | |
| 417 | - | } |