//! The toolbar, described: where you are, what you are looking for, and what is //! showing. //! //! The tenth port, and the last region of the main window. It is also the first //! screen that *navigates to the others*: Settings, Cloud Sync and Help are //! described already, so the toolbar's buttons for them are ordinary addresses //! rather than intents. Up to here every described window was reached by the //! host opening it; this is the port where the described app starts being one //! app. //! //! # What the description deletes, and this is the largest single case yet //! //! **The search field measures the row it is in.** `draw_toolbar` keeps a //! `trailing_width` in egui memory, reads it at the start of the frame to size //! the field, measures what the trailing controls actually consumed at the end, //! writes it back if it moved by more than half a pixel, and requests a repaint //! so the correction lands. Two constants support it — a generous //! `DEFAULT_TRAILING_WIDTH` for the first frame and a `MIN_SEARCH_WIDTH` floor — //! and the whole apparatus exists to express one sentence: *the field takes //! whatever the controls after it do not need*. //! //! Twenty lines, two constants, a persistent id and a one-frame lag go, and the //! described row measures nothing: how a row of controls divides itself is the //! host's, resolved in its own layout pass where the numbers actually are. //! //! **And it is now sayable.** `Width::Fill` existed on a table //! [`Column`](quasi_router::Column) and `Share` on a region, so the vocabulary //! already accepted that an app has opinions about which of several things //! expands; a leaf control having no way to say it was an inconsistency rather //! than a principle. Filed as `6d6a9160`, settled by Max the same day — *fill is //! determined at the description stage* — and landed as //! [`Field::width`](quasi_router::Field::width) in quasi 0.17.0. The search box //! below says `Width::Fill` and the twenty lines are gone. //! //! **Two more pixel breakpoints.** `screen_w < 900.0` collapses six panel //! toggles into a View menu; `screen_w < 700.0` marks the detail panel as //! present-but-hidden. Same class as the footer's `1000.0`, and the same answer: //! the description says what the controls are, and how many fit is the host's. //! What is *not* renderer policy is the detail toggle's muted state, which says //! "this is on but you cannot see it" — that is a fact about a window, so it is //! not described here either, for the opposite reason. //! //! # THE FINDING: a field cannot say that firing it is expensive //! //! [`Field::changes`](quasi_router::Field::changes) names an address to call //! when a value changes and says nothing about how often. The shipped search box //! cannot afford per-keystroke, and its comment is explicit: "each keystroke //! would otherwise run a blocking DB query + re-sort on the GUI thread". So it //! carries a 150ms debounce, re-armed on change, with a `request_repaint_after` //! to make the trailing edge land without further input. //! //! A described search field has no way to say that. `changes` fires, and how //! often is the host's — which is right in the same way a fade timer is right, //! and incomplete in a way a fade timer is not: getting a fade wrong is ugly and //! getting this wrong is a blocking query per keystroke. Every renderer will //! either invent its own interval, in which case they disagree, or fire eagerly, //! in which case the webview host sends one request per character over the wire. //! //! Note what is *not* being asked for: not a number. "150ms" is a host's //! judgment about its own input latency, the same kind of thing //! `Message::undo`'s header refuses to carry. What is missing is the app's half //! — *this write is expensive, settle before firing it* — which the host then //! answers with an interval of its own choosing. Filed rather than invented. //! //! # What is deliberately not described //! //! - ~~**The Import and Export menus.**~~ Described as of the import flow's //! pass, which is where they said they belonged. Import is an overlay //! ([`importing::open`](super::importing)) because the shipped control is a //! popup of three choices; Export is a single act, because the shipped control //! is a single button. Both are doors into a flow rather than controls of the //! toolbar's own, which is why neither answers a screen here. //! - **The theme selector.** Settings describes it already (`quasi/settings.rs`), //! and a second copy in the toolbar would be the drift this layer exists to //! end. The shipped toolbar has one because a menu was the convenient place //! for it. //! - **A search field as its own kind.** `FieldKind` has `Text`, `Email`, `Url`, //! `Tel` and ten more, and no `Search`. It is described as text, which is //! right about what is typed and loses the affordance a webview and a phone //! keyboard both have for it. One line rather than a finding: the fix is a //! member, and nothing else in this app wants it. //! - **Save-as-collection's popover.** It is described as an overlay, which is //! near enough and not exact: `Outcome::Over` is app-modal, and this is a //! popover anchored to the button that opened it. The difference is where a //! host draws it rather than what it holds, so no finding — but a vocabulary //! that grows anchoring should know this was the first place it mattered. use quasi_router::layout::{FieldKind, Priority, Selector, Tone, Width}; use quasi_router::{ Act, Action, Choice, Field, Node, RegionKind, Request, Response, RouteError, Router, Screen, Slot, }; use super::{Panel, Panels, Where}; /// The band above the list. const BAR: &str = "toolbar-bar"; /// What a search submits. const QUERY: &str = "query"; /// What the save-as-collection form submits. const NAME: &str = "name"; /// Register the toolbar's routes. /// /// Everything answers the whole main screen, for the sidebar's reason: searching /// and navigating change what the list holds, so the answer is the window. pub fn routes(router: Router>) -> Router> { router .post("/search", search) .post("/search/scope", scope) .post("/search/save", save) .get("/search/save", saving) .post("/undo", undo) .post("/panels/{panel}", toggle) .post("/here/root", root) .post("/here/{id}/{depth}", go) .post("/here/leave", leave) } /// `POST /search` fn search(state: &Panels<'_>, request: Request) -> Result { let query = request.payload.get(QUERY).unwrap_or_default(); state.bar.search(query); Ok(super::shell::screen(state).into()) } /// `POST /search/scope` fn scope(state: &Panels<'_>, request: Request) -> Result { let chosen = request.payload.get(Node::SELECTED).unwrap_or_default(); match chosen { "all" => state.bar.set_scope(true), "folder" => state.bar.set_scope(false), _ => return Err(RouteError::not_found("no such scope")), } Ok(super::shell::screen(state).into()) } /// `GET /search/save` /// /// The name is offered already filled in, which is the shipped popup's own /// behaviour: `SearchFilter::describe` writes a sentence out of the active /// filters, so the common case is pressing Save twice. fn saving(state: &Panels<'_>, _request: Request) -> Result { let searching = state.bar.searching(); if !searching.filtered { return Err(RouteError::not_found("nothing is filtered")); } Ok(Response::over( Screen::sidebar_content("Save as collection").with( Slot::new("save-collection", RegionKind::Pane) .with(Node::page("Save as collection")) .with(Node::text( "A dynamic collection re-applies these filters, so it updates itself as samples match.", )) .with(Node::Form { fields: vec![ Field::new(FieldKind::Text, NAME, "Name") .required() .value(searching.describes) .hint("e.g. Kicks Under 120 BPM"), ], submit: "Save collection".to_owned(), action: Action::post("/search/save"), }), ), )) } /// `POST /search/save` fn save(state: &Panels<'_>, request: Request) -> Result { let name = request.payload.get(NAME).unwrap_or_default().trim(); if name.is_empty() { return Err(RouteError::not_found("a collection needs a name")); } state.bar.save_collection(name); Ok(super::shell::screen(state).into()) } /// `POST /undo` /// /// Refused where there is nothing to undo, which is what the shipped button is /// disabled on. /// /// Not [`Message::undo`](quasi_router::Message), which is the transient offer /// that comes with a toast and expires. This is a standing capability over the /// app's own bulk-operation stack, so it is a control on the screen rather than /// a rider on a notice. fn undo(state: &Panels<'_>, _request: Request) -> Result { if !state.bar.undoable() { return Err(RouteError::not_found("there is nothing to undo")); } state.bar.undo(); Ok(super::shell::screen(state).into()) } /// `POST /panels/{panel}` fn toggle(state: &Panels<'_>, request: Request) -> Result { let named = request.captures.require("panel")?; let panel = Panel::from_key(named).ok_or_else(|| RouteError::not_found("no such panel"))?; state.bar.toggle(panel); Ok(super::shell::screen(state).into()) } /// `POST /here/root` fn root(state: &Panels<'_>, _request: Request) -> Result { state.bar.go_root(); Ok(super::shell::screen(state).into()) } /// `POST /here/{id}/{depth}` /// /// The depth rides with the id because navigating to a crumb also truncates the /// trail behind it, and how far along a crumb sits is a fact about *this* trail /// rather than about the folder. A folder reached two ways has one id and two /// depths. fn go(state: &Panels<'_>, request: Request) -> Result { let id: i64 = request .captures .require("id")? .parse() .map_err(|_| RouteError::not_found("no such folder"))?; let depth: usize = request .captures .require("depth")? .parse() .map_err(|_| RouteError::not_found("no such place in the trail"))?; state.bar.go_to(id, depth); Ok(super::shell::screen(state).into()) } /// `POST /here/leave` fn leave(state: &Panels<'_>, _request: Request) -> Result { state.bar.leave(); Ok(super::shell::screen(state).into()) } /// The toolbar, as a region something else holds. pub fn body(state: &Panels<'_>) -> Slot { let bar = Slot::new(BAR, RegionKind::Band); let bar = here(bar, state); let bar = looking(bar, state); panels(bar, state) } /// Where you are, which is one of three things. /// /// A trail of [`Node::Link`]s rather than acts. Both call a route; the /// difference is what the reader sees, and `Link`'s own header has the argument: /// "making every linked value a button would put a row of bevels down the first /// column of half a dashboard". A breadcrumb is the case that argument was /// written for. fn here(bar: Slot, state: &Panels<'_>) -> Slot { match state.bar.place() { Where::Folder { trail } => { let mut bar = bar.with(Node::Link { text: "/".to_owned(), action: Action::post("/here/root"), }); for (depth, crumb) in trail.iter().enumerate() { // The last crumb is where you are, so it goes nowhere. Said as // prose rather than as a link that does nothing, which is the // shipped row's `selectable_label(is_last, ..)` made explicit. if depth + 1 == trail.len() { bar = bar.with(Node::Text { text: crumb.name.clone(), tone: Tone::Info, }); } else { bar = bar.with(Node::Link { text: crumb.name.clone(), action: Action::post(format!("/here/{}/{}", crumb.id, depth + 1)), }); } } bar } // A mode rather than a place, so the way out is a control and not a // shorter path. The shipped breadcrumb puts Clear in the same segment // as the label for exactly this reason: "the mode label and the exit // affordance occupy one row, not two". Where::Collection { name } => { leaving(bar, format!("Collection: {name}"), "Back to browsing") } Where::Similar { name } => leaving(bar, format!("Similar to: {name}"), "Back to browsing") .with(Node::text( "Results are ranked by similarity, so column sort is off.", )), } } /// A mode you are in, and the way out of it. fn leaving(bar: Slot, says: String, out: &str) -> Slot { bar.with(Node::Text { text: says, tone: Tone::Info, }) .with(Node::Act(Act::new(out, Action::post("/here/leave")))) } /// What you are looking for. fn looking(bar: Slot, state: &Panels<'_>) -> Slot { let searching = state.bar.searching(); let mut bar = bar .with(Node::Field(Box::new( Field::new(FieldKind::Text, QUERY, "Search") .value(&searching.query) .hint("Search samples...") // What `trailing_width` was measuring for, said instead of // measured. quasi 0.17.0, settled by Max: fill is determined at // the description stage. It is the default, so this line changes // no pixel -- and it is the difference between a row that // happens to look right and one that says what it means. .width(Width::Fill) .changes(Action::post("/search")), ))) .with(Node::Select { kind: Selector::Segmented, options: vec![ (Choice::new("folder", "This folder"), None), (Choice::new("all", "Everywhere"), None), ], chosen: Some( if searching.everywhere { "all" } else { "folder" } .to_owned(), ), action: Some(Action::post("/search/scope")), }); if searching.filtered { bar = bar .with(Node::Figure(quasi_router::Figure::new( searching.results.to_string(), "results", ))) .with(Node::Act(Act::new( "Save as collection", Action::get("/search/save"), ))); } let mut undo = Act::new("Undo", Action::post("/undo")).key("ctrl+z"); if !state.bar.undoable() { undo = undo.disabled(); } bar.with(Node::Act(undo)) } /// What is showing, and the places the toolbar goes. /// /// The six toggles are latching, which is what a panel that is open or shut is, /// and the last three controls are addresses this router already serves. That is /// the toolbar's own claim: Settings, Cloud Sync and Help are screens, so /// reaching them is navigation rather than something the host arranges. fn panels(bar: Slot, state: &Panels<'_>) -> Slot { use quasi_router::Tag; use quasi_router::layout::Token; let showing = state.bar.showing(); let mut bar = bar; for panel in Panel::ALL { let on = showing.contains(&panel); let mut chip = Tag { kind: Token::Chip { removable: false }, label: panel.label().to_owned(), tone: if on { Tone::Info } else { Tone::Neutral }, latched: on, action: Some(Action::post(format!("/panels/{}", panel.as_str()))), }; // How many filters are on, on the chip that toggles the filter panel. // The shipped toggle takes an `Option` badge for this and nothing // else does, so it is the one toggle carrying a count. if panel == Panel::Filters && state.bar.searching().filters > 0 { chip.label = format!("{} ({})", chip.label, state.bar.searching().filters); } bar = bar.with_ranked(Node::Token(chip), panel.worth()); } // Import holds at Essential, alone among the right-hand controls, and it is // the shipped bar's own judgment: it bolds the label when the library is // empty because it is the one action that does anything then. A control that // is the only way to have any content is not one a narrow window drops. let bar = bar .with(Node::Act(Act::new("Import", Action::get("/import/open")))) .with_ranked( Node::Act(Act::new("Export", Action::post("/export/begin"))), Priority::Secondary, ); // Settings and Cloud Sync are how you reach two whole screens and have no // other route in, so they hold at Secondary. Help drops first because it // is the one control here that keeps working when it is not on the screen: // it carries `f1`, and a key is a route a narrow window cannot take away. bar.with_ranked( Node::Act(Act::new("Settings", Action::get("/settings"))), Priority::Secondary, ) .with_ranked( Node::Act(Act::new("Cloud Sync", Action::get("/sync"))), Priority::Secondary, ) .with_ranked( Node::Act(Act::new("Help", Action::get("/help")).key("f1")), Priority::Optional, ) }