Skip to main content

max / audiofiles

Describe the sidebar: vaults, collections, and what you can filter by The ninth port. It completes the main window -- Sidebar, Pane and Band are now all present, which is every region kind this app has a use for -- and it is the first place a described control replaces the confirmation machinery rather than arguing that it could. ConfirmAction::DeleteVfs, ::DeleteCollection and ::RemoveTagGlobally are written the other way round here: Act::confirm on the control, with the tone beside it. The shipped path is a context menu writing pending_confirm, a 140-line match turning the variant back into a prompt and a button label, a modal drawing it, and execute_confirmed_action dispatching on the variant again to find what to do. The described path is one method, and the runtime answers Step::Ask. What is left of ConfirmAction is an argument carrier: the host sets the variant it already has the user's answer for and calls the executor, so bulk delete keeps one implementation and the question stops making a round trip through an enum. THE FINDING: a hierarchy of rows has no description. audiofiles' tags are dotted and the shipped sidebar builds a real tree from them -- TagNode with children, a recursive draw, a disclosure chevron deliberately made a separate hit target from the label, per-node expansion, and a distinction between a parent that is itself a tag and one that only groups. None of it is sayable: RowPart is Primary, Secondary, Meta, Actions, Tokens, Proportion, and no member holds rows inside a row. Node::Region nests, but a region is a rect with its own scroll, and a tag tree built from nested regions would be describing a drawing. So the port flattens it to full dotted paths, which is honest about what the filter operates on -- required_tags holds exact paths and the tree is a navigation convenience over a flat set -- and loses the grouping, the collapse, and the parent/leaf distinction. On a vault with two hundred tags the described sidebar is a wall where the shipped one is an outline. Fourth consumer for the disabled-control precondition (9bab759c): the vault Delete is disabled on vfs_count > 1 with the sentence that would revive it in on_disabled_hover_text. Two smaller corrections the vocabulary suggests. A collection's kind moves out of its name -- the shipped row appends " (auto)" or " (12)" -- and becomes a token, which is where a second fact about a row goes. And a tag filter is a latching chip rather than a coloured label, because a filter is on or off and that is exactly what Token::Chip carries. Not described: renaming a tag or collection, which is a flow with a consequences screen in it; the library picker; the onboarding banner. 13 tests, 474 passing with the feature on. The default build is untouched.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 20:41 UTC
Signed with PGP, not checked
Commit: 5d8fc1cb8d3ba161c36af065857a9435ea5e5a65
Parent: abbcd65
6 files changed, +1024 insertions, -10 deletions
M Cargo.lock +2
@@ -3084,6 +3084,8 @@
3084 3084 [[package]]
3085 3085 name = "makeover-layout"
3086 3086 version = "0.27.3"
3087 + source = "registry+https://github.com/rust-lang/crates.io-index"
3088 + checksum = "688717314a2bc2e4a02701bd3cfe90de4997cf327c7d8c340f7359376a89af93"
3087 3089
3088 3090 [[package]]
3089 3091 name = "maplit"
@@ -23,6 +23,7 @@
23 23 //! | [`Detail`] | [`detail`] | an [`Intent`], applied after the frame |
24 24 //! | [`Bulk`] | [`bulk`] | an [`Intent`], applied after the frame |
25 25 //! | [`Shell`] | [`shell`] | an [`Intent`], applied after the frame |
26 + //! | [`Library`] | [`library`] | an [`Intent`], applied after the frame |
26 27 //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host |
27 28 //!
28 29 //! The themes are the settled rule from goingson's settings port applied first
@@ -76,6 +77,7 @@
76 77 pub mod export;
77 78 pub mod files;
78 79 pub mod help;
80 + pub mod library;
79 81 pub mod panel;
80 82 pub mod settings;
81 83 pub mod shell;
@@ -541,6 +543,22 @@
541 543 SpreadTag(String),
542 544 /// Untag every chosen sample that carries this tag.
543 545 StripTag(String),
546 + /// Make a new vault.
547 + NewVault,
548 + /// Switch to this vault.
549 + OpenVault(i64),
550 + /// Delete this vault and everything in it.
551 + DeleteVault(i64),
552 + /// Filter by this tag, or stop filtering by it.
553 + ToggleTag(String),
554 + /// Take this tag off every sample that has it.
555 + RemoveTagEverywhere(String),
556 + /// Show this collection.
557 + OpenCollection(i64),
558 + /// Stop showing whichever collection is showing.
559 + CloseCollection,
560 + /// Delete this collection.
561 + DeleteCollection(i64),
544 562 /// Stop the preview that is playing.
545 563 StopPlayback,
546 564 /// Put the first-launch hint away.
@@ -1796,6 +1814,188 @@
1796 1814 whole
1797 1815 }
1798 1816
1817 + /// One vault, as the sidebar needs to name it.
1818 + #[derive(Debug, Clone, PartialEq, Eq)]
1819 + pub struct Vault {
1820 + /// The row's own id, which its addresses are built from.
1821 + pub id: i64,
1822 + /// What it is called.
1823 + pub name: String,
1824 + /// Whether it is the one being browsed.
1825 + pub current: bool,
1826 + }
1827 +
1828 + /// What a collection holds.
1829 + ///
1830 + /// The distinction the shipped row puts in its label as " (auto)" or " (12)".
1831 + /// Two members rather than a string, because "this updates itself" and "this has
1832 + /// twelve things in it" are different claims and only one of them is a count.
1833 + #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1834 + pub enum Holding {
1835 + /// A saved search: whatever matches, whenever it matches.
1836 + Dynamic,
1837 + /// A fixed set, this big.
1838 + Fixed(usize),
1839 + }
1840 +
1841 + /// One collection, as the sidebar needs to name it.
1842 + #[derive(Debug, Clone, PartialEq, Eq)]
1843 + pub struct Collection {
1844 + /// The row's own id.
1845 + pub id: i64,
1846 + /// What it is called.
1847 + pub name: String,
1848 + /// What it holds.
1849 + pub holding: Holding,
1850 + /// Whether it is the one being shown.
1851 + pub active: bool,
1852 + }
1853 +
1854 + /// One tag, and whether the list is filtered by it.
1855 + #[derive(Debug, Clone, PartialEq, Eq)]
1856 + pub struct Filter {
1857 + /// The whole dotted path. See [`library`]'s header on why the hierarchy this
1858 + /// path encodes is not described.
1859 + pub path: String,
1860 + /// Whether it is in force.
1861 + pub on: bool,
1862 + }
1863 +
1864 + /// The vaults, collections and tags, as much as the sidebar needs.
1865 + ///
1866 + /// The eighth narrow trait. Every write is an intent for the usual reason —
1867 + /// selecting a vault, applying a filter and activating a collection are all
1868 + /// `&mut BrowserState` — and the two deletes are as well, because each pushes a
1869 + /// status line and re-reads the list it emptied.
1870 + pub trait Library {
1871 + /// Every vault in this library.
1872 + fn vaults(&self) -> Vec<Vault>;
1873 +
1874 + /// Every collection, manual and dynamic.
1875 + fn collections(&self) -> Vec<Collection>;
1876 +
1877 + /// Every tag the vault knows, and whether it is filtering.
1878 + fn tags(&self) -> Vec<Filter>;
1879 +
1880 + /// Make a new vault.
1881 + fn new_vault(&self);
1882 +
1883 + /// Browse this vault.
1884 + fn open_vault(&self, id: i64);
1885 +
1886 + /// Delete this vault and everything in it.
1887 + fn delete_vault(&self, id: i64);
1888 +
1889 + /// Filter by this tag, or stop.
1890 + fn toggle_tag(&self, path: &str);
1891 +
1892 + /// Take this tag off every sample that has it.
1893 + fn remove_tag(&self, path: &str);
1894 +
1895 + /// Show this collection.
1896 + fn open_collection(&self, id: i64);
1897 +
1898 + /// Stop showing whichever is showing.
1899 + fn close_collection(&self);
1900 +
1901 + /// Delete this collection.
1902 + fn delete_collection(&self, id: i64);
1903 + }
1904 +
1905 + /// The app's library, as the narrow thing the sidebar borrows.
1906 + pub struct FromLibrary<'a> {
1907 + /// What the app has loaded.
1908 + pub state: &'a crate::state::BrowserState,
1909 + /// What the described screen asked for, applied after the frame.
1910 + pub intents: &'a std::cell::RefCell<Vec<Intent>>,
1911 + }
1912 +
1913 + impl Library for FromLibrary<'_> {
1914 + fn vaults(&self) -> Vec<Vault> {
1915 + self.state
1916 + .nav
1917 + .vfs_list
1918 + .iter()
1919 + .enumerate()
1920 + .map(|(at, vfs)| Vault {
1921 + id: vfs.id.as_i64(),
1922 + name: vfs.name.clone(),
1923 + current: at == self.state.nav.current_vfs_idx,
1924 + })
1925 + .collect()
1926 + }
1927 +
1928 + fn collections(&self) -> Vec<Collection> {
1929 + let active = self.state.collections_ui.active_collection;
1930 + self.state
1931 + .collections_ui
1932 + .collections
1933 + .iter()
1934 + .map(|collection| Collection {
1935 + id: collection.id.as_i64(),
1936 + name: collection.name.clone(),
1937 + holding: if collection.is_dynamic() {
1938 + Holding::Dynamic
1939 + } else {
1940 + Holding::Fixed(collection.member_count)
1941 + },
1942 + active: active == Some(collection.id),
1943 + })
1944 + .collect()
1945 + }
1946 +
1947 + fn tags(&self) -> Vec<Filter> {
1948 + let on = &self.state.search.search_filter.required_tags;
1949 + self.state
1950 + .all_tags
1951 + .iter()
1952 + .map(|path| Filter {
1953 + on: on.contains(path),
1954 + path: path.clone(),
1955 + })
1956 + .collect()
1957 + }
1958 +
1959 + fn new_vault(&self) {
1960 + self.push(Intent::NewVault);
1961 + }
1962 +
1963 + fn open_vault(&self, id: i64) {
1964 + self.push(Intent::OpenVault(id));
1965 + }
1966 +
1967 + fn delete_vault(&self, id: i64) {
1968 + self.push(Intent::DeleteVault(id));
1969 + }
1970 +
1971 + fn toggle_tag(&self, path: &str) {
1972 + self.push(Intent::ToggleTag(path.to_owned()));
1973 + }
1974 +
1975 + fn remove_tag(&self, path: &str) {
1976 + self.push(Intent::RemoveTagEverywhere(path.to_owned()));
1977 + }
1978 +
1979 + fn open_collection(&self, id: i64) {
1980 + self.push(Intent::OpenCollection(id));
1981 + }
1982 +
1983 + fn close_collection(&self) {
1984 + self.push(Intent::CloseCollection);
1985 + }
1986 +
1987 + fn delete_collection(&self, id: i64) {
1988 + self.push(Intent::DeleteCollection(id));
1989 + }
1990 + }
1991 +
1992 + impl FromLibrary<'_> {
1993 + /// Record what the described screen asked for.
1994 + fn push(&self, intent: Intent) {
1995 + self.intents.borrow_mut().push(intent);
1996 + }
1997 + }
1998 +
1799 1999 /// A theme the host resolved, as the description needs to name it.
1800 2000 ///
1801 2001 /// Three strings rather than the app's own `ThemeMeta`, so the described screen
@@ -1831,6 +2031,8 @@
1831 2031 pub detail: &'a dyn Detail,
1832 2032 /// The window's own band, for the main screen.
1833 2033 pub shell: &'a dyn Shell,
2034 + /// The vaults, collections and tags, for the sidebar.
2035 + pub library: &'a dyn Library,
1834 2036 /// The selection again, for the bulk screens. Two capabilities over one
1835 2037 /// selection rather than one, because they need different things of it and
1836 2038 /// the narrowing is the point: the detail screen may not move a file and
@@ -1846,8 +2048,8 @@
1846 2048 /// cost is nothing, and building it fresh is what lets the state borrow.
1847 2049 #[must_use]
1848 2050 pub fn router<'a>() -> Router<Panels<'a>> {
1849 - shell::routes(help::routes(bulk::routes(detail::routes(export::routes(
1850 - files::routes(sync::routes(settings::routes(Router::new()))),
2051 + library::routes(shell::routes(help::routes(bulk::routes(detail::routes(
2052 + export::routes(files::routes(sync::routes(settings::routes(Router::new())))),
1851 2053 )))))
1852 2054 }
1853 2055
@@ -33,8 +33,8 @@
33 33 use std::cell::RefCell;
34 34
35 35 use super::{
36 - FromBackend, FromBulk, FromContents, FromExport, FromSelection, FromSyncManager, FromWindow,
37 - Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
36 + FromBackend, FromBulk, FromContents, FromExport, FromLibrary, FromSelection, FromSyncManager,
37 + FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured,
38 38 };
39 39 use crate::state::BrowserState;
40 40 use crate::ui::theme;
@@ -375,6 +375,80 @@
375 375 // partial-failure counting, and none of that should exist twice.
376 376 // See `Bulk`'s header on why the description does not carry
377 377 // `BulkModal` even though the commit path does.
378 + // The sidebar. Two of these hand an already-agreed decision to the
379 + // app's own executor: the described control asked with
380 + // `Act::confirm`, the runtime answered `Step::Ask`, the user said
381 + // yes, and `execute_confirmed_action` is what knows how to do it.
382 + // `ConfirmAction` becomes an argument carrier rather than a
383 + // question, which is `library`'s header made concrete.
384 + Intent::NewVault => {
385 + state.vfs_modal.show_vfs_create = true;
386 + state.vfs_modal.vfs_create_input.clear();
387 + }
388 + Intent::OpenVault(id) => {
389 + if let Some(at) = state
390 + .nav
391 + .vfs_list
392 + .iter()
393 + .position(|vfs| vfs.id.as_i64() == id)
394 + && at != state.nav.current_vfs_idx
395 + {
396 + state.select_vfs(at);
397 + }
398 + }
399 + Intent::DeleteVault(id) => {
400 + if let Some(vfs) = state.nav.vfs_list.iter().find(|vfs| vfs.id.as_i64() == id) {
401 + state.overlay.pending_confirm = Some(crate::state::ConfirmAction::DeleteVfs {
402 + vfs_id: vfs.id,
403 + vfs_name: vfs.name.clone(),
404 + });
405 + state.execute_confirmed_action();
406 + }
407 + }
408 + Intent::ToggleTag(path) => {
409 + let wanted = &mut state.search.search_filter.required_tags;
410 + if let Some(at) = wanted.iter().position(|held| *held == path) {
411 + wanted.remove(at);
412 + } else {
413 + wanted.push(path);
414 + }
415 + state.apply_search();
416 + }
417 + Intent::RemoveTagEverywhere(tag) => {
418 + state.overlay.pending_confirm =
419 + Some(crate::state::ConfirmAction::RemoveTagGlobally { tag });
420 + state.execute_confirmed_action();
421 + }
422 + Intent::OpenCollection(id) => {
423 + if let Some(collection) = state
424 + .collections_ui
425 + .collections
426 + .iter()
427 + .find(|collection| collection.id.as_i64() == id)
428 + {
429 + let (id, filter) = (collection.id, collection.filter.clone());
430 + match filter {
431 + Some(filter) => state.activate_dynamic_collection(id, &filter),
432 + None => state.activate_collection(id),
433 + }
434 + }
435 + }
436 + Intent::CloseCollection => state.deactivate_collection(),
437 + Intent::DeleteCollection(id) => {
438 + if let Some(collection) = state
439 + .collections_ui
440 + .collections
441 + .iter()
442 + .find(|collection| collection.id.as_i64() == id)
443 + {
444 + state.overlay.pending_confirm =
445 + Some(crate::state::ConfirmAction::DeleteCollection {
446 + coll_id: collection.id,
447 + coll_name: collection.name.clone(),
448 + });
449 + state.execute_confirmed_action();
450 + }
451 + }
378 452 Intent::StopPlayback => state.stop_preview(),
379 453 Intent::DismissHint => state.dismiss_first_launch_hint(),
380 454 Intent::BulkTag(typed, adding) => {
@@ -745,6 +819,7 @@
745 819 let detail = FromSelection { state, intents };
746 820 let bulk = FromBulk { state, intents };
747 821 let shell = FromWindow { state, intents };
822 + let library = FromLibrary { state, intents };
748 823 let panels = Panels {
749 824 config: &config,
750 825 sync,
@@ -753,6 +828,7 @@
753 828 detail: &detail,
754 829 bulk: &bulk,
755 830 shell: &shell,
831 + library: &library,
756 832 themes,
757 833 };
758 834 super::router()
@@ -106,9 +106,15 @@
106 106 Ok(screen(state).into())
107 107 }
108 108
109 - /// The window: the list, and the band that reports on it.
110 - fn screen(state: &Panels<'_>) -> Screen {
109 + /// The window: what you can filter by, what is in it, and what it is doing.
110 + ///
111 + /// `pub(super)` because the sidebar's routes answer it. Every control in
112 + /// [`library`](super::library) changes what the *list* shows — choosing a vault,
113 + /// applying a tag filter, opening a collection — so the answer is the window
114 + /// rather than the corner of it that was pressed.
115 + pub(super) fn screen(state: &Panels<'_>) -> Screen {
111 116 Screen::sidebar_content("audiofiles")
117 + .with(super::library::body(state))
112 118 .with(super::files::body(state))
113 119 .with(foot(state))
114 120 }
@@ -12,10 +12,11 @@
12 12 use quasi_router::{Method, Node, Outcome, Params, Request, Response, Screen};
13 13
14 14 use super::{
15 - Analysed, Analysis, Bulk, Channels, Chosen, ColumnsShown, Config, Coverage, Detail, Detailed,
16 - Export, Files, Focus, Folder, Format, Panels, Phase, Playing, Pricing, ProfileChoice, Sample,
17 - Saying, Setting, Settings, Shared, Shell, Source, Spread, State, Status, Subject, Subscription,
18 - Suggested, Sync, Tagged, ThemeChoice, router,
15 + Analysed, Analysis, Bulk, Channels, Chosen, Collection, ColumnsShown, Config, Coverage, Detail,
16 + Detailed, Export, Files, Filter, Focus, Folder, Format, Holding, Library, Panels, Phase,
17 + Playing, Pricing, ProfileChoice, Sample, Saying, Setting, Settings, Shared, Shell, Source,
18 + Spread, State, Status, Subject, Subscription, Suggested, Sync, Tagged, ThemeChoice, Vault,
19 + router,
19 20 };
20 21
21 22 /// A config store in memory.
@@ -211,6 +212,7 @@
211 212 detail: &Unfocused,
212 213 bulk: &Unchosen,
213 214 shell: &Quiet,
215 + library: &Empty,
214 216 config: &store,
215 217 sync: &sync,
216 218 files: &files,
@@ -270,6 +272,7 @@
270 272 detail: &Unfocused,
271 273 bulk: &Unchosen,
272 274 shell: &Quiet,
275 + library: &Empty,
273 276 config: &store,
274 277 sync: &sync,
275 278 files,
@@ -400,6 +403,7 @@
400 403 detail: &Unfocused,
401 404 bulk: &Unchosen,
402 405 shell: &Quiet,
406 + library: &Empty,
403 407 config: &store,
404 408 sync: &sync,
405 409 files: &files,
@@ -448,6 +452,7 @@
448 452 detail: &Unfocused,
449 453 bulk: &Unchosen,
450 454 shell: &Quiet,
455 + library: &Empty,
451 456 config: &store,
452 457 sync: &sync,
453 458 files: &files,
@@ -490,6 +495,7 @@
490 495 detail: &Unfocused,
491 496 bulk: &Unchosen,
492 497 shell: &Quiet,
498 + library: &Empty,
493 499 config: &store,
494 500 sync: &sync,
495 501 files: &files,
@@ -518,6 +524,7 @@
518 524 detail: &Unfocused,
519 525 bulk: &Unchosen,
520 526 shell: &Quiet,
527 + library: &Empty,
521 528 config: &store,
522 529 sync: &sync,
523 530 files: &files,
@@ -569,6 +576,7 @@
569 576 detail: &Unfocused,
570 577 bulk: &Unchosen,
571 578 shell: &Quiet,
579 + library: &Empty,
572 580 config: &store,
573 581 sync: &sync,
574 582 files: &files,
@@ -616,6 +624,7 @@
616 624 detail: &Unfocused,
617 625 bulk: &Unchosen,
618 626 shell: &Quiet,
627 + library: &Empty,
619 628 config: &store,
620 629 sync: &sync,
621 630 files: &files,
@@ -769,6 +778,7 @@
769 778 detail: &Unfocused,
770 779 bulk: &Unchosen,
771 780 shell: &Quiet,
781 + library: &Empty,
772 782 config: &store,
773 783 sync,
774 784 files: &files,
@@ -1789,6 +1799,7 @@
1789 1799 detail,
1790 1800 bulk: &Unchosen,
1791 1801 shell: &Quiet,
1802 + library: &Empty,
1792 1803 themes: &themes,
1793 1804 };
1794 1805 router().handle(&state, request)
@@ -2317,6 +2328,7 @@
2317 2328 detail: &Unfocused,
2318 2329 bulk,
2319 2330 shell: &Quiet,
2331 + library: &Empty,
2320 2332 themes: &themes,
2321 2333 };
2322 2334 router().handle(&state, request)
@@ -2685,6 +2697,7 @@
2685 2697 detail: &Unfocused,
2686 2698 bulk: &Unchosen,
2687 2699 shell: &Quiet,
2700 + library: &Empty,
2688 2701 themes: &themes,
2689 2702 };
2690 2703 router().handle(&state, request)
@@ -2733,6 +2746,7 @@
2733 2746 detail: &Unfocused,
2734 2747 bulk: &bulk,
2735 2748 shell: &Quiet,
2749 + library: &Empty,
2736 2750 themes: &themes,
2737 2751 };
2738 2752
@@ -2942,6 +2956,7 @@
2942 2956 detail: &Unfocused,
2943 2957 bulk: &Unchosen,
2944 2958 shell,
2959 + library: &Empty,
2945 2960 themes: &themes,
2946 2961 };
2947 2962 router().handle(&state, request)
@@ -2994,6 +3009,7 @@
2994 3009 assert_eq!(
2995 3010 regions(&screen),
2996 3011 [
3012 + ("library-side".to_owned(), quasi_router::RegionKind::Sidebar),
2997 3013 ("files-body".to_owned(), quasi_router::RegionKind::Pane),
2998 3014 ("shell-foot".to_owned(), quasi_router::RegionKind::Band),
2999 3015 ]
@@ -3192,3 +3208,405 @@
3192 3208 [("4".to_owned(), "selected".to_owned())]
3193 3209 );
3194 3210 }
3211 +
3212 + // The sidebar.
3213 +
3214 + /// A library with nothing in it but the one vault it must have.
3215 + struct Empty;
3216 +
3217 + impl Library for Empty {
3218 + fn vaults(&self) -> Vec<Vault> {
3219 + vec![Vault {
3220 + id: 1,
3221 + name: "Library".to_owned(),
3222 + current: true,
3223 + }]
3224 + }
3225 +
3226 + fn collections(&self) -> Vec<Collection> {
3227 + Vec::new()
3228 + }
3229 +
3230 + fn tags(&self) -> Vec<Filter> {
3231 + Vec::new()
3232 + }
3233 +
3234 + fn new_vault(&self) {}
3235 + fn open_vault(&self, _id: i64) {}
3236 + fn delete_vault(&self, _id: i64) {}
3237 + fn toggle_tag(&self, _path: &str) {}
3238 + fn remove_tag(&self, _path: &str) {}
3239 + fn open_collection(&self, _id: i64) {}
3240 + fn close_collection(&self) {}
3241 + fn delete_collection(&self, _id: i64) {}
3242 + }
3243 +
3244 + /// A library in memory, recording what was asked of it.
3245 + #[derive(Default)]
3246 + struct FakeLibrary {
3247 + vaults: Vec<Vault>,
3248 + collections: Vec<Collection>,
3249 + tags: Vec<Filter>,
3250 + asked: RefCell<Vec<String>>,
3251 + }
3252 +
3253 + impl FakeLibrary {
3254 + fn stocked() -> Self {
3255 + Self {
3256 + vaults: vec![
3257 + Vault {
3258 + id: 1,
3259 + name: "Drums".to_owned(),
3260 + current: true,
3261 + },
3262 + Vault {
3263 + id: 2,
3264 + name: "Synths".to_owned(),
3265 + current: false,
3266 + },
3267 + ],
3268 + collections: vec![
3269 + Collection {
3270 + id: 10,
3271 + name: "Favourites".to_owned(),
3272 + holding: Holding::Fixed(12),
3273 + active: false,
3274 + },
3275 + Collection {
3276 + id: 11,
3277 + name: "Fast".to_owned(),
3278 + holding: Holding::Dynamic,
3279 + active: true,
3280 + },
3281 + ],
3282 + tags: vec![
3283 + Filter {
3284 + path: "drums".to_owned(),
3285 + on: false,
3286 + },
3287 + Filter {
3288 + path: "drums.kick".to_owned(),
3289 + on: true,
3290 + },
3291 + ],
3292 + asked: RefCell::new(Vec::new()),
3293 + }
3294 + }
3295 +
3296 + fn only_one_vault() -> Self {
3297 + Self {
3298 + vaults: vec![Vault {
3299 + id: 1,
3300 + name: "Library".to_owned(),
3301 + current: true,
3302 + }],
3303 + ..Self::default()
3304 + }
3305 + }
3306 +
3307 + fn note(&self, what: impl Into<String>) {
3308 + self.asked.borrow_mut().push(what.into());
3309 + }
3310 +
3311 + fn asked(&self) -> Vec<String> {
3312 + self.asked.borrow().clone()
3313 + }
3314 + }
3315 +
3316 + impl Library for FakeLibrary {
3317 + fn vaults(&self) -> Vec<Vault> {
3318 + self.vaults.clone()
3319 + }
3320 +
3321 + fn collections(&self) -> Vec<Collection> {
3322 + self.collections.clone()
3323 + }
3324 +
3325 + fn tags(&self) -> Vec<Filter> {
3326 + self.tags.clone()
3327 + }
3328 +
3329 + fn new_vault(&self) {
3330 + self.note("new vault");
3331 + }
3332 +
3333 + fn open_vault(&self, id: i64) {
3334 + self.note(format!("open vault {id}"));
3335 + }
3336 +
3337 + fn delete_vault(&self, id: i64) {
3338 + self.note(format!("delete vault {id}"));
3339 + }
3340 +
3341 + fn toggle_tag(&self, path: &str) {
3342 + self.note(format!("toggle {path}"));
3343 + }
3344 +
3345 + fn remove_tag(&self, path: &str) {
3346 + self.note(format!("remove {path}"));
3347 + }
3348 +
3349 + fn open_collection(&self, id: i64) {
3350 + self.note(format!("open collection {id}"));
3351 + }
3352 +
3353 + fn close_collection(&self) {
3354 + self.note("close collection");
3355 + }
3356 +
3357 + fn delete_collection(&self, id: i64) {
3358 + self.note(format!("delete collection {id}"));
3359 + }
3360 + }
3361 +
3362 + /// A router call against this library.
3363 + fn browsing(library: &FakeLibrary, request: Request) -> Result<Response, quasi_router::RouteError> {
3364 + let store = Store::default();
3365 + let sync = Offline;
3366 + let files = FakeFiles::with(vec![sample(1, "kick.wav")]);
3367 + let themes = themes();
3368 + let state = Panels {
3369 + config: &store,
3370 + sync: &sync,
3371 + files: &files,
3372 + export: &Idle,
3373 + detail: &Unfocused,
3374 + bulk: &Unchosen,
3375 + shell: &Quiet,
3376 + library,
3377 + themes: &themes,
3378 + };
3379 + router().handle(&state, request)
3380 + }
3381 +
3382 + /// The main screen, seen through this library.
3383 + fn browsed(library: &FakeLibrary) -> Screen {
3384 + screen_of(&browsing(library, Request::get("/")).unwrap()).clone()
3385 + }
3386 +
3387 + /// Every row on a screen, across every list, with its acts.
3388 + fn all_rows(screen: &Screen) -> Vec<quasi_router::Row> {
3389 + nodes(screen)
3390 + .iter()
3391 + .filter_map(|node| match node {
3392 + Node::List { rows, .. } => Some(rows.clone()),
3393 + _ => None,
3394 + })
3395 + .flatten()
3396 + .collect()
3397 + }
3398 +
3399 + /// Every latched chip on a screen, by label.
3400 + fn latched(screen: &Screen) -> Vec<String> {
3401 + nodes(screen)
3402 + .iter()
3403 + .filter_map(|node| match node {
3404 + Node::Token(tag) if tag.latched => Some(tag.label.clone()),
3405 + _ => None,
3406 + })
3407 + .collect()
3408 + }
3409 +
3410 + #[test]
3411 + fn the_main_screen_now_has_all_three_region_kinds() {
3412 + // The sidebar completes the window. Pane and Band landed with `shell`;
3413 + // this is the third and last kind this app has a use for.
3414 + let library = FakeLibrary::stocked();
3415 + assert_eq!(
3416 + regions(&browsed(&library)),
3417 + [
3418 + ("library-side".to_owned(), quasi_router::RegionKind::Sidebar),
3419 + ("files-body".to_owned(), quasi_router::RegionKind::Pane),
3420 + ("shell-foot".to_owned(), quasi_router::RegionKind::Band),
3421 + ]
3422 + );
3423 + }
3424 +
3425 + #[test]
3426 + fn a_destructive_control_carries_its_own_prompt() {
3427 + // `ConfirmAction::DeleteVfs`, `::DeleteCollection` and `::RemoveTagGlobally`
3428 + // written the other way round: the prompt lives on the act rather than in a
3429 + // 140-line match that turns an enum variant back into a sentence.
3430 + let library = FakeLibrary::stocked();
3431 + let screen = browsed(&library);
3432 +
3433 + let prompts: Vec<String> = all_rows(&screen)
3434 + .iter()
3435 + .flat_map(|row| row.menu.clone())
3436 + .filter_map(|act| act.confirm.clone())
3437 + .collect();
3438 +
3439 + assert!(
3440 + prompts
3441 + .iter()
3442 + .any(|ask| ask == "Delete vault \"Drums\" and all its contents?")
3443 + );
3444 + assert!(
3445 + prompts
3446 + .iter()
3447 + .any(|ask| ask == "Delete collection \"Favourites\"?")
3448 + );
3449 + assert!(
3450 + prompts
3451 + .iter()
3452 + .any(|ask| ask == "Remove tag \"drums\" from every sample that has it?")
3453 + );
3454 +
3455 + // And every one of them is toned, which is the second half of what the
3456 + // dialog's `danger` flag was carrying.
3457 + for act in all_rows(&screen).iter().flat_map(|row| row.menu.clone()) {
3458 + if act.confirm.is_some() {
3459 + assert_eq!(act.tone, quasi_router::layout::Tone::Danger);
3460 + }
3461 + }
3462 + }
3463 +
3464 + #[test]
3465 + fn the_last_vault_offers_delete_dead_and_says_why() {
3466 + // Offered rather than hidden, which is the shipped menu's own choice:
3467 + // "Always render Delete so the user can see the capability exists."
3468 + // FOURTH consumer of quasi:vocabulary:act-precondition -- the sentence that
3469 + // would revive it sits beside the control instead of on it.
3470 + let alone = FakeLibrary::only_one_vault();
3471 + let screen = browsed(&alone);
3472 +
3473 + let delete = all_rows(&screen)
3474 + .iter()
3475 + .flat_map(|row| row.menu.clone())
3476 + .find(|act| act.label == "Delete")
3477 + .expect("the capability is still shown");
3478 + assert_eq!(delete.state, Some(quasi_router::layout::State::Disabled));
3479 + assert!(said(&screen).contains("audiofiles needs at least one"));
3480 +
3481 + // And the route refuses it too, because an address is reachable by typing.
3482 + assert!(browsing(&alone, Request::post("/vaults/1/delete")).is_err());
3483 + assert!(alone.asked().is_empty());
3484 + }
3485 +
3486 + #[test]
3487 + fn deleting_a_vault_is_allowed_once_there_are_two() {
3488 + let library = FakeLibrary::stocked();
3489 + browsing(&library, Request::post("/vaults/2/delete")).unwrap();
3490 + assert_eq!(library.asked(), ["delete vault 2"]);
3491 + }
3492 +
3493 + #[test]
3494 + fn a_tag_filter_is_a_chip_that_latches() {
3495 + // A filter is on or off, which is exactly what Token::Chip's `latched`
3496 + // says, and what a plain badge could not.
3497 + let library = FakeLibrary::stocked();
3498 + assert_eq!(latched(&browsed(&library)), ["drums.kick"]);
3499 +
3500 + browsing(&library, Request::post("/tags/drums/filter")).unwrap();
3501 + assert_eq!(library.asked(), ["toggle drums"]);
3502 + }
3503 +
3504 + #[test]
3505 + fn a_tag_is_named_by_its_whole_path_because_the_tree_is_not_described() {
3506 + // THE FINDING. `RowPart` has no depth and no member holds rows inside a
3507 + // row, so the shipped sidebar's TagNode tree flattens to full dotted paths.
3508 + // Honest about what the filter operates on -- `required_tags` holds exact
3509 + // paths -- and it loses the grouping, the collapse, and the
3510 + // parent-that-is-only-a-parent distinction.
3511 + let library = FakeLibrary::stocked();
3512 + let screen = browsed(&library);
3513 +
3514 + let chips: Vec<String> = nodes(&screen)
3515 + .iter()
3516 + .filter_map(|node| match node {
3517 + Node::Token(tag) => Some(tag.label.clone()),
3518 + _ => None,
3519 + })
3520 + .collect();
3521 + assert_eq!(chips, ["drums", "drums.kick"]);
3522 + }
3523 +
3524 + #[test]
3525 + fn an_active_collection_offers_to_close_rather_than_to_open() {
3526 + let library = FakeLibrary::stocked();
3527 + browsing(&library, Request::post("/collections/10/open")).unwrap();
3528 + browsing(&library, Request::post("/collections/close")).unwrap();
3529 + assert_eq!(library.asked(), ["open collection 10", "close collection"]);
3530 +
3531 + // Which one a row calls is a fact about whether it is showing.
3532 + // The collection rows, not the vault rows: `current` marks the vault being
3533 + // browsed as well as the collection being shown.
3534 + let rows = all_rows(&browsed(&library));
3535 + let active = rows
3536 + .iter()
3537 + .filter(|row| {
3538 + row.activate
3539 + .as_ref()
3540 + .and_then(|action| action.destination.route())
3541 + .is_some_and(|path| path.contains("collection"))
3542 + })
3543 + .find(|row| row.current)
3544 + .expect("one collection is showing");
3545 + assert_eq!(
3546 + active
3547 + .activate
3548 + .as_ref()
3549 + .and_then(|action| action.destination.route()),
3550 + Some("/collections/close")
3551 + );
3552 + }
3553 +
3554 + #[test]
3555 + fn a_collection_says_what_it_holds_beside_its_name_rather_than_inside_it() {
3556 + // The shipped row appends " (auto)" or " (12)" to the label. A token is
3557 + // where a second fact about a row goes.
3558 + let library = FakeLibrary::stocked();
3559 + let rows = all_rows(&browsed(&library));
3560 +
3561 + let marks: Vec<String> = rows
3562 + .iter()
3563 + .flat_map(|row| {
3564 + row.parts
3565 + .iter()
3566 + .filter(|part| part.role == quasi_router::layout::RowPart::Tokens)
3567 + .filter_map(|part| match &part.node {
3568 + Node::Token(tag) => Some(tag.label.clone()),
3569 + _ => None,
3570 + })
3571 + .collect::<Vec<_>>()
3572 + })
3573 + .collect();
3574 + assert_eq!(marks, ["12", "auto"]);
3575 +
3576 + // And the name is just the name.
3577 + assert!(
3578 + rows.iter()
3579 + .any(|row| primary_of(row).as_deref() == Some("Favourites"))
3580 + );
3581 + }
3582 +
3583 + #[test]
3584 + fn an_empty_library_says_so_in_each_section() {
3585 + let bare = FakeLibrary::only_one_vault();
3586 + let says = said(&browsed(&bare));
3587 + assert!(says.contains("No collections yet."));
Lines truncated
@@ -1,0 +1,335 @@
1 + //! The sidebar, described: vaults, collections, and the tags you can filter by.
2 + //!
3 + //! The ninth audiofiles port. It completes the main window's regions — the shell
4 + //! now has a `Sidebar`, a `Pane` and a `Band`, which is every region kind this
5 + //! app has a use for — and it is the first port where a described control
6 + //! *replaces* the app's confirmation machinery rather than merely arguing that
7 + //! it could.
8 + //!
9 + //! # `Act::confirm` doing the job `ConfirmAction` was doing
10 + //!
11 + //! `quasi/mod.rs`'s header counts `draw_confirm_dialog` as ten variants
12 + //! replaceable by two builder methods. Two of those variants are here and are
13 + //! now written the other way:
14 + //!
15 + //! - `ConfirmAction::DeleteVfs` is `Act::new("Delete", ..).tone(Danger).confirm("Delete
16 + //! vault \"x\" and all its contents?")` on the vault's row menu.
17 + //! - `ConfirmAction::RemoveTagGlobally` is the same shape on a tag's.
18 + //!
19 + //! The shipped path for either is: a context menu writes `pending_confirm`, a
20 + //! 140-line `match` in `overlays.rs` turns the variant back into a prompt and a
21 + //! button label, a modal draws it, and `execute_confirmed_action` dispatches on
22 + //! the variant again to find what to do. The described path is one method on the
23 + //! control, and the runtime answers `Step::Ask`. **The prompt lives where the
24 + //! action does**, which is the whole of `524a63fe`'s argument, and the round trip
25 + //! through an enum is what a description makes unnecessary rather than shorter.
26 + //!
27 + //! # THE FINDING: a hierarchy of rows has no description
28 + //!
29 + //! audiofiles' tags are dotted — `drums.kick`, `genre.house` — and the shipped
30 + //! sidebar builds a real tree out of them: `TagNode` with `children`, a
31 + //! recursive `draw_tag_node`, a disclosure chevron that is deliberately a
32 + //! separate hit target from the label, per-node expansion persisted by egui, and
33 + //! a distinction between a parent that is itself a tag and one that only groups
34 + //! (filtering by the latter "would match zero samples", so its label is not
35 + //! interactive at all).
36 + //!
37 + //! None of that is sayable. `RowPart` is `Primary`, `Secondary`, `Meta`,
38 + //! `Actions`, `Tokens`, `Proportion` — there is no depth on a row and no member
39 + //! that holds rows inside a row. `Node::Region` nests, but a region is a rect
40 + //! with its own scroll, not a row with children, and building a tag tree out of
41 + //! nested regions would be describing a drawing rather than a hierarchy.
42 + //!
43 + //! So this port **flattens it**: every tag is one row at its full dotted path,
44 + //! togglable as a filter. That is honest about what the filter actually operates
45 + //! on — `required_tags` holds exact paths, and the tree is a navigation
46 + //! convenience over a flat set — and it loses three real things: the grouping, the
47 + //! ability to collapse a branch you are not using, and the parent/leaf
48 + //! distinction. On a vault with two hundred tags the described sidebar is a wall
49 + //! where the shipped one is an outline.
50 + //!
51 + //! Filed rather than faked. Note this is not the same gap as
52 + //! `Node::Heading { level }`, which says how far down the *document* a title
53 + //! sits: that is depth in prose, and this is containment in a set.
54 + //!
55 + //! # A fourth consumer for the disabled-control precondition
56 + //!
57 + //! The vault Delete is `danger_button_enabled(ui, "Delete", vfs_count > 1)` with
58 + //! `on_disabled_hover_text("Create another vault first, audiofiles needs at
59 + //! least one.")`. Same missing fact as `9bab759c`'s other three. The described
60 + //! act is disabled and the sentence is said in the section's prose, which is
61 + //! wrong in the way that finding predicts.
62 + //!
63 + //! # What is deliberately not described
64 + //!
65 + //! - **Renaming a tag or a collection.** The shipped rename opens an inline
66 + //! editor *and* computes what it is about to affect — how many samples carry
67 + //! the tag, and which descendant tags will not be carried along, because
68 + //! `rename_tag_globally` is exact-match-only. That is a flow with a
69 + //! consequences screen in it, and it deserves a pass rather than a row in this
70 + //! one.
71 + //! - **The library picker.** Switching library is `VaultAction::SwitchVault`
72 + //! guarded by `has_in_flight_work`, which tears down and rebuilds the whole
73 + //! app around a different database. Out of scope for a sidebar region.
74 + //! - **The onboarding banner.** `show_vfs_banner` explains what a vault is once.
75 + //! A described first run is its own subject.
76 +
77 + use quasi_router::layout::{Token, Tone};
78 + use quasi_router::{
79 + Act, Action, Node, RegionKind, Request, Response, RouteError, Router, Row, Slot, Tag,
80 + };
81 +
82 + use super::{Holding, Panels};
83 +
84 + /// The region the sidebar answers into.
85 + const SIDE: &str = "library-side";
86 +
87 + /// Register the sidebar's routes.
88 + ///
89 + /// Every one answers the whole main screen, because the sidebar is a region of
90 + /// it and not a place: choosing a vault changes what the list shows, so the
91 + /// answer is the window rather than the corner of it that was pressed.
92 + pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> {
93 + router
94 + .post("/vaults/new", new_vault)
95 + .post("/vaults/{id}/open", open_vault)
96 + .post("/vaults/{id}/delete", delete_vault)
97 + .post("/tags/{path}/filter", toggle_tag)
98 + .post("/tags/{path}/remove", remove_tag)
99 + .post("/collections/{id}/open", open_collection)
100 + .post("/collections/close", close_collection)
101 + .post("/collections/{id}/delete", delete_collection)
102 + }
103 +
104 + /// `POST /vaults/new`
105 + fn new_vault(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
106 + state.library.new_vault();
107 + Ok(super::shell::screen(state).into())
108 + }
109 +
110 + /// `POST /vaults/{id}/open`
111 + fn open_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
112 + let id = numbered(&request, "no such vault")?;
113 + // Re-opening the vault you are in is a no-op rather than a refusal, which is
114 + // the shipped list's own rule: it would otherwise clear the current
115 + // directory, the breadcrumb and the selection, and "the click matches user
116 + // expectation" is what that comment says about it.
117 + if !state.library.vaults().iter().any(|vault| vault.id == id) {
118 + return Err(RouteError::not_found("no such vault"));
119 + }
120 + state.library.open_vault(id);
121 + Ok(super::shell::screen(state).into())
122 + }
123 +
124 + /// `POST /vaults/{id}/delete`
125 + ///
126 + /// Refused where it would leave none, which is the condition the shipped Delete
127 + /// is disabled on. A disabled control is an affordance and an address is
128 + /// reachable by typing, so the route says it too.
129 + fn delete_vault(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
130 + let id = numbered(&request, "no such vault")?;
131 + if state.library.vaults().len() < 2 {
132 + return Err(RouteError::not_found(LAST_VAULT));
133 + }
134 + state.library.delete_vault(id);
135 + Ok(super::shell::screen(state).into())
136 + }
137 +
138 + /// `POST /tags/{path}/filter`
139 + fn toggle_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
140 + let path = request.captures.require("path")?.to_owned();
141 + state.library.toggle_tag(&path);
142 + Ok(super::shell::screen(state).into())
143 + }
144 +
145 + /// `POST /tags/{path}/remove`
146 + fn remove_tag(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
147 + let path = request.captures.require("path")?.to_owned();
148 + state.library.remove_tag(&path);
149 + Ok(super::shell::screen(state).into())
150 + }
151 +
152 + /// `POST /collections/{id}/open`
153 + fn open_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
154 + let id = numbered(&request, "no such collection")?;
155 + state.library.open_collection(id);
156 + Ok(super::shell::screen(state).into())
157 + }
158 +
159 + /// `POST /collections/close`
160 + fn close_collection(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> {
161 + state.library.close_collection();
162 + Ok(super::shell::screen(state).into())
163 + }
164 +
165 + /// `POST /collections/{id}/delete`
166 + fn delete_collection(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> {
167 + let id = numbered(&request, "no such collection")?;
168 + state.library.delete_collection(id);
169 + Ok(super::shell::screen(state).into())
170 + }
171 +
172 + /// The id a request names.
173 + fn numbered(request: &Request, whats_wrong: &'static str) -> Result<i64, RouteError> {
174 + request
175 + .captures
176 + .require("id")?
177 + .parse()
178 + .map_err(|_| RouteError::not_found(whats_wrong))
179 + }
180 +
181 + /// What the shipped Delete says when there is only one vault left.
182 + const LAST_VAULT: &str = "Create another vault first, audiofiles needs at least one.";
183 +
184 + /// The sidebar, as a region something else holds.
185 + pub fn body(state: &Panels<'_>) -> Slot {
186 + let side = Slot::new(SIDE, RegionKind::Sidebar);
187 + let side = vaults(side, state);
188 + let side = collections(side, state);
189 + tags(side, state)
190 + }
191 +
192 + /// The vaults, and what can be done to one.
193 + fn vaults(side: Slot, state: &Panels<'_>) -> Slot {
194 + let all = state.library.vaults();
195 + let alone = all.len() < 2;
196 +
197 + let mut side = side.with(Node::section("Vaults")).with(Node::Act(Act::new(
198 + "New vault",
199 + Action::post("/vaults/new"),
200 + )));
201 +
202 + let mut rows = Vec::with_capacity(all.len());
203 + for vault in &all {
204 + let mut delete = Act::new(
205 + "Delete",
206 + Action::post(format!("/vaults/{}/delete", vault.id)),
207 + )
208 + .tone(Tone::Danger)
209 + // `ConfirmAction::DeleteVfs`, said where the action is. See the
210 + // module header.
211 + .confirm(format!(
212 + "Delete vault \"{}\" and all its contents?",
213 + vault.name
214 + ));
215 + if alone {
216 + // Offered dead rather than hidden, which is the shipped menu's
217 + // choice: "Always render Delete so the user can see the capability
218 + // exists."
219 + delete = delete.disabled();
220 + }
221 +
222 + // `offers` rather than `act`: the shipped affordance is a right-click
223 + // menu, and `Row::menu` is what "held back until the host asks" means.
224 + // An inline Delete on every vault row would be a different screen.
225 + let mut row = Row::new(&vault.name)
226 + .activate(Action::post(format!("/vaults/{}/open", vault.id)))
227 + .offers(delete);
228 + row.current = vault.current;
229 + rows.push(row);
230 + }
231 + side = side.with(Node::List { rows, more: None });
232 +
233 + if alone {
234 + // The precondition, said beside the control rather than on it. THE
235 + // FINDING, fourth consumer -- see the module header.
236 + side = side.with(Node::Text {
237 + text: LAST_VAULT.to_owned(),
238 + tone: Tone::Info,
239 + });
240 + }
241 + side
242 + }
243 +
244 + /// The collections, manual and dynamic.
245 + fn collections(side: Slot, state: &Panels<'_>) -> Slot {
246 + let all = state.library.collections();
247 + let mut side = side.with(Node::section("Collections"));
248 +
249 + if all.is_empty() {
250 + return side.with(Node::empty("No collections yet."));
251 + }
252 +
253 + let mut rows = Vec::with_capacity(all.len());
254 + for collection in &all {
255 + // What kind it is, as a token rather than a suffix on the name. The
256 + // shipped row appends " (auto)" or " (12)" to the label, with a comment
257 + // saying it is a text suffix "instead of a glyph (per the no-emoji brand
258 + // rule, and for accessibility)" -- which is right about the glyph and
259 + // still puts a second fact inside the name.
260 + let mark = match collection.holding {
261 + Holding::Dynamic => Tag::badge("auto"),
262 + Holding::Fixed(count) => Tag::badge(count.to_string()),
263 + };
264 + let mut row = Row::new(&collection.name)
265 + .token(mark)
266 + .activate(if collection.active {
267 + Action::post("/collections/close")
268 + } else {
269 + Action::post(format!("/collections/{}/open", collection.id))
270 + })
271 + .offers(
272 + Act::new(
273 + "Delete",
274 + Action::post(format!("/collections/{}/delete", collection.id)),
275 + )
276 + .tone(Tone::Danger)
277 + .confirm(format!("Delete collection \"{}\"?", collection.name)),
278 + );
279 + row.current = collection.active;
280 + rows.push(row);
281 + }
282 +
283 + side = side.with(Node::List { rows, more: None });
284 + side
285 + }
286 +
287 + /// The tags, flat.
288 + ///
289 + /// See the module header: the hierarchy the shipped sidebar draws has no
290 + /// description, so what is here is every tag at its full path. The chips latch,
291 + /// because a tag filter is on or off and that is exactly what
292 + /// [`Token::Chip`]'s `latched` says.
293 + fn tags(side: Slot, state: &Panels<'_>) -> Slot {
294 + let all = state.library.tags();
295 + let mut side = side.with(Node::section("Tags"));
296 +
297 + if all.is_empty() {
298 + return side.with(Node::empty("No tags yet."));
299 + }
300 +
301 + for filter in &all {
302 + side = side.with(Node::Token(Tag {
303 + kind: Token::Chip { removable: false },
304 + label: filter.path.clone(),
305 + tone: if filter.on { Tone::Info } else { Tone::Neutral },
306 + latched: filter.on,
307 + action: Some(Action::post(format!("/tags/{}/filter", filter.path))),
308 + }));
309 + }
310 +
311 + // Removing a tag from every sample is not a filter, so it is not a chip. It
312 + // is a list of the same tags with a destructive act on each, which is the
313 + // shipped right-click menu made visible -- and the second
314 + // `ConfirmAction` variant this port replaces.
315 + side.with(Node::section("Remove a tag everywhere"))
316 + .with(Node::List {
317 + rows: all
318 + .iter()
319 + .map(|filter| {
320 + Row::new(&filter.path).offers(
321 + Act::new(
322 + "Remove",
323 + Action::post(format!("/tags/{}/remove", filter.path)),
324 + )
325 + .tone(Tone::Danger)
326 + .confirm(format!(
327 + "Remove tag \"{}\" from every sample that has it?",
328 + filter.path
329 + )),
330 + )
331 + })
332 + .collect(),
333 + more: None,
334 + })
335 + }