max / audiofiles
7 files changed,
+1127 insertions,
-25 deletions
| @@ -7543,6 +7543,18 @@ | |||
| 7543 | 7543 | "winnow 1.0.4", | |
| 7544 | 7544 | ] | |
| 7545 | 7545 | ||
| 7546 | + | [[patch.unused]] | |
| 7547 | + | name = "kberg" | |
| 7548 | + | version = "0.1.0" | |
| 7549 | + | ||
| 7550 | + | [[patch.unused]] | |
| 7551 | + | name = "ops-status" | |
| 7552 | + | version = "0.1.0" | |
| 7553 | + | ||
| 7554 | + | [[patch.unused]] | |
| 7555 | + | name = "painhours" | |
| 7556 | + | version = "0.1.0" | |
| 7557 | + | ||
| 7546 | 7558 | [[patch.unused]] | |
| 7547 | 7559 | name = "quasi-axum" | |
| 7548 | 7560 | version = "0.16.0" | |
| @@ -7566,15 +7578,3 @@ | |||
| 7566 | 7578 | [[patch.unused]] | |
| 7567 | 7579 | name = "quasi-webview" | |
| 7568 | 7580 | version = "0.16.0" | |
| 7569 | - | ||
| 7570 | - | [[patch.unused]] | |
| 7571 | - | name = "kberg" | |
| 7572 | - | version = "0.1.0" | |
| 7573 | - | ||
| 7574 | - | [[patch.unused]] | |
| 7575 | - | name = "ops-status" | |
| 7576 | - | version = "0.1.0" | |
| 7577 | - | ||
| 7578 | - | [[patch.unused]] | |
| 7579 | - | name = "painhours" | |
| 7580 | - | version = "0.1.0" |
| @@ -24,6 +24,7 @@ | |||
| 24 | 24 | //! | [`Bulk`] | [`bulk`] | an [`Intent`], applied after the frame | | |
| 25 | 25 | //! | [`Shell`] | [`shell`] | an [`Intent`], applied after the frame | | |
| 26 | 26 | //! | [`Library`] | [`library`] | an [`Intent`], applied after the frame | | |
| 27 | + | //! | [`Bar`] | [`toolbar`] | an [`Intent`], applied after the frame | | |
| 27 | 28 | //! | [`ThemeChoice`] | [`settings`] | nothing: resolved once by the host | | |
| 28 | 29 | //! | |
| 29 | 30 | //! The themes are the settled rule from goingson's settings port applied first | |
| @@ -82,6 +83,7 @@ | |||
| 82 | 83 | pub mod settings; | |
| 83 | 84 | pub mod shell; | |
| 84 | 85 | pub mod sync; | |
| 86 | + | pub mod toolbar; | |
| 85 | 87 | ||
| 86 | 88 | use audiofiles_core::config_key::ConfigKey; | |
| 87 | 89 | use quasi_router::Router; | |
| @@ -543,6 +545,22 @@ | |||
| 543 | 545 | SpreadTag(String), | |
| 544 | 546 | /// Untag every chosen sample that carries this tag. | |
| 545 | 547 | StripTag(String), | |
| 548 | + | /// Search for this. | |
| 549 | + | Search(String), | |
| 550 | + | /// Search here, or everywhere. | |
| 551 | + | Scope(bool), | |
| 552 | + | /// Save the active filters as a dynamic collection. | |
| 553 | + | SaveCollection(String), | |
| 554 | + | /// Undo the last bulk action. | |
| 555 | + | Undo, | |
| 556 | + | /// Show this panel, or stop showing it. | |
| 557 | + | TogglePanel(Panel), | |
| 558 | + | /// Go to the vault root. | |
| 559 | + | GoRoot, | |
| 560 | + | /// Go to this folder, this far along the trail. | |
| 561 | + | GoTo(i64, usize), | |
| 562 | + | /// Leave whatever mode the list is in. | |
| 563 | + | Leave, | |
| 546 | 564 | /// Make a new vault. | |
| 547 | 565 | NewVault, | |
| 548 | 566 | /// Switch to this vault. | |
| @@ -1996,6 +2014,290 @@ | |||
| 1996 | 2014 | } | |
| 1997 | 2015 | } | |
| 1998 | 2016 | ||
| 2017 | + | /// One step of the trail, as the toolbar needs to name it. | |
| 2018 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 2019 | + | pub struct Crumb { | |
| 2020 | + | /// The folder's own id. | |
| 2021 | + | pub id: i64, | |
| 2022 | + | /// What it is called. | |
| 2023 | + | pub name: String, | |
| 2024 | + | } | |
| 2025 | + | ||
| 2026 | + | /// What the list is showing, as a place or as a mode. | |
| 2027 | + | /// | |
| 2028 | + | /// Three shapes at one region, and the same reasoning `Focus` and `Phase` are | |
| 2029 | + | /// written with: a user does not navigate to "similar to kick.wav", they arrive | |
| 2030 | + | /// there by asking for it. What is different is that two of the three carry a | |
| 2031 | + | /// way *out* rather than a way back, which is what a mode is. | |
| 2032 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 2033 | + | pub enum Where { | |
| 2034 | + | /// A folder, and how you got to it. | |
| 2035 | + | Folder { | |
| 2036 | + | /// The trail from the root, nearest the root first. Empty at the root. | |
| 2037 | + | trail: Vec<Crumb>, | |
| 2038 | + | }, | |
| 2039 | + | /// A collection's contents. | |
| 2040 | + | Collection { | |
| 2041 | + | /// What the collection is called. | |
| 2042 | + | name: String, | |
| 2043 | + | }, | |
| 2044 | + | /// Samples that sound like one particular sample. | |
| 2045 | + | Similar { | |
| 2046 | + | /// What that sample is called. | |
| 2047 | + | name: String, | |
| 2048 | + | }, | |
| 2049 | + | } | |
| 2050 | + | ||
| 2051 | + | /// What is being looked for. | |
| 2052 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 2053 | + | pub struct Searching { | |
| 2054 | + | /// What is typed. | |
| 2055 | + | pub query: String, | |
| 2056 | + | /// Whether the search covers every vault rather than this folder. | |
| 2057 | + | pub everywhere: bool, | |
| 2058 | + | /// Whether anything is narrowing the list at all. | |
| 2059 | + | pub filtered: bool, | |
| 2060 | + | /// How many rows came back. | |
| 2061 | + | pub results: usize, | |
| 2062 | + | /// How many filter axes are set. | |
| 2063 | + | pub filters: usize, | |
| 2064 | + | /// The name the app would give a collection made of these filters. | |
| 2065 | + | /// | |
| 2066 | + | /// `SearchFilter::describe`, resolved here rather than in the description | |
| 2067 | + | /// for [`Sync::quote_cents`]'s reason: naming a filter set is the app's, and | |
| 2068 | + | /// a screen that reimplemented it would be a second answer. | |
| 2069 | + | pub describes: String, | |
| 2070 | + | } | |
| 2071 | + | ||
| 2072 | + | /// A panel the toolbar shows or hides. | |
| 2073 | + | /// | |
| 2074 | + | /// A closed set for the reason [`Setting`] is one: it lets a single route serve | |
| 2075 | + | /// all six, and an undeclared name is a `NotFound` rather than a silent no-op. | |
| 2076 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 2077 | + | pub enum Panel { | |
| 2078 | + | /// The vaults, collections and tags. | |
| 2079 | + | Sidebar, | |
| 2080 | + | /// The selected sample's facts. | |
| 2081 | + | Detail, | |
| 2082 | + | /// The sample editor. | |
| 2083 | + | Edit, | |
| 2084 | + | /// The instrument keyboard. | |
| 2085 | + | Instrument, | |
| 2086 | + | /// Whether preview loops. | |
| 2087 | + | Loop, | |
| 2088 | + | /// The filter axes. | |
| 2089 | + | Filters, | |
| 2090 | + | } | |
| 2091 | + | ||
| 2092 | + | impl Panel { | |
| 2093 | + | /// Every one, in the order the toolbar puts them. | |
| 2094 | + | pub const ALL: [Self; 6] = [ | |
| 2095 | + | Self::Sidebar, | |
| 2096 | + | Self::Detail, | |
| 2097 | + | Self::Edit, | |
| 2098 | + | Self::Instrument, | |
| 2099 | + | Self::Loop, | |
| 2100 | + | Self::Filters, | |
| 2101 | + | ]; | |
| 2102 | + | ||
| 2103 | + | /// The name an address is built from. | |
| 2104 | + | #[must_use] | |
| 2105 | + | pub const fn as_str(self) -> &'static str { | |
| 2106 | + | match self { | |
| 2107 | + | Self::Sidebar => "sidebar", | |
| 2108 | + | Self::Detail => "detail", | |
| 2109 | + | Self::Edit => "edit", | |
| 2110 | + | Self::Instrument => "instrument", | |
| 2111 | + | Self::Loop => "loop", | |
| 2112 | + | Self::Filters => "filters", | |
| 2113 | + | } | |
| 2114 | + | } | |
| 2115 | + | ||
| 2116 | + | /// What the control says. | |
| 2117 | + | #[must_use] | |
| 2118 | + | pub const fn label(self) -> &'static str { | |
| 2119 | + | match self { | |
| 2120 | + | Self::Sidebar => "Sidebar", | |
| 2121 | + | Self::Detail => "Detail", | |
| 2122 | + | Self::Edit => "Edit", | |
| 2123 | + | Self::Instrument => "Instrument", | |
| 2124 | + | Self::Loop => "Loop", | |
| 2125 | + | Self::Filters => "Filters", | |
| 2126 | + | } | |
| 2127 | + | } | |
| 2128 | + | ||
| 2129 | + | /// The panel that name means, if it means one. | |
| 2130 | + | #[must_use] | |
| 2131 | + | pub fn from_key(name: &str) -> Option<Self> { | |
| 2132 | + | Self::ALL.into_iter().find(|panel| panel.as_str() == name) | |
| 2133 | + | } | |
| 2134 | + | } | |
| 2135 | + | ||
| 2136 | + | /// The toolbar, as much of it as a described screen needs. | |
| 2137 | + | /// | |
| 2138 | + | /// The ninth narrow trait. | |
| 2139 | + | pub trait Bar { | |
| 2140 | + | /// Where the list is, or what mode it is in. | |
| 2141 | + | fn place(&self) -> Where; | |
| 2142 | + | ||
| 2143 | + | /// What is being looked for. | |
| 2144 | + | fn searching(&self) -> Searching; | |
| 2145 | + | ||
| 2146 | + | /// Which panels are showing. | |
| 2147 | + | fn showing(&self) -> Vec<Panel>; | |
| 2148 | + | ||
| 2149 | + | /// Whether there is anything to undo. | |
| 2150 | + | fn undoable(&self) -> bool; | |
| 2151 | + | ||
| 2152 | + | /// Look for this. | |
| 2153 | + | fn search(&self, query: &str); | |
| 2154 | + | ||
| 2155 | + | /// Look everywhere, or just here. | |
| 2156 | + | fn set_scope(&self, everywhere: bool); | |
| 2157 | + | ||
| 2158 | + | /// Keep the active filters under this name. | |
| 2159 | + | fn save_collection(&self, name: &str); | |
| 2160 | + | ||
| 2161 | + | /// Undo the last bulk action. | |
| 2162 | + | fn undo(&self); | |
| 2163 | + | ||
| 2164 | + | /// Show this panel, or stop. | |
| 2165 | + | fn toggle(&self, panel: Panel); | |
| 2166 | + | ||
| 2167 | + | /// Go to the vault root. | |
| 2168 | + | fn go_root(&self); | |
| 2169 | + | ||
| 2170 | + | /// Go to this folder, this far along the trail. | |
| 2171 | + | fn go_to(&self, id: i64, depth: usize); | |
| 2172 | + | ||
| 2173 | + | /// Leave whatever mode the list is in. | |
| 2174 | + | fn leave(&self); | |
| 2175 | + | } | |
| 2176 | + | ||
| 2177 | + | /// The app's toolbar, as the narrow thing a described screen borrows. | |
| 2178 | + | pub struct FromBar<'a> { | |
| 2179 | + | /// Where the app is and what it has found. | |
| 2180 | + | pub state: &'a crate::state::BrowserState, | |
| 2181 | + | /// What the described screen asked for, applied after the frame. | |
| 2182 | + | pub intents: &'a std::cell::RefCell<Vec<Intent>>, | |
| 2183 | + | } | |
| 2184 | + | ||
| 2185 | + | impl Bar for FromBar<'_> { | |
| 2186 | + | fn place(&self) -> Where { | |
| 2187 | + | if self.state.search.similarity_search_hash.is_some() { | |
| 2188 | + | return Where::Similar { | |
| 2189 | + | name: self | |
| 2190 | + | .state | |
| 2191 | + | .search | |
| 2192 | + | .similarity_source_name | |
| 2193 | + | .clone() | |
| 2194 | + | .unwrap_or_else(|| "sample".to_owned()), | |
| 2195 | + | }; | |
| 2196 | + | } | |
| 2197 | + | if let Some(active) = self.state.collections_ui.active_collection { | |
| 2198 | + | return Where::Collection { | |
| 2199 | + | name: self | |
| 2200 | + | .state | |
| 2201 | + | .collections_ui | |
| 2202 | + | .collections | |
| 2203 | + | .iter() | |
| 2204 | + | .find(|collection| collection.id == active) | |
| 2205 | + | .map_or_else(|| "Collection".to_owned(), |found| found.name.clone()), | |
| 2206 | + | }; | |
| 2207 | + | } | |
| 2208 | + | Where::Folder { | |
| 2209 | + | trail: self | |
| 2210 | + | .state | |
| 2211 | + | .nav | |
| 2212 | + | .breadcrumb | |
| 2213 | + | .iter() | |
| 2214 | + | .map(|crumb| Crumb { | |
| 2215 | + | id: crumb.id.as_i64(), | |
| 2216 | + | name: crumb.name.clone(), | |
| 2217 | + | }) | |
| 2218 | + | .collect(), | |
| 2219 | + | } | |
| 2220 | + | } | |
| 2221 | + | ||
| 2222 | + | fn searching(&self) -> Searching { | |
| 2223 | + | let filter = &self.state.search.search_filter; | |
| 2224 | + | Searching { | |
| 2225 | + | query: self.state.search.search_query.clone(), | |
| 2226 | + | everywhere: matches!(filter.scope, audiofiles_core::search::SearchScope::Global), | |
| 2227 | + | filtered: filter.is_active(), | |
| 2228 | + | results: self.state.nav.contents.len(), | |
| 2229 | + | filters: filter.active_count(), | |
| 2230 | + | describes: filter.describe(), | |
| 2231 | + | } | |
| 2232 | + | } | |
| 2233 | + | ||
| 2234 | + | fn showing(&self) -> Vec<Panel> { | |
| 2235 | + | let mut showing = Vec::new(); | |
| 2236 | + | if self.state.sidebar_visible { | |
| 2237 | + | showing.push(Panel::Sidebar); | |
| 2238 | + | } | |
| 2239 | + | if self.state.detail.detail_visible { | |
| 2240 | + | showing.push(Panel::Detail); | |
| 2241 | + | } | |
| 2242 | + | if self.state.edit.show_window { | |
| 2243 | + | showing.push(Panel::Edit); | |
| 2244 | + | } | |
| 2245 | + | if self.state.preview.show_midi_window { | |
| 2246 | + | showing.push(Panel::Instrument); | |
| 2247 | + | } | |
| 2248 | + | if self.state.preview.loop_enabled { | |
| 2249 | + | showing.push(Panel::Loop); | |
| 2250 | + | } | |
| 2251 | + | if self.state.search.filter_panel_open { | |
| 2252 | + | showing.push(Panel::Filters); | |
| 2253 | + | } | |
| 2254 | + | showing | |
| 2255 | + | } | |
| 2256 | + | ||
| 2257 | + | fn undoable(&self) -> bool { | |
| 2258 | + | self.state.can_undo() | |
| 2259 | + | } | |
| 2260 | + | ||
| 2261 | + | fn search(&self, query: &str) { | |
| 2262 | + | self.push(Intent::Search(query.to_owned())); | |
| 2263 | + | } | |
| 2264 | + | ||
| 2265 | + | fn set_scope(&self, everywhere: bool) { | |
| 2266 | + | self.push(Intent::Scope(everywhere)); | |
| 2267 | + | } | |
| 2268 | + | ||
| 2269 | + | fn save_collection(&self, name: &str) { | |
| 2270 | + | self.push(Intent::SaveCollection(name.to_owned())); | |
| 2271 | + | } | |
| 2272 | + | ||
| 2273 | + | fn undo(&self) { | |
| 2274 | + | self.push(Intent::Undo); | |
| 2275 | + | } | |
| 2276 | + | ||
| 2277 | + | fn toggle(&self, panel: Panel) { | |
| 2278 | + | self.push(Intent::TogglePanel(panel)); | |
| 2279 | + | } | |
| 2280 | + | ||
| 2281 | + | fn go_root(&self) { | |
| 2282 | + | self.push(Intent::GoRoot); | |
| 2283 | + | } | |
| 2284 | + | ||
| 2285 | + | fn go_to(&self, id: i64, depth: usize) { | |
| 2286 | + | self.push(Intent::GoTo(id, depth)); | |
| 2287 | + | } | |
| 2288 | + | ||
| 2289 | + | fn leave(&self) { | |
| 2290 | + | self.push(Intent::Leave); | |
| 2291 | + | } | |
| 2292 | + | } | |
| 2293 | + | ||
| 2294 | + | impl FromBar<'_> { | |
| 2295 | + | /// Record what the described screen asked for. | |
| 2296 | + | fn push(&self, intent: Intent) { | |
| 2297 | + | self.intents.borrow_mut().push(intent); | |
| 2298 | + | } | |
| 2299 | + | } | |
| 2300 | + | ||
| 1999 | 2301 | /// A theme the host resolved, as the description needs to name it. | |
| 2000 | 2302 | /// | |
| 2001 | 2303 | /// Three strings rather than the app's own `ThemeMeta`, so the described screen | |
| @@ -2033,6 +2335,8 @@ | |||
| 2033 | 2335 | pub shell: &'a dyn Shell, | |
| 2034 | 2336 | /// The vaults, collections and tags, for the sidebar. | |
| 2035 | 2337 | pub library: &'a dyn Library, | |
| 2338 | + | /// Where you are and what you are looking for, for the toolbar. | |
| 2339 | + | pub bar: &'a dyn Bar, | |
| 2036 | 2340 | /// The selection again, for the bulk screens. Two capabilities over one | |
| 2037 | 2341 | /// selection rather than one, because they need different things of it and | |
| 2038 | 2342 | /// the narrowing is the point: the detail screen may not move a file and | |
| @@ -2048,8 +2352,10 @@ | |||
| 2048 | 2352 | /// cost is nothing, and building it fresh is what lets the state borrow. | |
| 2049 | 2353 | #[must_use] | |
| 2050 | 2354 | pub fn router<'a>() -> Router<Panels<'a>> { | |
| 2051 | - | library::routes(shell::routes(help::routes(bulk::routes(detail::routes( | |
| 2052 | - | export::routes(files::routes(sync::routes(settings::routes(Router::new())))), | |
| 2355 | + | toolbar::routes(library::routes(shell::routes(help::routes(bulk::routes( | |
| 2356 | + | detail::routes(export::routes(files::routes(sync::routes( | |
| 2357 | + | settings::routes(Router::new()), | |
| 2358 | + | )))), | |
| 2053 | 2359 | ))))) | |
| 2054 | 2360 | } | |
| 2055 | 2361 |
| @@ -33,8 +33,8 @@ | |||
| 33 | 33 | use std::cell::RefCell; | |
| 34 | 34 | ||
| 35 | 35 | use super::{ | |
| 36 | - | FromBackend, FromBulk, FromContents, FromExport, FromLibrary, FromSelection, FromSyncManager, | |
| 37 | - | FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured, | |
| 36 | + | FromBackend, FromBar, FromBulk, FromContents, FromExport, FromLibrary, FromSelection, | |
| 37 | + | FromSyncManager, FromWindow, Intent, Panels, Setting, Sync, ThemeChoice, Unconfigured, | |
| 38 | 38 | }; | |
| 39 | 39 | use crate::state::BrowserState; | |
| 40 | 40 | use crate::ui::theme; | |
| @@ -375,6 +375,65 @@ | |||
| 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 toolbar. | |
| 379 | + | Intent::Search(query) => { | |
| 380 | + | state.search.search_query = query; | |
| 381 | + | // Applied at once rather than debounced: an intent already | |
| 382 | + | // arrived because the host decided to fire, so the settling is | |
| 383 | + | // behind us. See `toolbar`'s header on what the description | |
| 384 | + | // cannot yet say about that. | |
| 385 | + | state.search.search_debounce_at = None; | |
| 386 | + | state.apply_search(); | |
| 387 | + | } | |
| 388 | + | Intent::Scope(everywhere) => { | |
| 389 | + | state.search.search_filter.scope = if everywhere { | |
| 390 | + | audiofiles_core::search::SearchScope::Global | |
| 391 | + | } else { | |
| 392 | + | audiofiles_core::search::SearchScope::CurrentFolder | |
| 393 | + | }; | |
| 394 | + | state.apply_search(); | |
| 395 | + | } | |
| 396 | + | Intent::SaveCollection(name) => state.save_dynamic_collection(&name), | |
| 397 | + | Intent::Undo => state.undo(), | |
| 398 | + | Intent::TogglePanel(panel) => match panel { | |
| 399 | + | super::Panel::Sidebar => state.toggle_sidebar(), | |
| 400 | + | super::Panel::Detail => state.toggle_detail(), | |
| 401 | + | super::Panel::Edit => crate::ui::toolbar::toggle_edit_window(state), | |
| 402 | + | super::Panel::Instrument => { | |
| 403 | + | state.preview.show_midi_window = !state.preview.show_midi_window; | |
| 404 | + | } | |
| 405 | + | super::Panel::Loop => state.toggle_loop(), | |
| 406 | + | super::Panel::Filters => state.toggle_filter_panel(), | |
| 407 | + | }, | |
| 408 | + | Intent::GoRoot => { | |
| 409 | + | state.nav.current_dir = None; | |
| 410 | + | state.nav.breadcrumb.clear(); | |
| 411 | + | state.nav.selection.clear(); | |
| 412 | + | state.refresh_contents(); | |
| 413 | + | } | |
| 414 | + | Intent::GoTo(id, depth) => { | |
| 415 | + | if state | |
| 416 | + | .nav | |
| 417 | + | .breadcrumb | |
| 418 | + | .get(depth.saturating_sub(1)) | |
| 419 | + | .is_some_and(|crumb| crumb.id.as_i64() == id) | |
| 420 | + | { | |
| 421 | + | state.nav.current_dir = Some(audiofiles_core::NodeId::from(id)); | |
| 422 | + | state.nav.breadcrumb.truncate(depth); | |
| 423 | + | state.nav.selection.clear(); | |
| 424 | + | state.refresh_contents(); | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | // One control for two modes, because leaving either means the same | |
| 428 | + | // thing to the user: go back to browsing. Which one is showing is | |
| 429 | + | // what `Where` already says. | |
| 430 | + | Intent::Leave => { | |
| 431 | + | if state.search.similarity_search_hash.is_some() { | |
| 432 | + | state.clear_similarity_search(); | |
| 433 | + | } else { | |
| 434 | + | state.deactivate_collection(); | |
| 435 | + | } | |
| 436 | + | } | |
| 378 | 437 | // The sidebar. Two of these hand an already-agreed decision to the | |
| 379 | 438 | // app's own executor: the described control asked with | |
| 380 | 439 | // `Act::confirm`, the runtime answered `Step::Ask`, the user said | |
| @@ -820,6 +879,7 @@ | |||
| 820 | 879 | let bulk = FromBulk { state, intents }; | |
| 821 | 880 | let shell = FromWindow { state, intents }; | |
| 822 | 881 | let library = FromLibrary { state, intents }; | |
| 882 | + | let bar = FromBar { state, intents }; | |
| 823 | 883 | let panels = Panels { | |
| 824 | 884 | config: &config, | |
| 825 | 885 | sync, | |
| @@ -829,6 +889,7 @@ | |||
| 829 | 889 | bulk: &bulk, | |
| 830 | 890 | shell: &shell, | |
| 831 | 891 | library: &library, | |
| 892 | + | bar: &bar, | |
| 832 | 893 | themes, | |
| 833 | 894 | }; | |
| 834 | 895 | super::router() |
| @@ -114,6 +114,7 @@ | |||
| 114 | 114 | /// rather than the corner of it that was pressed. | |
| 115 | 115 | pub(super) fn screen(state: &Panels<'_>) -> Screen { | |
| 116 | 116 | Screen::sidebar_content("audiofiles") | |
| 117 | + | .with(super::toolbar::body(state)) | |
| 117 | 118 | .with(super::library::body(state)) | |
| 118 | 119 | .with(super::files::body(state)) | |
| 119 | 120 | .with(foot(state)) |
| @@ -12,11 +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, 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, | |
| 15 | + | Analysed, Analysis, Bar, Bulk, Channels, Chosen, Collection, ColumnsShown, Config, Coverage, | |
| 16 | + | Crumb, Detail, Detailed, Export, Files, Filter, Focus, Folder, Format, Holding, Library, Panel, | |
| 17 | + | Panels, Phase, Playing, Pricing, ProfileChoice, Sample, Saying, Searching, Setting, Settings, | |
| 18 | + | Shared, Shell, Source, Spread, State, Status, Subject, Subscription, Suggested, Sync, Tagged, | |
| 19 | + | ThemeChoice, Vault, Where, router, | |
| 20 | 20 | }; | |
| 21 | 21 | ||
| 22 | 22 | /// A config store in memory. | |
| @@ -213,6 +213,7 @@ | |||
| 213 | 213 | bulk: &Unchosen, | |
| 214 | 214 | shell: &Quiet, | |
| 215 | 215 | library: &Empty, | |
| 216 | + | bar: &Still, | |
| 216 | 217 | config: &store, | |
| 217 | 218 | sync: &sync, | |
| 218 | 219 | files: &files, | |
| @@ -273,6 +274,7 @@ | |||
| 273 | 274 | bulk: &Unchosen, | |
| 274 | 275 | shell: &Quiet, | |
| 275 | 276 | library: &Empty, | |
| 277 | + | bar: &Still, | |
| 276 | 278 | config: &store, | |
| 277 | 279 | sync: &sync, | |
| 278 | 280 | files, | |
| @@ -381,12 +383,23 @@ | |||
| 381 | 383 | } | |
| 382 | 384 | ||
| 383 | 385 | /// Every field on the screen, by name. | |
| 386 | + | /// | |
| 387 | + | /// Standing alone or inside a form: the two are the same fact about what the | |
| 388 | + | /// screen asks for, and only the submit differs. | |
| 384 | 389 | fn fields(screen: &Screen) -> BTreeMap<String, Option<String>> { | |
| 385 | 390 | let mut found = BTreeMap::new(); | |
| 386 | 391 | for slot in &screen.slots { | |
| 387 | 392 | for node in &slot.body { | |
| 388 | - | if let Node::Field(field) = node { | |
| 389 | - | found.insert(field.name.clone(), field.value.clone()); | |
| 393 | + | match node { | |
| 394 | + | Node::Field(field) => { | |
| 395 | + | found.insert(field.name.clone(), field.value.clone()); | |
| 396 | + | } | |
| 397 | + | Node::Form { fields, .. } => { | |
| 398 | + | for field in fields { | |
| 399 | + | found.insert(field.name.clone(), field.value.clone()); | |
| 400 | + | } | |
| 401 | + | } | |
| 402 | + | _ => {} | |
| 390 | 403 | } | |
| 391 | 404 | } | |
| 392 | 405 | } | |
| @@ -404,6 +417,7 @@ | |||
| 404 | 417 | bulk: &Unchosen, | |
| 405 | 418 | shell: &Quiet, | |
| 406 | 419 | library: &Empty, | |
| 420 | + | bar: &Still, | |
| 407 | 421 | config: &store, | |
| 408 | 422 | sync: &sync, | |
| 409 | 423 | files: &files, | |
| @@ -453,6 +467,7 @@ | |||
| 453 | 467 | bulk: &Unchosen, | |
| 454 | 468 | shell: &Quiet, | |
| 455 | 469 | library: &Empty, | |
| 470 | + | bar: &Still, | |
| 456 | 471 | config: &store, | |
| 457 | 472 | sync: &sync, | |
| 458 | 473 | files: &files, | |
| @@ -496,6 +511,7 @@ | |||
| 496 | 511 | bulk: &Unchosen, | |
| 497 | 512 | shell: &Quiet, | |
| 498 | 513 | library: &Empty, | |
| 514 | + | bar: &Still, | |
| 499 | 515 | config: &store, | |
| 500 | 516 | sync: &sync, | |
| 501 | 517 | files: &files, | |
| @@ -525,6 +541,7 @@ | |||
| 525 | 541 | bulk: &Unchosen, | |
| 526 | 542 | shell: &Quiet, | |
| 527 | 543 | library: &Empty, | |
| 544 | + | bar: &Still, | |
| 528 | 545 | config: &store, | |
| 529 | 546 | sync: &sync, | |
| 530 | 547 | files: &files, | |
| @@ -577,6 +594,7 @@ | |||
| 577 | 594 | bulk: &Unchosen, | |
| 578 | 595 | shell: &Quiet, | |
| 579 | 596 | library: &Empty, | |
| 597 | + | bar: &Still, | |
| 580 | 598 | config: &store, | |
| 581 | 599 | sync: &sync, | |
| 582 | 600 | files: &files, | |
| @@ -625,6 +643,7 @@ | |||
| 625 | 643 | bulk: &Unchosen, | |
| 626 | 644 | shell: &Quiet, | |
| 627 | 645 | library: &Empty, | |
| 646 | + | bar: &Still, | |
| 628 | 647 | config: &store, | |
| 629 | 648 | sync: &sync, | |
| 630 | 649 | files: &files, | |
| @@ -779,6 +798,7 @@ | |||
| 779 | 798 | bulk: &Unchosen, | |
| 780 | 799 | shell: &Quiet, | |
| 781 | 800 | library: &Empty, | |
| 801 | + | bar: &Still, | |
| 782 | 802 | config: &store, | |
| 783 | 803 | sync, | |
| 784 | 804 | files: &files, | |
| @@ -1800,6 +1820,7 @@ | |||
| 1800 | 1820 | bulk: &Unchosen, | |
| 1801 | 1821 | shell: &Quiet, | |
| 1802 | 1822 | library: &Empty, | |
| 1823 | + | bar: &Still, | |
| 1803 | 1824 | themes: &themes, | |
| 1804 | 1825 | }; | |
| 1805 | 1826 | router().handle(&state, request) | |
| @@ -2329,6 +2350,7 @@ | |||
| 2329 | 2350 | bulk, | |
| 2330 | 2351 | shell: &Quiet, | |
| 2331 | 2352 | library: &Empty, | |
| 2353 | + | bar: &Still, | |
| 2332 | 2354 | themes: &themes, | |
| 2333 | 2355 | }; | |
| 2334 | 2356 | router().handle(&state, request) | |
| @@ -2698,6 +2720,7 @@ | |||
| 2698 | 2720 | bulk: &Unchosen, | |
| 2699 | 2721 | shell: &Quiet, | |
| 2700 | 2722 | library: &Empty, | |
| 2723 | + | bar: &Still, | |
| 2701 | 2724 | themes: &themes, | |
| 2702 | 2725 | }; | |
| 2703 | 2726 | router().handle(&state, request) | |
| @@ -2747,6 +2770,7 @@ | |||
| 2747 | 2770 | bulk: &bulk, | |
| 2748 | 2771 | shell: &Quiet, | |
| 2749 | 2772 | library: &Empty, | |
| 2773 | + | bar: &Still, | |
| 2750 | 2774 | themes: &themes, | |
| 2751 | 2775 | }; | |
| 2752 | 2776 | ||
| @@ -2957,6 +2981,7 @@ | |||
| 2957 | 2981 | bulk: &Unchosen, | |
| 2958 | 2982 | shell, | |
| 2959 | 2983 | library: &Empty, | |
| 2984 | + | bar: &Still, | |
| 2960 | 2985 | themes: &themes, | |
| 2961 | 2986 | }; | |
| 2962 | 2987 | router().handle(&state, request) | |
| @@ -3009,6 +3034,7 @@ | |||
| 3009 | 3034 | assert_eq!( | |
| 3010 | 3035 | regions(&screen), | |
| 3011 | 3036 | [ | |
| 3037 | + | ("toolbar-bar".to_owned(), quasi_router::RegionKind::Band), | |
| 3012 | 3038 | ("library-side".to_owned(), quasi_router::RegionKind::Sidebar), | |
| 3013 | 3039 | ("files-body".to_owned(), quasi_router::RegionKind::Pane), | |
| 3014 | 3040 | ("shell-foot".to_owned(), quasi_router::RegionKind::Band), | |
| @@ -3374,6 +3400,7 @@ | |||
| 3374 | 3400 | bulk: &Unchosen, | |
| 3375 | 3401 | shell: &Quiet, | |
| 3376 | 3402 | library, | |
| 3403 | + | bar: &Still, | |
| 3377 | 3404 | themes: &themes, | |
| 3378 | 3405 | }; | |
| 3379 | 3406 | router().handle(&state, request) | |
| @@ -3415,6 +3442,7 @@ | |||
| 3415 | 3442 | assert_eq!( | |
| 3416 | 3443 | regions(&browsed(&library)), | |
| 3417 | 3444 | [ | |
| 3445 | + | ("toolbar-bar".to_owned(), quasi_router::RegionKind::Band), | |
| 3418 | 3446 | ("library-side".to_owned(), quasi_router::RegionKind::Sidebar), | |
| 3419 | 3447 | ("files-body".to_owned(), quasi_router::RegionKind::Pane), | |
| 3420 | 3448 | ("shell-foot".to_owned(), quasi_router::RegionKind::Band), | |
| @@ -3511,8 +3539,12 @@ | |||
| 3511 | 3539 | let library = FakeLibrary::stocked(); | |
| 3512 | 3540 | let screen = browsed(&library); | |
| 3513 | 3541 | ||
| 3514 | - | let chips: Vec<String> = nodes(&screen) | |
| 3542 | + | // The sidebar's own region, since the toolbar's panel toggles are chips too. | |
| 3543 | + | let chips: Vec<String> = screen | |
| 3544 | + | .slots | |
| 3515 | 3545 | .iter() | |
| 3546 | + | .filter(|slot| slot.id == "library-side") | |
| 3547 | + | .flat_map(|slot| &slot.body) | |
| 3516 | 3548 | .filter_map(|node| match node { | |
| 3517 | 3549 | Node::Token(tag) => Some(tag.label.clone()), | |
| 3518 | 3550 | _ => None, | |
| @@ -3610,3 +3642,436 @@ | |||
| 3610 | 3642 | ["new vault", "delete collection 11", "remove drums.kick"] | |
| 3611 | 3643 | ); | |
| 3612 | 3644 | } | |
| 3645 | + | ||
| 3646 | + | // The toolbar. | |
| 3647 | + | ||
| 3648 | + | /// A toolbar at the root with nothing typed. | |
| 3649 | + | struct Still; | |
| 3650 | + | ||
| 3651 | + | impl Bar for Still { | |
| 3652 | + | fn place(&self) -> Where { | |
| 3653 | + | Where::Folder { trail: Vec::new() } | |
| 3654 | + | } | |
| 3655 | + | ||
| 3656 | + | fn searching(&self) -> Searching { | |
| 3657 | + | Searching { | |
| 3658 | + | query: String::new(), | |
| 3659 | + | everywhere: false, | |
| 3660 | + | filtered: false, | |
| 3661 | + | results: 0, | |
| 3662 | + | filters: 0, | |
| 3663 | + | describes: String::new(), | |
| 3664 | + | } | |
| 3665 | + | } | |
| 3666 | + | ||
| 3667 | + | fn showing(&self) -> Vec<Panel> { | |
| 3668 | + | Vec::new() | |
| 3669 | + | } | |
| 3670 | + | ||
| 3671 | + | fn undoable(&self) -> bool { | |
| 3672 | + | false | |
| 3673 | + | } | |
| 3674 | + | ||
| 3675 | + | fn search(&self, _query: &str) {} | |
| 3676 | + | fn set_scope(&self, _everywhere: bool) {} | |
| 3677 | + | fn save_collection(&self, _name: &str) {} | |
| 3678 | + | fn undo(&self) {} | |
| 3679 | + | fn toggle(&self, _panel: Panel) {} | |
| 3680 | + | fn go_root(&self) {} | |
| 3681 | + | fn go_to(&self, _id: i64, _depth: usize) {} | |
| 3682 | + | fn leave(&self) {} | |
| 3683 | + | } | |
| 3684 | + | ||
| 3685 | + | /// A toolbar in memory, recording what was asked of it. | |
| 3686 | + | struct FakeBar { | |
| 3687 | + | place: Where, | |
| 3688 | + | searching: Searching, | |
| 3689 | + | showing: Vec<Panel>, | |
| 3690 | + | undoable: bool, | |
| 3691 | + | asked: RefCell<Vec<String>>, | |
| 3692 | + | } | |
| 3693 | + | ||
| 3694 | + | impl FakeBar { | |
| 3695 | + | fn at(place: Where) -> Self { | |
| 3696 | + | Self { | |
| 3697 | + | place, | |
| 3698 | + | searching: Searching { | |
| 3699 | + | query: String::new(), | |
| 3700 | + | everywhere: false, | |
| 3701 | + | filtered: false, | |
| 3702 | + | results: 0, | |
| 3703 | + | filters: 0, | |
| 3704 | + | describes: String::new(), | |
| 3705 | + | }, | |
| 3706 | + | showing: Vec::new(), | |
| 3707 | + | undoable: false, | |
| 3708 | + | asked: RefCell::new(Vec::new()), | |
| 3709 | + | } | |
| 3710 | + | } | |
| 3711 | + | ||
| 3712 | + | fn deep() -> Self { | |
| 3713 | + | Self::at(Where::Folder { | |
| 3714 | + | trail: vec![ | |
| 3715 | + | Crumb { | |
| 3716 | + | id: 7, | |
| 3717 | + | name: "kits".to_owned(), | |
| 3718 | + | }, | |
| 3719 | + | Crumb { | |
| 3720 | + | id: 8, | |
| 3721 | + | name: "808".to_owned(), | |
| 3722 | + | }, | |
| 3723 | + | ], | |
| 3724 | + | }) | |
| 3725 | + | } | |
| 3726 | + | ||
| 3727 | + | fn filtering() -> Self { | |
| 3728 | + | let mut bar = Self::at(Where::Folder { trail: Vec::new() }); | |
| 3729 | + | bar.searching = Searching { | |
| 3730 | + | query: "kick".to_owned(), | |
| 3731 | + | everywhere: true, | |
| 3732 | + | filtered: true, | |
| 3733 | + | results: 42, | |
| 3734 | + | filters: 3, | |
| 3735 | + | describes: "Kicks under 120 BPM".to_owned(), | |
| 3736 | + | }; | |
| 3737 | + | bar | |
| 3738 | + | } | |
| 3739 | + | ||
| 3740 | + | fn note(&self, what: impl Into<String>) { | |
| 3741 | + | self.asked.borrow_mut().push(what.into()); | |
| 3742 | + | } | |
| 3743 | + | ||
| 3744 | + | fn asked(&self) -> Vec<String> { | |
| 3745 | + | self.asked.borrow().clone() | |
| 3746 | + | } | |
| 3747 | + | } | |
| 3748 | + | ||
| 3749 | + | impl Bar for FakeBar { | |
| 3750 | + | fn place(&self) -> Where { | |
| 3751 | + | self.place.clone() | |
| 3752 | + | } | |
| 3753 | + | ||
| 3754 | + | fn searching(&self) -> Searching { | |
| 3755 | + | self.searching.clone() | |
| 3756 | + | } | |
| 3757 | + | ||
| 3758 | + | fn showing(&self) -> Vec<Panel> { | |
| 3759 | + | self.showing.clone() | |
| 3760 | + | } | |
| 3761 | + | ||
| 3762 | + | fn undoable(&self) -> bool { | |
| 3763 | + | self.undoable | |
| 3764 | + | } | |
| 3765 | + | ||
| 3766 | + | fn search(&self, query: &str) { | |
| 3767 | + | self.note(format!("search {query}")); | |
| 3768 | + | } | |
| 3769 | + | ||
| 3770 | + | fn set_scope(&self, everywhere: bool) { | |
| 3771 | + | self.note(if everywhere { "everywhere" } else { "here" }); | |
| 3772 | + | } | |
| 3773 | + | ||
| 3774 | + | fn save_collection(&self, name: &str) { | |
| 3775 | + | self.note(format!("save {name}")); | |
| 3776 | + | } | |
| 3777 | + | ||
| 3778 | + | fn undo(&self) { | |
| 3779 | + | self.note("undo"); | |
| 3780 | + | } | |
| 3781 | + | ||
| 3782 | + | fn toggle(&self, panel: Panel) { | |
| 3783 | + | self.note(format!("toggle {}", panel.as_str())); | |
| 3784 | + | } | |
| 3785 | + | ||
| 3786 | + | fn go_root(&self) { | |
| 3787 | + | self.note("root"); | |
| 3788 | + | } | |
| 3789 | + | ||
| 3790 | + | fn go_to(&self, id: i64, depth: usize) { | |
| 3791 | + | self.note(format!("go {id} at {depth}")); | |
| 3792 | + | } | |
| 3793 | + | ||
| 3794 | + | fn leave(&self) { | |
| 3795 | + | self.note("leave"); | |
| 3796 | + | } | |
| 3797 | + | } | |
| 3798 | + | ||
| 3799 | + | /// A router call against this toolbar. | |
| 3800 | + | fn barred(bar: &FakeBar, request: Request) -> Result<Response, quasi_router::RouteError> { | |
| 3801 | + | let store = Store::default(); | |
| 3802 | + | let sync = Offline; | |
| 3803 | + | let files = FakeFiles::with(vec![sample(1, "kick.wav")]); | |
| 3804 | + | let themes = themes(); | |
| 3805 | + | let state = Panels { | |
| 3806 | + | config: &store, | |
| 3807 | + | sync: &sync, | |
| 3808 | + | files: &files, | |
| 3809 | + | export: &Idle, | |
| 3810 | + | detail: &Unfocused, | |
| 3811 | + | bulk: &Unchosen, | |
| 3812 | + | shell: &Quiet, | |
| 3813 | + | library: &Empty, | |
| 3814 | + | bar, | |
| 3815 | + | themes: &themes, | |
| 3816 | + | }; | |
| 3817 | + | router().handle(&state, request) | |
| 3818 | + | } | |
| 3819 | + | ||
| 3820 | + | /// The main screen, seen through this toolbar. | |
| 3821 | + | fn topped(bar: &FakeBar) -> Screen { | |
| 3822 | + | screen_of(&barred(bar, Request::get("/")).unwrap()).clone() | |
| 3823 | + | } | |
| 3824 | + | ||
| 3825 | + | /// Every link on a screen, as text and destination. | |
| 3826 | + | fn links(screen: &Screen) -> Vec<(String, String)> { | |
| 3827 | + | nodes(screen) | |
| 3828 | + | .iter() | |
| 3829 | + | .filter_map(|node| match node { | |
| 3830 | + | Node::Link { text, action } => Some(( | |
| 3831 | + | text.clone(), | |
| 3832 | + | action.destination.route().unwrap_or_default().to_owned(), | |
| 3833 | + | )), | |
| 3834 | + | _ => None, | |
| 3835 | + | }) | |
| 3836 | + | .collect() | |
| 3837 | + | } | |
| 3838 | + | ||
| 3839 | + | #[test] | |
| 3840 | + | fn the_toolbar_is_the_window_s_fourth_region_and_its_first() { | |
| 3841 | + | let bar = FakeBar::deep(); | |
| 3842 | + | assert_eq!( | |
| 3843 | + | regions(&topped(&bar)) | |
| 3844 | + | .into_iter() | |
| 3845 | + | .map(|(id, _)| id) | |
| 3846 | + | .collect::<Vec<_>>(), | |
| 3847 | + | ["toolbar-bar", "library-side", "files-body", "shell-foot"] | |
| 3848 | + | ); | |
| 3849 | + | } | |
| 3850 | + | ||
| 3851 | + | #[test] | |
| 3852 | + | fn a_breadcrumb_is_links_and_the_place_you_are_is_not_one() { | |
| 3853 | + | // Links rather than acts, which is `Node::Link`'s own argument: "making | |
| 3854 | + | // every linked value a button would put a row of bevels down the first | |
| 3855 | + | // column of half a dashboard". | |
| 3856 | + | let bar = FakeBar::deep(); | |
| 3857 | + | let screen = topped(&bar); | |
| 3858 | + | ||
| 3859 | + | assert_eq!( | |
| 3860 | + | links(&screen), | |
| 3861 | + | [ | |
| 3862 | + | ("/".to_owned(), "/here/root".to_owned()), | |
| 3863 | + | ("kits".to_owned(), "/here/7/1".to_owned()), | |
| 3864 | + | ] | |
| 3865 | + | ); | |
| 3866 | + | // The last crumb is where you are, so it goes nowhere at all rather than | |
| 3867 | + | // being a link that does nothing. | |
| 3868 | + | assert!(said(&screen).contains("808")); | |
| 3869 | + | } | |
| 3870 | + | ||
| 3871 | + | #[test] | |
| 3872 | + | fn walking_back_up_the_trail_names_how_far_along_it_went() { | |
| 3873 | + | // The depth rides with the id because navigating to a crumb truncates the | |
| 3874 | + | // trail behind it, and how far along a folder sits is a fact about this | |
| 3875 | + | // trail rather than about the folder. | |
| 3876 | + | let bar = FakeBar::deep(); | |
| 3877 | + | barred(&bar, Request::post("/here/7/1")).unwrap(); | |
| 3878 | + | barred(&bar, Request::post("/here/root")).unwrap(); | |
| 3879 | + | assert_eq!(bar.asked(), ["go 7 at 1", "root"]); | |
| 3880 | + | ||
| 3881 | + | assert!(barred(&bar, Request::post("/here/seven/1")).is_err()); | |
| 3882 | + | assert!(barred(&bar, Request::post("/here/7/deep")).is_err()); | |
| 3883 | + | } | |
| 3884 | + | ||
| 3885 | + | #[test] | |
| 3886 | + | fn a_mode_offers_a_way_out_rather_than_a_shorter_path() { | |
| 3887 | + | for place in [ | |
| 3888 | + | Where::Collection { | |
| 3889 | + | name: "Favourites".to_owned(), | |
| 3890 | + | }, | |
| 3891 | + | Where::Similar { | |
| 3892 | + | name: "kick.wav".to_owned(), | |
| 3893 | + | }, | |
| 3894 | + | ] { | |
| 3895 | + | let bar = FakeBar::at(place); | |
| 3896 | + | let screen = topped(&bar); | |
| 3897 | + | assert!(links(&screen).is_empty(), "a mode is not a trail"); | |
| 3898 | + | assert!( | |
| 3899 | + | acts(&screen) | |
| 3900 | + | .iter() | |
| 3901 | + | .any(|label| label == "Back to browsing") | |
| 3902 | + | ); | |
| 3903 | + | } | |
| 3904 | + | ||
| 3905 | + | // One control for both, because leaving either means the same thing to the | |
| 3906 | + | // user; which mode is showing is what `Where` already says. | |
| 3907 | + | let bar = FakeBar::at(Where::Similar { | |
| 3908 | + | name: "kick.wav".to_owned(), | |
| 3909 | + | }); | |
| 3910 | + | barred(&bar, Request::post("/here/leave")).unwrap(); | |
| 3911 | + | assert_eq!(bar.asked(), ["leave"]); | |
| 3912 | + | } | |
| 3913 | + | ||
| 3914 | + | #[test] | |
| 3915 | + | fn the_similarity_mode_says_why_the_columns_stopped_sorting() { | |
| 3916 | + | // The shipped breadcrumb moved this off the column headings, "where the | |
| 3917 | + | // explanation lived on a control the user had no reason to point at". Here | |
| 3918 | + | // it is prose beside the mode, which is where the mode is. | |
| 3919 | + | let bar = FakeBar::at(Where::Similar { | |
| 3920 | + | name: "kick.wav".to_owned(), | |
| 3921 | + | }); | |
| 3922 | + | assert!(said(&topped(&bar)).contains("ranked by similarity")); | |
| 3923 | + | } | |
| 3924 | + | ||
| 3925 | + | #[test] | |
| 3926 | + | fn searching_carries_what_was_typed_and_which_scope() { | |
| 3927 | + | let bar = FakeBar::filtering(); | |
| 3928 | + | barred( | |
| 3929 | + | &bar, | |
| 3930 | + | Request::post("/search").sending(Params::new().with("query".to_owned(), "808".to_owned())), | |
| 3931 | + | ) | |
| 3932 | + | .unwrap(); | |
| 3933 | + | barred( | |
| 3934 | + | &bar, | |
| 3935 | + | Request::post("/search/scope") | |
| 3936 | + | .sending(Params::new().with(Node::SELECTED.to_owned(), "all".to_owned())), | |
| 3937 | + | ) | |
| 3938 | + | .unwrap(); | |
| 3939 | + | assert_eq!(bar.asked(), ["search 808", "everywhere"]); | |
| 3940 | + | ||
| 3941 | + | assert!( | |
| 3942 | + | barred( | |
| 3943 | + | &bar, | |
| 3944 | + | Request::post("/search/scope") | |
| 3945 | + | .sending(Params::new().with(Node::SELECTED.to_owned(), "sideways".to_owned())), | |
| 3946 | + | ) | |
| 3947 | + | .is_err() | |
| 3948 | + | ); | |
| 3949 | + | } | |
| 3950 | + | ||
| 3951 | + | #[test] | |
| 3952 | + | fn the_result_count_and_save_appear_only_once_something_narrows_the_list() { | |
| 3953 | + | let quiet = FakeBar::at(Where::Folder { trail: Vec::new() }); | |
| 3954 | + | assert!(figures(&topped(&quiet)).is_empty()); | |
| 3955 | + | assert!( | |
| 3956 | + | !acts(&topped(&quiet)) | |
| 3957 | + | .iter() | |
| 3958 | + | .any(|label| label == "Save as collection") | |
| 3959 | + | ); | |
| 3960 | + | ||
| 3961 | + | let filtering = FakeBar::filtering(); | |
| 3962 | + | assert_eq!( | |
| 3963 | + | figures(&topped(&filtering)), | |
| 3964 | + | [("42".to_owned(), "results".to_owned())] | |
| 3965 | + | ); | |
| 3966 | + | assert!( | |
| 3967 | + | acts(&topped(&filtering)) | |
| 3968 | + | .iter() |
Lines truncated
| @@ -369,7 +369,7 @@ | |||
| 369 | 369 | } | |
| 370 | 370 | ||
| 371 | 371 | /// Shared editor toggle path used by both the inline and collapsed layouts. | |
| 372 | - | fn toggle_edit_window(state: &mut BrowserState) { | |
| 372 | + | pub(crate) fn toggle_edit_window(state: &mut BrowserState) { | |
| 373 | 373 | if state.edit.show_window { | |
| 374 | 374 | state.close_edit_window(); | |
| 375 | 375 | } else if let Some(node) = state.selected_node() |
| @@ -1,0 +1,378 @@ | |||
| 1 | + | //! The toolbar, described: where you are, what you are looking for, and what is | |
| 2 | + | //! showing. | |
| 3 | + | //! | |
| 4 | + | //! The tenth port, and the last region of the main window. It is also the first | |
| 5 | + | //! screen that *navigates to the others*: Settings, Cloud Sync and Help are | |
| 6 | + | //! described already, so the toolbar's buttons for them are ordinary addresses | |
| 7 | + | //! rather than intents. Up to here every described window was reached by the | |
| 8 | + | //! host opening it; this is the port where the described app starts being one | |
| 9 | + | //! app. | |
| 10 | + | //! | |
| 11 | + | //! # What the description deletes, and this is the largest single case yet | |
| 12 | + | //! | |
| 13 | + | //! **The search field measures the row it is in.** `draw_toolbar` keeps a | |
| 14 | + | //! `trailing_width` in egui memory, reads it at the start of the frame to size | |
| 15 | + | //! the field, measures what the trailing controls actually consumed at the end, | |
| 16 | + | //! writes it back if it moved by more than half a pixel, and requests a repaint | |
| 17 | + | //! so the correction lands. Two constants support it — a generous | |
| 18 | + | //! `DEFAULT_TRAILING_WIDTH` for the first frame and a `MIN_SEARCH_WIDTH` floor — | |
| 19 | + | //! and the whole apparatus exists to express one sentence: *the field takes | |
| 20 | + | //! whatever the controls after it do not need*. | |
| 21 | + | //! | |
| 22 | + | //! Twenty lines, two constants, a persistent id and a one-frame lag go, and the | |
| 23 | + | //! described row measures nothing: how a row of controls divides itself is the | |
| 24 | + | //! host's, resolved in its own layout pass where the numbers actually are. | |
| 25 | + | //! | |
| 26 | + | //! **The second finding is what the app cannot say instead.** `Width::Fill` | |
| 27 | + | //! exists — on a table [`Column`](quasi_router::Column) — and `Share` exists on | |
| 28 | + | //! a region, so the vocabulary already accepts that an app has opinions about | |
| 29 | + | //! which of several things expands. A [`Field`](quasi_router::Field) has no | |
| 30 | + | //! such member, so "the search box takes the room the buttons do not" is not | |
| 31 | + | //! sayable at all, and every renderer will size it by its own default. That is | |
| 32 | + | //! an inconsistency rather than a principle, and it is filed. | |
| 33 | + | //! | |
| 34 | + | //! **Two more pixel breakpoints.** `screen_w < 900.0` collapses six panel | |
| 35 | + | //! toggles into a View menu; `screen_w < 700.0` marks the detail panel as | |
| 36 | + | //! present-but-hidden. Same class as the footer's `1000.0`, and the same answer: | |
| 37 | + | //! the description says what the controls are, and how many fit is the host's. | |
| 38 | + | //! What is *not* renderer policy is the detail toggle's muted state, which says | |
| 39 | + | //! "this is on but you cannot see it" — that is a fact about a window, so it is | |
| 40 | + | //! not described here either, for the opposite reason. | |
| 41 | + | //! | |
| 42 | + | //! # THE FINDING: a field cannot say that firing it is expensive | |
| 43 | + | //! | |
| 44 | + | //! [`Field::changes`](quasi_router::Field::changes) names an address to call | |
| 45 | + | //! when a value changes and says nothing about how often. The shipped search box | |
| 46 | + | //! cannot afford per-keystroke, and its comment is explicit: "each keystroke | |
| 47 | + | //! would otherwise run a blocking DB query + re-sort on the GUI thread". So it | |
| 48 | + | //! carries a 150ms debounce, re-armed on change, with a `request_repaint_after` | |
| 49 | + | //! to make the trailing edge land without further input. | |
| 50 | + | //! | |
| 51 | + | //! A described search field has no way to say that. `changes` fires, and how | |
| 52 | + | //! often is the host's — which is right in the same way a fade timer is right, | |
| 53 | + | //! and incomplete in a way a fade timer is not: getting a fade wrong is ugly and | |
| 54 | + | //! getting this wrong is a blocking query per keystroke. Every renderer will | |
| 55 | + | //! either invent its own interval, in which case they disagree, or fire eagerly, | |
| 56 | + | //! in which case the webview host sends one request per character over the wire. | |
| 57 | + | //! | |
| 58 | + | //! Note what is *not* being asked for: not a number. "150ms" is a host's | |
| 59 | + | //! judgment about its own input latency, the same kind of thing | |
| 60 | + | //! `Message::undo`'s header refuses to carry. What is missing is the app's half | |
| 61 | + | //! — *this write is expensive, settle before firing it* — which the host then | |
| 62 | + | //! answers with an interval of its own choosing. Filed rather than invented. | |
| 63 | + | //! | |
| 64 | + | //! # What is deliberately not described | |
| 65 | + | //! | |
| 66 | + | //! - **The Import and Export menus.** Each is a popup with several choices, and | |
| 67 | + | //! both belong with the import flow, which is its own remaining pass. The | |
| 68 | + | //! toolbar's other four right-hand controls are single acts and are here. | |
| 69 | + | //! - **The theme selector.** Settings describes it already (`quasi/settings.rs`), | |
| 70 | + | //! and a second copy in the toolbar would be the drift this layer exists to | |
| 71 | + | //! end. The shipped toolbar has one because a menu was the convenient place | |
| 72 | + | //! for it. | |
| 73 | + | //! - **A search field as its own kind.** `FieldKind` has `Text`, `Email`, `Url`, | |
| 74 | + | //! `Tel` and ten more, and no `Search`. It is described as text, which is | |
| 75 | + | //! right about what is typed and loses the affordance a webview and a phone | |
| 76 | + | //! keyboard both have for it. One line rather than a finding: the fix is a | |
| 77 | + | //! member, and nothing else in this app wants it. | |
| 78 | + | //! - **Save-as-collection's popover.** It is described as an overlay, which is | |
| 79 | + | //! near enough and not exact: `Outcome::Over` is app-modal, and this is a | |
| 80 | + | //! popover anchored to the button that opened it. The difference is where a | |
| 81 | + | //! host draws it rather than what it holds, so no finding — but a vocabulary | |
| 82 | + | //! that grows anchoring should know this was the first place it mattered. | |
| 83 | + | ||
| 84 | + | use quasi_router::layout::{FieldKind, Selector, Tone}; | |
| 85 | + | use quasi_router::{ | |
| 86 | + | Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, | |
| 87 | + | Slot, | |
| 88 | + | }; | |
| 89 | + | ||
| 90 | + | use super::{Panel, Panels, Where}; | |
| 91 | + | ||
| 92 | + | /// The band above the list. | |
| 93 | + | const BAR: &str = "toolbar-bar"; | |
| 94 | + | ||
| 95 | + | /// What a search submits. | |
| 96 | + | const QUERY: &str = "query"; | |
| 97 | + | /// What the save-as-collection form submits. | |
| 98 | + | const NAME: &str = "name"; | |
| 99 | + | ||
| 100 | + | /// Register the toolbar's routes. | |
| 101 | + | /// | |
| 102 | + | /// Everything answers the whole main screen, for the sidebar's reason: searching | |
| 103 | + | /// and navigating change what the list holds, so the answer is the window. | |
| 104 | + | pub fn routes(router: Router<Panels<'_>>) -> Router<Panels<'_>> { | |
| 105 | + | router | |
| 106 | + | .post("/search", search) | |
| 107 | + | .post("/search/scope", scope) | |
| 108 | + | .post("/search/save", save) | |
| 109 | + | .get("/search/save", saving) | |
| 110 | + | .post("/undo", undo) | |
| 111 | + | .post("/panels/{panel}", toggle) | |
| 112 | + | .post("/here/root", root) | |
| 113 | + | .post("/here/{id}/{depth}", go) | |
| 114 | + | .post("/here/leave", leave) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// `POST /search` | |
| 118 | + | fn search(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 119 | + | let query = request.payload.get(QUERY).unwrap_or_default(); | |
| 120 | + | state.bar.search(query); | |
| 121 | + | Ok(super::shell::screen(state).into()) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// `POST /search/scope` | |
| 125 | + | fn scope(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 126 | + | let chosen = request.payload.get(Node::SELECTED).unwrap_or_default(); | |
| 127 | + | match chosen { | |
| 128 | + | "all" => state.bar.set_scope(true), | |
| 129 | + | "folder" => state.bar.set_scope(false), | |
| 130 | + | _ => return Err(RouteError::not_found("no such scope")), | |
| 131 | + | } | |
| 132 | + | Ok(super::shell::screen(state).into()) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | /// `GET /search/save` | |
| 136 | + | /// | |
| 137 | + | /// The name is offered already filled in, which is the shipped popup's own | |
| 138 | + | /// behaviour: `SearchFilter::describe` writes a sentence out of the active | |
| 139 | + | /// filters, so the common case is pressing Save twice. | |
| 140 | + | fn saving(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 141 | + | let searching = state.bar.searching(); | |
| 142 | + | if !searching.filtered { | |
| 143 | + | return Err(RouteError::not_found("nothing is filtered")); | |
| 144 | + | } | |
| 145 | + | Ok(Response::over( | |
| 146 | + | Screen::sidebar_content("Save as collection").with( | |
| 147 | + | Slot::new("save-collection", RegionKind::Pane) | |
| 148 | + | .with(Node::page("Save as collection")) | |
| 149 | + | .with(Node::text( | |
| 150 | + | "A dynamic collection re-applies these filters, so it updates itself as samples match.", | |
| 151 | + | )) | |
| 152 | + | .with(Node::Form { | |
| 153 | + | fields: vec![ | |
| 154 | + | Field::new(FieldKind::Text, NAME, "Name") | |
| 155 | + | .required() | |
| 156 | + | .value(searching.describes) | |
| 157 | + | .hint("e.g. Kicks Under 120 BPM"), | |
| 158 | + | ], | |
| 159 | + | submit: "Save collection".to_owned(), | |
| 160 | + | action: Action::post("/search/save"), | |
| 161 | + | }), | |
| 162 | + | ), | |
| 163 | + | )) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | /// `POST /search/save` | |
| 167 | + | fn save(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 168 | + | let name = request.payload.get(NAME).unwrap_or_default().trim(); | |
| 169 | + | if name.is_empty() { | |
| 170 | + | return Err(RouteError::not_found("a collection needs a name")); | |
| 171 | + | } | |
| 172 | + | state.bar.save_collection(name); | |
| 173 | + | Ok(super::shell::screen(state).into()) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// `POST /undo` | |
| 177 | + | /// | |
| 178 | + | /// Refused where there is nothing to undo, which is what the shipped button is | |
| 179 | + | /// disabled on. | |
| 180 | + | /// | |
| 181 | + | /// Not [`Message::undo`](quasi_router::Message), which is the transient offer | |
| 182 | + | /// that comes with a toast and expires. This is a standing capability over the | |
| 183 | + | /// app's own bulk-operation stack, so it is a control on the screen rather than | |
| 184 | + | /// a rider on a notice. | |
| 185 | + | fn undo(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 186 | + | if !state.bar.undoable() { | |
| 187 | + | return Err(RouteError::not_found("there is nothing to undo")); | |
| 188 | + | } | |
| 189 | + | state.bar.undo(); | |
| 190 | + | Ok(super::shell::screen(state).into()) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// `POST /panels/{panel}` | |
| 194 | + | fn toggle(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 195 | + | let named = request.captures.require("panel")?; | |
| 196 | + | let panel = Panel::from_key(named).ok_or_else(|| RouteError::not_found("no such panel"))?; | |
| 197 | + | state.bar.toggle(panel); | |
| 198 | + | Ok(super::shell::screen(state).into()) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// `POST /here/root` | |
| 202 | + | fn root(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 203 | + | state.bar.go_root(); | |
| 204 | + | Ok(super::shell::screen(state).into()) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// `POST /here/{id}/{depth}` | |
| 208 | + | /// | |
| 209 | + | /// The depth rides with the id because navigating to a crumb also truncates the | |
| 210 | + | /// trail behind it, and how far along a crumb sits is a fact about *this* trail | |
| 211 | + | /// rather than about the folder. A folder reached two ways has one id and two | |
| 212 | + | /// depths. | |
| 213 | + | fn go(state: &Panels<'_>, request: Request) -> Result<Response, RouteError> { | |
| 214 | + | let id: i64 = request | |
| 215 | + | .captures | |
| 216 | + | .require("id")? | |
| 217 | + | .parse() | |
| 218 | + | .map_err(|_| RouteError::not_found("no such folder"))?; | |
| 219 | + | let depth: usize = request | |
| 220 | + | .captures | |
| 221 | + | .require("depth")? | |
| 222 | + | .parse() | |
| 223 | + | .map_err(|_| RouteError::not_found("no such place in the trail"))?; | |
| 224 | + | state.bar.go_to(id, depth); | |
| 225 | + | Ok(super::shell::screen(state).into()) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// `POST /here/leave` | |
| 229 | + | fn leave(state: &Panels<'_>, _request: Request) -> Result<Response, RouteError> { | |
| 230 | + | state.bar.leave(); | |
| 231 | + | Ok(super::shell::screen(state).into()) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// The toolbar, as a region something else holds. | |
| 235 | + | pub fn body(state: &Panels<'_>) -> Slot { | |
| 236 | + | let bar = Slot::new(BAR, RegionKind::Band); | |
| 237 | + | let bar = here(bar, state); | |
| 238 | + | let bar = looking(bar, state); | |
| 239 | + | panels(bar, state) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | /// Where you are, which is one of three things. | |
| 243 | + | /// | |
| 244 | + | /// A trail of [`Node::Link`]s rather than acts. Both call a route; the | |
| 245 | + | /// difference is what the reader sees, and `Link`'s own header has the argument: | |
| 246 | + | /// "making every linked value a button would put a row of bevels down the first | |
| 247 | + | /// column of half a dashboard". A breadcrumb is the case that argument was | |
| 248 | + | /// written for. | |
| 249 | + | fn here(bar: Slot, state: &Panels<'_>) -> Slot { | |
| 250 | + | match state.bar.place() { | |
| 251 | + | Where::Folder { trail } => { | |
| 252 | + | let mut bar = bar.with(Node::Link { | |
| 253 | + | text: "/".to_owned(), | |
| 254 | + | action: Action::post("/here/root"), | |
| 255 | + | }); | |
| 256 | + | for (depth, crumb) in trail.iter().enumerate() { | |
| 257 | + | // The last crumb is where you are, so it goes nowhere. Said as | |
| 258 | + | // prose rather than as a link that does nothing, which is the | |
| 259 | + | // shipped row's `selectable_label(is_last, ..)` made explicit. | |
| 260 | + | if depth + 1 == trail.len() { | |
| 261 | + | bar = bar.with(Node::Text { | |
| 262 | + | text: crumb.name.clone(), | |
| 263 | + | tone: Tone::Info, | |
| 264 | + | }); | |
| 265 | + | } else { | |
| 266 | + | bar = bar.with(Node::Link { | |
| 267 | + | text: crumb.name.clone(), | |
| 268 | + | action: Action::post(format!("/here/{}/{}", crumb.id, depth + 1)), | |
| 269 | + | }); | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | bar | |
| 273 | + | } | |
| 274 | + | // A mode rather than a place, so the way out is a control and not a | |
| 275 | + | // shorter path. The shipped breadcrumb puts Clear in the same segment | |
| 276 | + | // as the label for exactly this reason: "the mode label and the exit | |
| 277 | + | // affordance occupy one row, not two". | |
| 278 | + | Where::Collection { name } => { | |
| 279 | + | leaving(bar, format!("Collection: {name}"), "Back to browsing") | |
| 280 | + | } | |
| 281 | + | Where::Similar { name } => leaving(bar, format!("Similar to: {name}"), "Back to browsing") | |
| 282 | + | .with(Node::text( | |
| 283 | + | "Results are ranked by similarity, so column sort is off.", | |
| 284 | + | )), | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | /// A mode you are in, and the way out of it. | |
| 289 | + | fn leaving(bar: Slot, says: String, out: &str) -> Slot { | |
| 290 | + | bar.with(Node::Text { | |
| 291 | + | text: says, | |
| 292 | + | tone: Tone::Info, | |
| 293 | + | }) | |
| 294 | + | .with(Node::Act(Act::new(out, Action::post("/here/leave")))) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | /// What you are looking for. | |
| 298 | + | fn looking(bar: Slot, state: &Panels<'_>) -> Slot { | |
| 299 | + | let searching = state.bar.searching(); | |
| 300 | + | ||
| 301 | + | let mut bar = bar | |
| 302 | + | .with(Node::Field(Box::new( | |
| 303 | + | Field::new(FieldKind::Text, QUERY, "Search") | |
| 304 | + | .value(&searching.query) | |
| 305 | + | .hint("Search samples...") | |
| 306 | + | .changes(Action::post("/search")), | |
| 307 | + | ))) | |
| 308 | + | .with(Node::Select { | |
| 309 | + | kind: Selector::Segmented, | |
| 310 | + | options: vec![ | |
| 311 | + | (Choice::new("folder", "This folder"), None), | |
| 312 | + | (Choice::new("all", "Everywhere"), None), | |
| 313 | + | ], | |
| 314 | + | chosen: Some( | |
| 315 | + | if searching.everywhere { | |
| 316 | + | "all" | |
| 317 | + | } else { | |
| 318 | + | "folder" | |
| 319 | + | } | |
| 320 | + | .to_owned(), | |
| 321 | + | ), | |
| 322 | + | action: Some(Action::post("/search/scope")), | |
| 323 | + | }); | |
| 324 | + | ||
| 325 | + | if searching.filtered { | |
| 326 | + | bar = bar | |
| 327 | + | .with(Node::Figure(quasi_router::Figure::new( | |
| 328 | + | searching.results.to_string(), | |
| 329 | + | "results", | |
| 330 | + | ))) | |
| 331 | + | .with(Node::Act(Act::new( | |
| 332 | + | "Save as collection", | |
| 333 | + | Action::get("/search/save"), | |
| 334 | + | ))); | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | let mut undo = Act::new("Undo", Action::post("/undo")).key("ctrl+z"); | |
| 338 | + | if !state.bar.undoable() { | |
| 339 | + | undo = undo.disabled(); | |
| 340 | + | } | |
| 341 | + | bar.with(Node::Act(undo)) | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | /// What is showing, and the places the toolbar goes. | |
| 345 | + | /// | |
| 346 | + | /// The six toggles are latching, which is what a panel that is open or shut is, | |
| 347 | + | /// and the last three controls are addresses this router already serves. That is | |
| 348 | + | /// the toolbar's own claim: Settings, Cloud Sync and Help are screens, so | |
| 349 | + | /// reaching them is navigation rather than something the host arranges. | |
| 350 | + | fn panels(bar: Slot, state: &Panels<'_>) -> Slot { | |
| 351 | + | use quasi_router::Tag; | |
| 352 | + | use quasi_router::layout::Token; | |
| 353 | + | ||
| 354 | + | let showing = state.bar.showing(); | |
| 355 | + | let mut bar = bar; | |
| 356 | + | ||
| 357 | + | for panel in Panel::ALL { | |
| 358 | + | let on = showing.contains(&panel); | |
| 359 | + | let mut chip = Tag { | |
| 360 | + | kind: Token::Chip { removable: false }, | |
| 361 | + | label: panel.label().to_owned(), | |
| 362 | + | tone: if on { Tone::Info } else { Tone::Neutral }, | |
| 363 | + | latched: on, | |
| 364 | + | action: Some(Action::post(format!("/panels/{}", panel.as_str()))), | |
| 365 | + | }; | |
| 366 | + | // How many filters are on, on the chip that toggles the filter panel. | |
| 367 | + | // The shipped toggle takes an `Option<usize>` badge for this and nothing | |
| 368 | + | // else does, so it is the one toggle carrying a count. | |
| 369 | + | if panel == Panel::Filters && state.bar.searching().filters > 0 { | |
| 370 | + | chip.label = format!("{} ({})", chip.label, state.bar.searching().filters); | |
| 371 | + | } | |
| 372 | + | bar = bar.with(Node::Token(chip)); | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | bar.with(Node::Act(Act::new("Settings", Action::get("/settings")))) | |
| 376 | + | .with(Node::Act(Act::new("Cloud Sync", Action::get("/sync")))) | |
| 377 | + | .with(Node::Act(Act::new("Help", Action::get("/help")).key("f1"))) | |
| 378 | + | } |