Skip to main content

max / audiofiles

Describe the toolbar, and let it reach the screens the other ports built The tenth port and the last region of the main window. It is also the first described screen that navigates to the others: Settings, Cloud Sync and Help are addresses this router already serves, so the toolbar's buttons for them are ordinary acts rather than intents. Up to here every described window was opened by the host; this is where the described app starts being one app. The largest single deletion yet. draw_toolbar keeps a trailing_width in egui memory, reads it to size the search field, measures what the controls after it consumed, writes it back if it moved by more than half a pixel, and requests a repaint so the correction lands -- twenty lines, two constants, a persistent id and a one-frame lag, all to express "the field takes whatever the controls after it do not need". Plus two more pixel breakpoints, 900 for collapsing six toggles into a View menu and 700 for marking the detail panel hidden. The described row measures nothing. Two findings, and the second is the other half of that deletion. A field cannot say that firing it is expensive. Field::changes names an address and says nothing about how often; the shipped search box carries a 150ms debounce because "each keystroke would otherwise run a blocking DB query + re-sort on the GUI thread". Not asking for a number -- 150ms is a host's judgment about its own latency -- but for the app's half, which the host answers with an interval of its own choosing. And a field cannot say it should take the remaining room. Width::Fill exists on a table Column and Share exists on a region, so the vocabulary already accepts that an app has opinions about which of several things expands. A Field has none, so every renderer sizes the search box by its own default. Smaller things the vocabulary suggested. The breadcrumb is Node::Link rather than acts, which is that member's own argument about not putting a row of bevels down a dashboard; the crumb you are standing on goes nowhere instead of being a link that does nothing. The two modes -- a collection, a similarity search -- carry a way out rather than a shorter path, and share one control because leaving either means the same thing. The six panel toggles latch, because a panel is open or shut. Not described: the Import and Export menus, which belong with the import flow; the theme selector, which Settings already describes and a second copy of which would be the drift this layer exists to end. 14 tests, 486 passing with the feature on. The default build is untouched.
Author: Max Johnson <me@maxj.phd> · 2026-08-16 21:03 UTC
Signed with PGP, not checked
Commit: 90b6d68f86fcdcc77495e2c4db6e97249f3b448e
Parent: 5d8fc1c
7 files changed, +1127 insertions, -25 deletions
M Cargo.lock +12 -12
@@ -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 + }