//! What the app offers on every screen, rather than on one of them. //! //! A [`Screen`](crate::Screen) names one place. Everything here outlives any one //! of them: a command palette reachable from anywhere, a global shortcut, the //! help overlay that lists the shortcuts. None of that is a fact about the //! screen the user happens to be on, and describing it per screen means //! repeating it on every screen or reimplementing it per host. //! //! # Why this is not [`RegionKind::Modal`](crate::RegionKind::Modal) //! //! That is a modal a screen *contains*, which is how a confirmation is drawn: //! the screen carries it, and it goes when the screen goes. Chrome belongs to //! the app, so it is reachable from screens that know nothing about it. The //! same move [`Screen::notices`](crate::Screen::notices) made one level down, //! when a notice stopped belonging to a region and started belonging to the //! screen. //! //! # What it costs, and what it does not //! //! An overlay's *contents* were always sayable: a query, a result list, a //! keyboard walk through it, an [`Act`](crate::Act) that navigates. What was //! missing was a way to say "fetched from a route, and drawn over what is under //! it", which is [`Outcome::Over`](crate::Outcome::Over) and not a second //! description tree. So an overlay is a [`Screen`](crate::Screen) like any //! other, and this module is only the way in. //! //! A toast stack needs nothing from here: [`Screen::notices`](crate::Screen::notices) //! and [`Message`](crate::Message) already carry notices, and how they stack is //! renderer policy. An app-modal is the overlay case with one region in it. //! //! # A panel is chrome too, and is not a region every screen repeats //! //! Described per screen it is repeated on every screen, and the screen that //! forgets it drops the panel, which is the repetition this module exists to //! end. So [`Chrome::panel`] holds it. //! //! Its contents are an ordinary [`Node`](crate::Node), the way an overlay's //! contents are an ordinary [`Screen`](crate::Screen): a panel with several //! things in it is a [`Node::Region`](crate::Node::Region), and there is no //! second description tree here either. //! //! Where it sits is the renderer's. A description saying "bottom right, //! floating" would be naming one host's screen, and a terminal has no floating. //! Same call as `4453bf82`, where the clock a toast expires on turned out to be //! the renderer's. //! //! ## Dismissal is nobody's, because nothing dismisses one //! //! So there is no dismissed state to keep, in the description or in a //! renderer, and the panel's presence is the app's answer: declare none, or //! replace its contents through [`Chrome::replace`] the way every other region //! is replaced. //! //! Adding a dismissal later is additive. Inventing one now would be a member //! three renderers implement for no measured widget. //! //! # An app shell is several always-present things, and one of them is not a panel //! //! One panel was enough while the only measured consumer was a timer band. It //! stopped being enough the moment an app wanted a tab bar as well: goingson's //! shell is three tabs, a sub-nav under the chosen one, a sync indicator and //! the timer band, and the reason `presenting` replaced rather than appended //! was that a renderer handed two anonymous panels would place them by //! declaration order. //! //! The answer is two members rather than a longer list of one kind. //! //! [`Chrome::nav`] holds the places the app has. A tab bar is not content that //! happens to be always on screen: it is a set of addresses with names, which is //! why it is [`Place`] and not a [`Panel`] holding a list. A renderer draws it as //! a tab bar, a sidebar or a terminal's tab line, and it never has to be told //! which, because the description never said. //! //! [`Chrome::panels`] holds the rest, each carrying a [`Role`]. //! [`Role::Activity`] is something the app is doing right now and //! [`Role::Status`] is a standing readout of its condition. The role says what a //! panel is *for* and still not where it goes. There is no `Role::Navigation`, //! because navigation is not a panel. //! //! ## Which place is current is the screen's to say //! //! Chrome is built once, so a `current` flag on a [`Place`] would be frozen at //! build time and could never point at where the user is. The screen names its //! own place instead ([`Screen::place`](crate::Screen::place)) and the renderer //! marks the [`Place`] whose [`key`](Place::key) matches. Exactly the move //! [`Row::current`](crate::screen::Row::current) makes one level down: the app's //! own pointer at what is showing, said by the thing that knows. //! //! The alternative was the renderer comparing a place's address to the request //! path, which needs the path in [`Serves`](crate::Screen) and gets fuzzy the //! first time a screen carries its view on the address. goingson's Timer is //! `/timer?work=25&days=7` and its place is `/timer`. use crate::screen::{Action, Field, Node}; /// The affordances the app offers from every screen. /// /// Built once by the app and held beside the [`Router`](crate::Router), never /// per request. That is what "outlives any one screen" means concretely: a /// request answers with a screen, and this is not part of that answer. /// /// Beside the router rather than inside it, decided while building this: a /// `Router` is a route table, and a key binding is not a route. The two are /// held together by whatever the host is, which already holds both. Nothing /// here would break if it moved inside, so this is a tidiness argument rather /// than a correctness one. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Chrome { /// The keys that work from anywhere. pub bindings: Vec, /// The places the app has, in the order it offers them. /// /// One level of nesting is what [`Place::within`] is for, and it is what /// the measured consumer has. A renderer that cannot draw a second level /// flattens or ignores it, the same rule [`Binding::key`] states for a key /// one host names and another has never heard of. pub nav: Vec, /// What is on the screen whatever screen is showing. /// /// Empty is the app that declares none, and a renderer with nothing here /// draws nothing extra. /// /// Plural, and each carries a [`Role`]. Two anonymous panels would be a /// renderer placing them by declaration order, which is the guess this was /// a single slot to avoid; the role is what makes the second one sayable /// instead. pub panels: Vec, /// The header band the app puts above every screen, if it has one. /// /// [`nav`](Self::nav) is the places and nothing else, and a real header is /// a brand mark, a search box and those places sitting together. Described /// as three separate things they are three elements a renderer places by /// declaration order, and in a browser they are also three elements a /// stylesheet cannot make into one bar: MNW's narrow-viewport menu is a /// checkbox styling its siblings, and siblings that are not siblings match /// nothing. /// /// So the band is what says they are one thing. The nav does not move into /// it -- an app with places and no band still has places -- and a renderer /// draws [`nav`](Self::nav) *inside* the band when there is one and on its /// own when there is not. See [`Band`]. /// /// `None` is every app that has never had a header, and it draws exactly /// what it drew before this member existed. pub band: Option, } /// The header the app puts above every screen. /// /// Named slots rather than a `Vec` body, which is the shape decided in /// `93f999c3`. A band holding arbitrary content is a band a renderer cannot /// read: it could not tell a brand from a heading, so it could not draw the /// brand large on a phone and the nav behind a control, and every host would /// be back to being handed markup. Chrome is a fixed vocabulary for the same /// reason [`Role`] is a closed set. /// /// # The nav is not a member here /// /// It stays [`Chrome::nav`]. Moving it would mean an app with places and no /// band had nowhere to put them, and the two facts are independent: the places /// are what the app has, and the band is whether they are drawn in a bar with /// a wordmark. A renderer draws the nav inside the band when the app declared /// one. /// /// # Order is the renderer's, and it is the same order everywhere /// /// Brand, then the disclosure control, then search, then the nav. Not carried /// here as a sequence, because a band with a configurable order is a band the /// app is laying out; the reading order is the same on every host and each /// renderer writes it once. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Band { /// The mark the app is called by, if it shows one. pub brand: Option, /// The box the band offers for searching, if it offers one. /// /// A [`Field`], so a search box in the header is the same question a search /// box in a screen is, and no renderer grows a second field emitter for it. /// [`Field::writes`](crate::Field::writes) is what says where the query /// goes, which is the whole of what MNW's header form does today. /// /// This is the hole [`Panel`] could not fill: a panel is drawn after the /// screen, and a search box that lands under the content is not a header. pub search: Option, /// Whether the nav is out at all times or behind a control when there is /// no room for it. pub disclose: Disclose, } /// The mark an app is called by. /// /// A name plus one marked run inside it, which is what a wordmark is and what /// no member here could say before: MNW's is `Makenot.work` with the dot drawn /// as a graphic. An [`Image`](crate::Image) could not say it -- the mark is a /// character of the name rather than a picture beside it -- and a /// [`Node::Text`](crate::Node::Text) could not either, because nothing in it /// says which part is the mark. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Brand { /// The whole name, as it is read. /// /// Read whole, including the mark. `Makenot.work` is a domain and a reader /// hearing "Makenot dot work" has heard the name; hiding the mark from a /// screen reader would leave "Makenotwork", which is not what the app is /// called. pub name: String, /// The run inside [`name`](Self::name) drawn as the graphic mark. /// /// The first occurrence, and no more than one: a wordmark has one mark, /// and a rule that found every "." would mark both dots of a name that had /// two. A run this name does not contain marks nothing, which is /// [`Screen::place`](crate::Screen::place)'s bargain again -- the name is /// the app's and so is this. pub mark: Option, /// What pressing it calls. Home, for every app that has ever had one. pub action: Action, } /// Whether the band's nav is always out. /// /// The narrow-viewport question, named rather than hand-rolled. Every app that /// has a header has answered it, and every one of them answered it in its own /// stylesheet with its own checkbox, which is markup in the assembly layer /// doing what a description should have said. /// /// It says *whether*, never *how*. A checkbox and a label is one host's answer /// and a terminal has neither; what a description can honestly state is that /// the places are worth hiding when there is no room. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Disclose { /// The nav is out whatever the room. The default, and every app with a /// handful of places. #[default] Always, /// The nav goes behind a control when there is not room for it. /// /// A renderer with no notion of "not enough room" -- a terminal draws the /// width it was given -- ignores this and draws the places, which is the /// same degrading every renderer does with a key it has never heard of. Narrow, } impl Band { /// A band with nothing in it, which is the nav in a bar and no more. #[must_use] pub fn new() -> Self { Self::default() } /// Show this mark, chaining. #[must_use] pub fn branded(mut self, brand: Brand) -> Self { self.brand = Some(brand); self } /// Offer this box for searching, chaining. #[must_use] pub fn searching(mut self, field: Field) -> Self { self.search = Some(field); self } /// Whether the nav goes behind a control when there is no room, chaining. #[must_use] pub const fn disclosing(mut self, disclose: Disclose) -> Self { self.disclose = disclose; self } } impl Brand { /// The name the app is called by, and what pressing it calls. pub fn new(name: impl Into, action: Action) -> Self { Self { name: name.into(), mark: None, action, } } /// Draw this run of the name as the graphic mark, chaining. /// /// The first occurrence. See [`mark`](Self::mark). #[must_use] pub fn marking(mut self, mark: impl Into) -> Self { self.mark = Some(mark.into()); self } /// The name in three parts: before the mark, the mark, and after it. /// /// Answered here rather than in each renderer, so a webview, a terminal and /// an egui host cannot disagree about which run is marked. The whole name /// comes back as the first part when nothing is marked or when the run is /// not in the name, which is what makes a renderer's drawing one branch /// rather than three. #[must_use] pub fn parts(&self) -> (&str, &str, &str) { let Some(mark) = self.mark.as_deref().filter(|mark| !mark.is_empty()) else { return (&self.name, "", ""); }; match self.name.find(mark) { Some(at) => ( &self.name[..at], &self.name[at..at + mark.len()], &self.name[at + mark.len()..], ), None => (&self.name, "", ""), } } } /// A place the app has, and what going there calls. /// /// Not a [`Node`] holding a list of controls: a tab bar is a set of addresses /// with names, and saying so is what lets a webview draw tabs, a terminal draw /// a tab line and an egui host draw a toolbar without any of them being told /// which. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Place { /// What a screen names to say it is here. /// /// An identifier and not the label, because a label is display text: it is /// renamed, translated, and reworded to fit, and a pointer that broke when /// somebody improved the wording would be a pointer nobody trusts. pub key: String, /// What it is called. pub label: String, /// What going there calls. /// /// A place that only groups others still has one: pressing goingson's Work /// tab means its first sub-place, which is what the shipped tab does. A /// group with nowhere to go would be a control that does nothing. pub action: Action, /// The places inside this one. /// /// Empty for a flat nav. One level deep: goingson's pills under its tabs /// are the measured case, and a renderer meeting a third level flattens it /// rather than inventing a shape for it. pub within: Vec, } /// What a panel is for. /// /// A small closed set the renderers agree on, so that an app with two panels /// is placing them by what they are rather than by which was declared first. /// /// Still not where it goes. A stylesheet, a terminal's layout and an egui /// host's panel all read this and each answers the placement question its own /// way, which is the arrangement `4453bf82` settled for the clock a toast /// expires on. /// /// # Why there is no `Navigation` /// /// Navigation is [`Chrome::nav`], which is a set of addresses rather than /// content. A `Role::Navigation` panel would be an app hand-building a tab bar /// out of controls and every renderer drawing it as content, which is the thing /// the nav member exists to stop. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum Role { /// Something the app is doing right now, on screen while it lasts. /// /// goingson's running-timer band is the measured one: it is there while a /// timer runs and absent otherwise, and it is about a thing in progress /// rather than about the app's condition. #[default] Activity, /// A standing readout of the app's condition. /// /// goingson's sync indicator is the measured one: always present, saying /// how the app stands rather than what it is doing. Status, } /// Something the app keeps on screen, whatever screen the user is on. /// /// goingson's running-timer widget is the measured one: a task name, an elapsed /// readout, Stop and Discard, present on every screen while a timer runs. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Panel { /// The address a fresh answer aims at. /// /// A [`Slot::id`](crate::Slot) in everything but the slot: a route that has /// changed what the panel says names this in /// [`Response::also`](crate::Response::also) or answers a fragment aimed at /// it, and the renderers put the new contents in. Without an address the /// panel could only ever say what it said when the app was built, which for /// a timer is a readout that never moves. pub id: String, /// What it holds. /// /// A [`Node`], so a panel is described in the vocabulary every screen is /// described in. Several things in one panel is a /// [`Node::Region`](crate::Node::Region), which is what a screen's own /// grouping already is. pub content: Node, /// What it is for, which is how a renderer tells two of them apart. pub role: Role, } impl Place { /// A place, by the key a screen names it with and the name a person reads. pub fn new(key: impl Into, label: impl Into, action: Action) -> Self { Self { key: key.into(), label: label.into(), action, within: Vec::new(), } } /// The places inside this one. #[must_use] pub fn within(mut self, places: impl IntoIterator) -> Self { self.within.extend(places); self } /// This place or one inside it, under `key`. /// /// One level down and no further, which is the depth [`Self::within`] /// describes. A renderer marking the current place asks the nav rather than /// walking it itself, so the three of them cannot disagree about how deep /// the search goes. #[must_use] pub fn holds(&self, key: &str) -> bool { self.key == key || self.within.iter().any(|inner| inner.key == key) } } /// A key that works from every screen, and what it calls. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Binding { /// The key, as text. /// /// Text rather than a modelled chord — "ctrl+k", "?" — for the reason /// [`Act::key`](crate::Act::key) is: the vocabulary of keys is the host's, /// and a description that modelled it would be naming one host's keyboard. /// A renderer that does not know a name ignores it, which is what a webview /// does with a key a terminal wants. pub key: String, /// What a shortcuts list shows for it. /// /// The reason this is a struct and not a `(String, Action)` pair. A help /// overlay that lists the bindings is otherwise a second, hand-written copy /// of them, free to drift from what the keys actually do. pub label: String, /// What pressing it calls. /// /// Ordinarily a route answering with [`Response::over`](crate::Response::over), /// which is what makes the palette an overlay rather than a navigation. It /// is not required to: a binding that navigates is a binding that navigates. pub action: Action, /// The heading this binding sits under in a listing, if the app gave it one. /// /// A listing fact and nothing else: no renderer changes what a key *does* /// because of it. It exists because [`label`](Self::label)'s own argument /// runs out at length — a table that exists once is worth having, and a /// flat table of thirteen rows is a wall rather than a reference. /// audiofiles' shipped shortcuts tab sorts twenty-six rows into seven /// hand-written arrays, which is the evidence that somebody already /// thought so before this member existed. /// /// Said by the app, never derived. Sorting by key puts "Toggle the sidebar" /// next to "Show this help" because both start with a letter the app did /// not choose for that reason, and deriving a group from the address prefix /// is the same guess wearing a path: `/panels/sidebar` and `/forge` are both /// Toggles to a reader and are siblings in nothing. /// /// Free text rather than a modelled set, for [`key`](Self::key)'s reason. /// What the groups of an app are is the app's, and a vocabulary that /// enumerated them would be naming one app's help screen. /// /// [`Chrome::grouped`] is how a listing reads it, so the three renderers /// and every app that draws its own shortcuts table cannot disagree about /// what order the groups come in. pub group: Option, } impl Chrome { /// No chrome. What an app that declares none has. #[must_use] pub fn new() -> Self { Self::default() } /// Offer a place, with whatever places sit inside it. /// /// Appends, because a nav is a sequence and the order it is declared in is /// the order it is offered in. Nothing here refuses a repeated key: two /// places with one key is an app pointing at itself twice, and the renderer /// marking both is a truthful drawing of it. #[must_use] pub fn offering(mut self, place: Place) -> Self { self.nav.push(place); self } /// Put a header band above every screen. /// /// Replaces rather than adds, because an app has one header. See /// [`band`](Self::band). #[must_use] pub fn banded(mut self, band: Band) -> Self { self.band = Some(band); self } /// Add a key that works from every screen. #[must_use] pub fn bind( mut self, key: impl Into, label: impl Into, action: Action, ) -> Self { self.bindings.push(Binding { key: key.into(), label: label.into(), action, group: None, }); self } /// Add a key that works from every screen, under the heading a listing /// shows it beneath. /// /// A second constructor rather than a fourth argument on /// [`bind`](Self::bind): every app in the tree binds ungrouped keys and /// most of them will go on doing it, so the group belongs on the call that /// wants one. See [`Binding::group`]. #[must_use] pub fn bind_in( mut self, group: impl Into, key: impl Into, label: impl Into, action: Action, ) -> Self { self.bindings.push(Binding { key: key.into(), label: label.into(), action, group: Some(group.into()), }); self } /// Keep this on screen, whatever screen is showing. /// /// Appends since `71aa29b4`. It replaced until then, and the reason was /// that two anonymous panels would be a renderer deciding which of them is /// where. [`Role`] is what answers that instead, so a second panel is now a /// second panel rather than a lost one. /// /// Declaring the same id twice is still one panel's worth of address for /// two elements, and [`Self::replace`] then fills the first. Not refused /// here: the router does not police an app's own names, and the failure is /// visible the first time an answer lands. #[must_use] pub fn presenting(mut self, id: impl Into, role: Role, content: Node) -> Self { self.panels.push(Panel { id: id.into(), content, role, }); self } /// Put new contents in the panel, if this names it. /// /// The chrome's half of [`Screen::replace`](crate::Screen::replace), and it /// answers the same way: `false` when nothing here is called `region`, so a /// renderer can tell an answer aimed at the panel from one aimed at a /// region that is not there. pub fn replace(&mut self, region: &str, content: Node) -> bool { let Some(panel) = self.panels.iter_mut().find(|panel| panel.id == region) else { return false; }; panel.content = content; true } /// The panel under this id, if the app declared one. #[must_use] pub fn panel(&self, id: &str) -> Option<&Panel> { self.panels.iter().find(|panel| panel.id == id) } /// What the key calls, if anything claimed it. /// /// First match wins, so an app that binds one key twice gets the one it /// declared first rather than an error. Matching is exact: normalising /// "ctrl+k" against "Ctrl+K" would be this crate deciding what a key name /// looks like, which is the host's to decide. #[must_use] pub fn bound(&self, key: &str) -> Option<&Binding> { self.bindings.iter().find(|binding| binding.key == key) } /// The bindings, gathered under their headings, for a listing to draw. /// /// Here rather than in each renderer and each app, so a shortcuts table /// drawn in a terminal and one drawn in a browser cannot come out in two /// different orders from one description. No renderer draws a shortcuts /// listing on its own — the help screen is a described screen like any /// other, which is the whole of what [`Binding::label`] bought — so this /// is the shared half that stops the three of them each writing it. /// /// # The order is the app's /// /// Groups come in the order they were first bound, and within a group so do /// the bindings. Not alphabetical: a help screen's headings are a reading /// order somebody chose, and sorting them would be this crate overruling it /// for the sake of a rule nobody asked for. /// /// Ungrouped bindings come back under [`None`], in one run, wherever the /// first of them was bound. An app that groups nothing therefore gets one /// run holding everything in declaration order, which is exactly the flat /// list every listing already draws. #[must_use] pub fn grouped(&self) -> Vec<(Option<&str>, Vec<&Binding>)> { let mut groups: Vec<(Option<&str>, Vec<&Binding>)> = Vec::new(); for binding in &self.bindings { let group = binding.group.as_deref(); match groups.iter_mut().find(|(name, _)| *name == group) { Some((_, members)) => members.push(binding), None => groups.push((group, vec![binding])), } } groups } } #[cfg(test)] mod tests { use super::*; #[test] fn an_ungrouped_table_comes_back_as_one_run_in_the_order_it_was_bound() { // The flat list every listing already draws, and the shape an app that // says nothing new keeps. let chrome = Chrome::new() .bind("f1", "Show this help", Action::get("/help")) .bind("s", "Toggle the sidebar", Action::post("/panels/sidebar")); let grouped = chrome.grouped(); assert_eq!(grouped.len(), 1); assert_eq!(grouped[0].0, None); let keys: Vec<_> = grouped[0].1.iter().map(|binding| &binding.key).collect(); assert_eq!(keys, ["f1", "s"]); } #[test] fn groups_come_in_the_order_they_were_first_bound_and_gather_what_follows() { // Two groups declared alternately: the run is what gathers them, and // the heading order is the one the app wrote rather than the alphabet. let chrome = Chrome::new() .bind_in( "Toggles", "s", "Toggle the sidebar", Action::post("/panels/sidebar"), ) .bind_in( "Bulk", "f2", "Rename the selection", Action::get("/bulk/rename"), ) .bind_in( "Toggles", "d", "Toggle the detail panel", Action::post("/panels/detail"), ); let grouped = chrome.grouped(); assert_eq!(grouped.len(), 2); assert_eq!(grouped[0].0, Some("Toggles")); let toggles: Vec<_> = grouped[0].1.iter().map(|binding| &binding.key).collect(); assert_eq!(toggles, ["s", "d"]); assert_eq!(grouped[1].0, Some("Bulk")); assert_eq!(grouped[1].1.len(), 1); } #[test] fn a_group_changes_the_listing_and_nothing_about_what_the_key_does() { let chrome = Chrome::new().bind_in("System", "f1", "Show this help", Action::get("/help")); let bound = chrome.bound("f1").expect("claimed"); assert_eq!(bound.group.as_deref(), Some("System")); assert_eq!(bound.action, Action::get("/help")); // And a key nobody grouped is still found the same way. assert!( Chrome::new() .bind("f1", "Help", Action::get("/help")) .bound("f1") .is_some() ); } #[test] fn an_app_with_no_chrome_claims_no_keys_and_keeps_nothing_on_screen() { let chrome = Chrome::new(); assert!(chrome.bindings.is_empty()); assert!(chrome.bound("ctrl+k").is_none()); // The default has to be the old behaviour, or every renderer draws // something new the moment this member arrives. assert!(chrome.panels.is_empty()); assert!(chrome.nav.is_empty()); } #[test] fn a_panel_is_a_node_and_carries_the_address_answers_aim_at() { let chrome = Chrome::new().presenting("timer", Role::Activity, Node::text("00:12:04")); let panel = chrome.panel("timer").expect("declared"); assert_eq!(panel.id, "timer"); assert_eq!(panel.content, Node::text("00:12:04")); assert_eq!(panel.role, Role::Activity); } #[test] fn an_app_can_keep_more_than_one_thing_on_screen_and_says_what_each_is_for() { // It replaced rather than appended until `71aa29b4`, because two // anonymous panels would be a renderer placing them by declaration // order. The role is what answers that, so the second one survives now. let chrome = Chrome::new() .presenting("timer", Role::Activity, Node::text("00:12:04")) .presenting("sync", Role::Status, Node::text("Synced")); assert_eq!(chrome.panels.len(), 2); assert_eq!( chrome.panel("timer").expect("declared").role, Role::Activity ); assert_eq!(chrome.panel("sync").expect("declared").role, Role::Status); } #[test] fn a_nav_is_addresses_with_names_rather_than_content_that_is_always_there() { // The distinction the member exists for: a renderer reads places and // draws a tab bar, a sidebar or a tab line without being told which. let chrome = Chrome::new() .offering(Place::new("work", "Work", Action::get("/tasks")).within([ Place::new("tasks", "Tasks", Action::get("/tasks")), Place::new("board", "Board", Action::get("/board")), ])) .offering(Place::new("time", "Time", Action::get("/day"))); assert_eq!(chrome.nav.len(), 2); assert_eq!(chrome.nav[0].within.len(), 2); // Order is declaration order, because that is the order it is offered. assert_eq!(chrome.nav[1].key, "time"); // A group still goes somewhere: pressing it means its first sub-place. assert_eq!(chrome.nav[0].action, Action::get("/tasks")); } #[test] fn a_place_finds_itself_and_the_places_inside_it_and_no_deeper() { let deep = Place::new("work", "Work", Action::get("/tasks")).within([Place::new( "tasks", "Tasks", Action::get("/tasks"), ) .within([Place::new("buried", "Buried", Action::get("/buried"))])]); assert!(deep.holds("work"), "itself"); assert!(deep.holds("tasks"), "one level down"); // One level is the depth `within` describes, and the three renderers // ask this rather than each walking the tree to a depth of its own. assert!(!deep.holds("buried"), "no deeper"); } #[test] fn a_fresh_answer_lands_in_whichever_panel_it_names() { let mut chrome = Chrome::new() .presenting("timer", Role::Activity, Node::text("00:12:04")) .presenting("sync", Role::Status, Node::text("Synced")); assert!(chrome.replace("sync", Node::text("Syncing"))); assert_eq!( chrome.panel("sync").map(|panel| &panel.content), Some(&Node::text("Syncing")) ); // And leaves the other alone, which is the whole reason they are two. assert_eq!( chrome.panel("timer").map(|panel| &panel.content), Some(&Node::text("00:12:04")) ); } #[test] fn a_fresh_answer_lands_in_the_panel_it_names_and_nowhere_else() { let mut chrome = Chrome::new().presenting("timer", Role::Activity, Node::text("00:12:04")); assert!(chrome.replace("timer", Node::text("00:12:05"))); assert_eq!( chrome.panel("timer").map(|panel| &panel.content), Some(&Node::text("00:12:05")) ); // Not the panel, so the renderer can say so rather than swallowing it. assert!(!chrome.replace("detail", Node::text("nope"))); assert!(!Chrome::new().replace("timer", Node::text("nope"))); } #[test] fn an_app_with_no_band_is_an_app_with_the_chrome_it_had_before_one_existed() { // The default has to be the old behaviour, or every renderer draws a // header the moment this member arrives. assert!(Chrome::new().band.is_none()); assert!( Chrome::new() .offering(Place::new("work", "Work", Action::get("/tasks"))) .band .is_none(), "places without a band are still places" ); } #[test] fn a_band_says_the_header_is_one_thing_and_leaves_the_nav_where_it_was() { let chrome = Chrome::new() .offering(Place::new("discover", "Discover", Action::get("/discover"))) .banded( Band::new() .branded(Brand::new("Makenot.work", Action::get("/")).marking(".")) .disclosing(Disclose::Narrow), ); let band = chrome.band.as_ref().expect("declared"); assert_eq!(band.disclose, Disclose::Narrow); // The places did not move into it. An app with a nav and no band still // has a nav, which is why they are two members. assert_eq!(chrome.nav.len(), 1); assert!(band.search.is_none()); } #[test] fn a_wordmark_comes_apart_at_its_mark_once() { let brand = Brand::new("Makenot.work", Action::get("/")).marking("."); assert_eq!(brand.parts(), ("Makenot", ".", "work")); } #[test] fn a_name_with_no_mark_is_the_whole_name_and_so_is_one_whose_mark_is_not_in_it() { // One answer for all three renderers, so a webview, a terminal and an // egui host cannot disagree about which run is marked. let plain = Brand::new("Goingson", Action::get("/")); assert_eq!(plain.parts(), ("Goingson", "", "")); // A run the name does not contain marks nothing rather than failing: // the name is the app's and so is this. let wrong = Brand::new("Goingson", Action::get("/")).marking("@"); assert_eq!(wrong.parts(), ("Goingson", "", "")); // And an empty mark is the same as no mark, rather than an empty span // in front of the name. let empty = Brand::new("Goingson", Action::get("/")).marking(""); assert_eq!(empty.parts(), ("Goingson", "", "")); } #[test] fn only_the_first_occurrence_is_the_mark() { // A wordmark has one mark. A rule that found every "." would mark both // dots of a name that had two. let brand = Brand::new("a.b.c", Action::get("/")).marking("."); assert_eq!(brand.parts(), ("a", ".", "b.c")); } #[test] fn a_search_box_in_the_band_is_an_ordinary_field() { // The whole of what typing it as a `Field` buys: no renderer grows a // second field emitter for a box that happens to be in the header. let field = Field::new(crate::layout::FieldKind::Text, "q", "Search"); let band = Band::new().searching(field.clone()); assert_eq!(band.search.as_ref(), Some(&field)); assert_eq!(band.disclose, Disclose::Always, "the default is out"); } #[test] fn a_binding_carries_its_label_so_a_help_list_is_not_a_second_copy() { let chrome = Chrome::new() .bind("ctrl+k", "Search", Action::get("/palette")) .bind("?", "Keys", Action::get("/help")); let found = chrome.bound("ctrl+k").expect("bound"); assert_eq!(found.label, "Search"); assert_eq!(found.action, Action::get("/palette")); assert_eq!(chrome.bindings.len(), 2); } #[test] fn a_key_nothing_claimed_is_none_rather_than_a_guess() { let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette")); // Exact match: normalising case or modifier order would be this crate // deciding what a key name looks like. assert!(chrome.bound("Ctrl+K").is_none()); assert!(chrome.bound("ctrl+j").is_none()); } #[test] fn the_first_claim_on_a_key_wins() { let chrome = Chrome::new() .bind("ctrl+k", "Search", Action::get("/palette")) .bind("ctrl+k", "Other", Action::get("/other")); assert_eq!(chrome.bound("ctrl+k").expect("bound").label, "Search"); } }