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