Skip to main content

max / quasi

37.8 KB · 903 lines History Blame Raw
1 //! What the app offers on every screen, rather than on one of them.
2 //!
3 //! A [`Screen`](crate::Screen) names one place. Everything here outlives any one
4 //! of them: a command palette reachable from anywhere, a global shortcut, the
5 //! help overlay that lists the shortcuts. None of that is a fact about the
6 //! screen the user happens to be on, and describing it per screen means
7 //! repeating it on every screen or reimplementing it per host.
8 //!
9 //! # Why this is not [`RegionKind::Modal`](crate::RegionKind::Modal)
10 //!
11 //! That is a modal a screen *contains*, which is how a confirmation is drawn:
12 //! the screen carries it, and it goes when the screen goes. Chrome belongs to
13 //! the app, so it is reachable from screens that know nothing about it. The
14 //! same move [`Screen::notices`](crate::Screen::notices) made one level down,
15 //! when a notice stopped belonging to a region and started belonging to the
16 //! screen.
17 //!
18 //! # What it costs, and what it does not
19 //!
20 //! An overlay's *contents* were always sayable: a query, a result list, a
21 //! keyboard walk through it, an [`Act`](crate::Act) that navigates. What was
22 //! missing was a way to say "fetched from a route, and drawn over what is under
23 //! it", which is [`Outcome::Over`](crate::Outcome::Over) and not a second
24 //! description tree. So an overlay is a [`Screen`](crate::Screen) like any
25 //! other, and this module is only the way in.
26 //!
27 //! A toast stack needs nothing from here: [`Screen::notices`](crate::Screen::notices)
28 //! and [`Message`](crate::Message) already carry notices, and how they stack is
29 //! renderer policy. An app-modal is the overlay case with one region in it.
30 //!
31 //! # A panel is chrome too, and is not a region every screen repeats
32 //!
33 //! Described per screen it is repeated on every screen, and the screen that
34 //! forgets it drops the panel, which is the repetition this module exists to
35 //! end. So [`Chrome::panel`] holds it.
36 //!
37 //! Its contents are an ordinary [`Node`](crate::Node), the way an overlay's
38 //! contents are an ordinary [`Screen`](crate::Screen): a panel with several
39 //! things in it is a [`Node::Region`](crate::Node::Region), and there is no
40 //! second description tree here either.
41 //!
42 //! Where it sits is the renderer's. A description saying "bottom right,
43 //! floating" would be naming one host's screen, and a terminal has no floating.
44 //! Same call as `4453bf82`, where the clock a toast expires on turned out to be
45 //! the renderer's.
46 //!
47 //! ## Dismissal is nobody's, because nothing dismisses one
48 //!
49 //! So there is no dismissed state to keep, in the description or in a
50 //! renderer, and the panel's presence is the app's answer: declare none, or
51 //! replace its contents through [`Chrome::replace`] the way every other region
52 //! is replaced.
53 //!
54 //! Adding a dismissal later is additive. Inventing one now would be a member
55 //! three renderers implement for no measured widget.
56 //!
57 //! # An app shell is several always-present things, and one of them is not a panel
58 //!
59 //! One panel was enough while the only measured consumer was a timer band. It
60 //! stopped being enough the moment an app wanted a tab bar as well: goingson's
61 //! shell is three tabs, a sub-nav under the chosen one, a sync indicator and
62 //! the timer band, and the reason `presenting` replaced rather than appended
63 //! was that a renderer handed two anonymous panels would place them by
64 //! declaration order.
65 //!
66 //! The answer is two members rather than a longer list of one kind.
67 //!
68 //! [`Chrome::nav`] holds the places the app has. A tab bar is not content that
69 //! happens to be always on screen: it is a set of addresses with names, which is
70 //! why it is [`Place`] and not a [`Panel`] holding a list. A renderer draws it as
71 //! a tab bar, a sidebar or a terminal's tab line, and it never has to be told
72 //! which, because the description never said.
73 //!
74 //! [`Chrome::panels`] holds the rest, each carrying a [`Role`].
75 //! [`Role::Activity`] is something the app is doing right now and
76 //! [`Role::Status`] is a standing readout of its condition. The role says what a
77 //! panel is *for* and still not where it goes. There is no `Role::Navigation`,
78 //! because navigation is not a panel.
79 //!
80 //! ## Which place is current is the screen's to say
81 //!
82 //! Chrome is built once, so a `current` flag on a [`Place`] would be frozen at
83 //! build time and could never point at where the user is. The screen names its
84 //! own place instead ([`Screen::place`](crate::Screen::place)) and the renderer
85 //! marks the [`Place`] whose [`key`](Place::key) matches. Exactly the move
86 //! [`Row::current`](crate::screen::Row::current) makes one level down: the app's
87 //! own pointer at what is showing, said by the thing that knows.
88 //!
89 //! The alternative was the renderer comparing a place's address to the request
90 //! path, which needs the path in [`Serves`](crate::Screen) and gets fuzzy the
91 //! first time a screen carries its view on the address. goingson's Timer is
92 //! `/timer?work=25&days=7` and its place is `/timer`.
93
94 use crate::screen::{Action, Field, Node};
95
96 /// The affordances the app offers from every screen.
97 ///
98 /// Built once by the app and held beside the [`Router`](crate::Router), never
99 /// per request. That is what "outlives any one screen" means concretely: a
100 /// request answers with a screen, and this is not part of that answer.
101 ///
102 /// Beside the router rather than inside it, decided while building this: a
103 /// `Router` is a route table, and a key binding is not a route. The two are
104 /// held together by whatever the host is, which already holds both. Nothing
105 /// here would break if it moved inside, so this is a tidiness argument rather
106 /// than a correctness one.
107 #[derive(Debug, Clone, Default, PartialEq, Eq)]
108 pub struct Chrome {
109 /// The keys that work from anywhere.
110 pub bindings: Vec<Binding>,
111 /// The places the app has, in the order it offers them.
112 ///
113 /// One level of nesting is what [`Place::within`] is for, and it is what
114 /// the measured consumer has. A renderer that cannot draw a second level
115 /// flattens or ignores it, the same rule [`Binding::key`] states for a key
116 /// one host names and another has never heard of.
117 pub nav: Vec<Place>,
118 /// What is on the screen whatever screen is showing.
119 ///
120 /// Empty is the app that declares none, and a renderer with nothing here
121 /// draws nothing extra.
122 ///
123 /// Plural, and each carries a [`Role`]. Two anonymous panels would be a
124 /// renderer placing them by declaration order, which is the guess this was
125 /// a single slot to avoid; the role is what makes the second one sayable
126 /// instead.
127 pub panels: Vec<Panel>,
128 /// The header band the app puts above every screen, if it has one.
129 ///
130 /// [`nav`](Self::nav) is the places and nothing else, and a real header is
131 /// a brand mark, a search box and those places sitting together. Described
132 /// as three separate things they are three elements a renderer places by
133 /// declaration order, and in a browser they are also three elements a
134 /// stylesheet cannot make into one bar: MNW's narrow-viewport menu is a
135 /// checkbox styling its siblings, and siblings that are not siblings match
136 /// nothing.
137 ///
138 /// So the band is what says they are one thing. The nav does not move into
139 /// it -- an app with places and no band still has places -- and a renderer
140 /// draws [`nav`](Self::nav) *inside* the band when there is one and on its
141 /// own when there is not. See [`Band`].
142 ///
143 /// `None` is every app that has never had a header, and it draws exactly
144 /// what it drew before this member existed.
145 pub band: Option<Band>,
146 }
147
148 /// The header the app puts above every screen.
149 ///
150 /// Named slots rather than a `Vec<Node>` body, which is the shape decided in
151 /// `93f999c3`. A band holding arbitrary content is a band a renderer cannot
152 /// read: it could not tell a brand from a heading, so it could not draw the
153 /// brand large on a phone and the nav behind a control, and every host would
154 /// be back to being handed markup. Chrome is a fixed vocabulary for the same
155 /// reason [`Role`] is a closed set.
156 ///
157 /// # The nav is not a member here
158 ///
159 /// It stays [`Chrome::nav`]. Moving it would mean an app with places and no
160 /// band had nowhere to put them, and the two facts are independent: the places
161 /// are what the app has, and the band is whether they are drawn in a bar with
162 /// a wordmark. A renderer draws the nav inside the band when the app declared
163 /// one.
164 ///
165 /// # Order is the renderer's, and it is the same order everywhere
166 ///
167 /// Brand, then the disclosure control, then search, then the nav. Not carried
168 /// here as a sequence, because a band with a configurable order is a band the
169 /// app is laying out; the reading order is the same on every host and each
170 /// renderer writes it once.
171 #[derive(Debug, Clone, Default, PartialEq, Eq)]
172 pub struct Band {
173 /// The mark the app is called by, if it shows one.
174 pub brand: Option<Brand>,
175 /// The box the band offers for searching, if it offers one.
176 ///
177 /// A [`Field`], so a search box in the header is the same question a search
178 /// box in a screen is, and no renderer grows a second field emitter for it.
179 /// [`Field::writes`](crate::Field::writes) is what says where the query
180 /// goes, which is the whole of what MNW's header form does today.
181 ///
182 /// This is the hole [`Panel`] could not fill: a panel is drawn after the
183 /// screen, and a search box that lands under the content is not a header.
184 pub search: Option<Field>,
185 /// Whether the nav is out at all times or behind a control when there is
186 /// no room for it.
187 pub disclose: Disclose,
188 }
189
190 /// The mark an app is called by.
191 ///
192 /// A name plus one marked run inside it, which is what a wordmark is and what
193 /// no member here could say before: MNW's is `Makenot.work` with the dot drawn
194 /// as a graphic. An [`Image`](crate::Image) could not say it -- the mark is a
195 /// character of the name rather than a picture beside it -- and a
196 /// [`Node::Text`](crate::Node::Text) could not either, because nothing in it
197 /// says which part is the mark.
198 #[derive(Debug, Clone, Default, PartialEq, Eq)]
199 pub struct Brand {
200 /// The whole name, as it is read.
201 ///
202 /// Read whole, including the mark. `Makenot.work` is a domain and a reader
203 /// hearing "Makenot dot work" has heard the name; hiding the mark from a
204 /// screen reader would leave "Makenotwork", which is not what the app is
205 /// called.
206 pub name: String,
207 /// The run inside [`name`](Self::name) drawn as the graphic mark.
208 ///
209 /// The first occurrence, and no more than one: a wordmark has one mark,
210 /// and a rule that found every "." would mark both dots of a name that had
211 /// two. A run this name does not contain marks nothing, which is
212 /// [`Screen::place`](crate::Screen::place)'s bargain again -- the name is
213 /// the app's and so is this.
214 pub mark: Option<String>,
215 /// What pressing it calls. Home, for every app that has ever had one.
216 pub action: Action,
217 }
218
219 /// Whether the band's nav is always out.
220 ///
221 /// The narrow-viewport question, named rather than hand-rolled. Every app that
222 /// has a header has answered it, and every one of them answered it in its own
223 /// stylesheet with its own checkbox, which is markup in the assembly layer
224 /// doing what a description should have said.
225 ///
226 /// It says *whether*, never *how*. A checkbox and a label is one host's answer
227 /// and a terminal has neither; what a description can honestly state is that
228 /// the places are worth hiding when there is no room.
229 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
230 pub enum Disclose {
231 /// The nav is out whatever the room. The default, and every app with a
232 /// handful of places.
233 #[default]
234 Always,
235 /// The nav goes behind a control when there is not room for it.
236 ///
237 /// A renderer with no notion of "not enough room" -- a terminal draws the
238 /// width it was given -- ignores this and draws the places, which is the
239 /// same degrading every renderer does with a key it has never heard of.
240 Narrow,
241 }
242
243 impl Band {
244 /// A band with nothing in it, which is the nav in a bar and no more.
245 #[must_use]
246 pub fn new() -> Self {
247 Self::default()
248 }
249
250 /// Show this mark, chaining.
251 #[must_use]
252 pub fn branded(mut self, brand: Brand) -> Self {
253 self.brand = Some(brand);
254 self
255 }
256
257 /// Offer this box for searching, chaining.
258 #[must_use]
259 pub fn searching(mut self, field: Field) -> Self {
260 self.search = Some(field);
261 self
262 }
263
264 /// Whether the nav goes behind a control when there is no room, chaining.
265 #[must_use]
266 pub const fn disclosing(mut self, disclose: Disclose) -> Self {
267 self.disclose = disclose;
268 self
269 }
270 }
271
272 impl Brand {
273 /// The name the app is called by, and what pressing it calls.
274 pub fn new(name: impl Into<String>, action: Action) -> Self {
275 Self {
276 name: name.into(),
277 mark: None,
278 action,
279 }
280 }
281
282 /// Draw this run of the name as the graphic mark, chaining.
283 ///
284 /// The first occurrence. See [`mark`](Self::mark).
285 #[must_use]
286 pub fn marking(mut self, mark: impl Into<String>) -> Self {
287 self.mark = Some(mark.into());
288 self
289 }
290
291 /// The name in three parts: before the mark, the mark, and after it.
292 ///
293 /// Answered here rather than in each renderer, so a webview, a terminal and
294 /// an egui host cannot disagree about which run is marked. The whole name
295 /// comes back as the first part when nothing is marked or when the run is
296 /// not in the name, which is what makes a renderer's drawing one branch
297 /// rather than three.
298 #[must_use]
299 pub fn parts(&self) -> (&str, &str, &str) {
300 let Some(mark) = self.mark.as_deref().filter(|mark| !mark.is_empty()) else {
301 return (&self.name, "", "");
302 };
303 match self.name.find(mark) {
304 Some(at) => (
305 &self.name[..at],
306 &self.name[at..at + mark.len()],
307 &self.name[at + mark.len()..],
308 ),
309 None => (&self.name, "", ""),
310 }
311 }
312 }
313
314 /// A place the app has, and what going there calls.
315 ///
316 /// Not a [`Node`] holding a list of controls: a tab bar is a set of addresses
317 /// with names, and saying so is what lets a webview draw tabs, a terminal draw
318 /// a tab line and an egui host draw a toolbar without any of them being told
319 /// which.
320 #[derive(Debug, Clone, PartialEq, Eq)]
321 pub struct Place {
322 /// What a screen names to say it is here.
323 ///
324 /// An identifier and not the label, because a label is display text: it is
325 /// renamed, translated, and reworded to fit, and a pointer that broke when
326 /// somebody improved the wording would be a pointer nobody trusts.
327 pub key: String,
328 /// What it is called.
329 pub label: String,
330 /// What going there calls.
331 ///
332 /// A place that only groups others still has one: pressing goingson's Work
333 /// tab means its first sub-place, which is what the shipped tab does. A
334 /// group with nowhere to go would be a control that does nothing.
335 pub action: Action,
336 /// The places inside this one.
337 ///
338 /// Empty for a flat nav. One level deep: goingson's pills under its tabs
339 /// are the measured case, and a renderer meeting a third level flattens it
340 /// rather than inventing a shape for it.
341 pub within: Vec<Place>,
342 }
343
344 /// What a panel is for.
345 ///
346 /// A small closed set the renderers agree on, so that an app with two panels
347 /// is placing them by what they are rather than by which was declared first.
348 ///
349 /// Still not where it goes. A stylesheet, a terminal's layout and an egui
350 /// host's panel all read this and each answers the placement question its own
351 /// way, which is the arrangement `4453bf82` settled for the clock a toast
352 /// expires on.
353 ///
354 /// # Why there is no `Navigation`
355 ///
356 /// Navigation is [`Chrome::nav`], which is a set of addresses rather than
357 /// content. A `Role::Navigation` panel would be an app hand-building a tab bar
358 /// out of controls and every renderer drawing it as content, which is the thing
359 /// the nav member exists to stop.
360 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
361 pub enum Role {
362 /// Something the app is doing right now, on screen while it lasts.
363 ///
364 /// goingson's running-timer band is the measured one: it is there while a
365 /// timer runs and absent otherwise, and it is about a thing in progress
366 /// rather than about the app's condition.
367 #[default]
368 Activity,
369 /// A standing readout of the app's condition.
370 ///
371 /// goingson's sync indicator is the measured one: always present, saying
372 /// how the app stands rather than what it is doing.
373 Status,
374 }
375
376 /// Something the app keeps on screen, whatever screen the user is on.
377 ///
378 /// goingson's running-timer widget is the measured one: a task name, an elapsed
379 /// readout, Stop and Discard, present on every screen while a timer runs.
380 #[derive(Debug, Clone, PartialEq, Eq)]
381 pub struct Panel {
382 /// The address a fresh answer aims at.
383 ///
384 /// A [`Slot::id`](crate::Slot) in everything but the slot: a route that has
385 /// changed what the panel says names this in
386 /// [`Response::also`](crate::Response::also) or answers a fragment aimed at
387 /// it, and the renderers put the new contents in. Without an address the
388 /// panel could only ever say what it said when the app was built, which for
389 /// a timer is a readout that never moves.
390 pub id: String,
391 /// What it holds.
392 ///
393 /// A [`Node`], so a panel is described in the vocabulary every screen is
394 /// described in. Several things in one panel is a
395 /// [`Node::Region`](crate::Node::Region), which is what a screen's own
396 /// grouping already is.
397 pub content: Node,
398 /// What it is for, which is how a renderer tells two of them apart.
399 pub role: Role,
400 }
401
402 impl Place {
403 /// A place, by the key a screen names it with and the name a person reads.
404 pub fn new(key: impl Into<String>, label: impl Into<String>, action: Action) -> Self {
405 Self {
406 key: key.into(),
407 label: label.into(),
408 action,
409 within: Vec::new(),
410 }
411 }
412
413 /// The places inside this one.
414 #[must_use]
415 pub fn within(mut self, places: impl IntoIterator<Item = Self>) -> Self {
416 self.within.extend(places);
417 self
418 }
419
420 /// This place or one inside it, under `key`.
421 ///
422 /// One level down and no further, which is the depth [`Self::within`]
423 /// describes. A renderer marking the current place asks the nav rather than
424 /// walking it itself, so the three of them cannot disagree about how deep
425 /// the search goes.
426 #[must_use]
427 pub fn holds(&self, key: &str) -> bool {
428 self.key == key || self.within.iter().any(|inner| inner.key == key)
429 }
430 }
431
432 /// A key that works from every screen, and what it calls.
433 #[derive(Debug, Clone, PartialEq, Eq)]
434 pub struct Binding {
435 /// The key, as text.
436 ///
437 /// Text rather than a modelled chord — "ctrl+k", "?" — for the reason
438 /// [`Act::key`](crate::Act::key) is: the vocabulary of keys is the host's,
439 /// and a description that modelled it would be naming one host's keyboard.
440 /// A renderer that does not know a name ignores it, which is what a webview
441 /// does with a key a terminal wants.
442 pub key: String,
443 /// What a shortcuts list shows for it.
444 ///
445 /// The reason this is a struct and not a `(String, Action)` pair. A help
446 /// overlay that lists the bindings is otherwise a second, hand-written copy
447 /// of them, free to drift from what the keys actually do.
448 pub label: String,
449 /// What pressing it calls.
450 ///
451 /// Ordinarily a route answering with [`Response::over`](crate::Response::over),
452 /// which is what makes the palette an overlay rather than a navigation. It
453 /// is not required to: a binding that navigates is a binding that navigates.
454 pub action: Action,
455 /// The heading this binding sits under in a listing, if the app gave it one.
456 ///
457 /// A listing fact and nothing else: no renderer changes what a key *does*
458 /// because of it. It exists because [`label`](Self::label)'s own argument
459 /// runs out at length — a table that exists once is worth having, and a
460 /// flat table of thirteen rows is a wall rather than a reference.
461 /// audiofiles' shipped shortcuts tab sorts twenty-six rows into seven
462 /// hand-written arrays, which is the evidence that somebody already
463 /// thought so before this member existed.
464 ///
465 /// Said by the app, never derived. Sorting by key puts "Toggle the sidebar"
466 /// next to "Show this help" because both start with a letter the app did
467 /// not choose for that reason, and deriving a group from the address prefix
468 /// is the same guess wearing a path: `/panels/sidebar` and `/forge` are both
469 /// Toggles to a reader and are siblings in nothing.
470 ///
471 /// Free text rather than a modelled set, for [`key`](Self::key)'s reason.
472 /// What the groups of an app are is the app's, and a vocabulary that
473 /// enumerated them would be naming one app's help screen.
474 ///
475 /// [`Chrome::grouped`] is how a listing reads it, so the three renderers
476 /// and every app that draws its own shortcuts table cannot disagree about
477 /// what order the groups come in.
478 pub group: Option<String>,
479 }
480
481 impl Chrome {
482 /// No chrome. What an app that declares none has.
483 #[must_use]
484 pub fn new() -> Self {
485 Self::default()
486 }
487
488 /// Offer a place, with whatever places sit inside it.
489 ///
490 /// Appends, because a nav is a sequence and the order it is declared in is
491 /// the order it is offered in. Nothing here refuses a repeated key: two
492 /// places with one key is an app pointing at itself twice, and the renderer
493 /// marking both is a truthful drawing of it.
494 #[must_use]
495 pub fn offering(mut self, place: Place) -> Self {
496 self.nav.push(place);
497 self
498 }
499
500 /// Put a header band above every screen.
501 ///
502 /// Replaces rather than adds, because an app has one header. See
503 /// [`band`](Self::band).
504 #[must_use]
505 pub fn banded(mut self, band: Band) -> Self {
506 self.band = Some(band);
507 self
508 }
509
510 /// Add a key that works from every screen.
511 #[must_use]
512 pub fn bind(
513 mut self,
514 key: impl Into<String>,
515 label: impl Into<String>,
516 action: Action,
517 ) -> Self {
518 self.bindings.push(Binding {
519 key: key.into(),
520 label: label.into(),
521 action,
522 group: None,
523 });
524 self
525 }
526
527 /// Add a key that works from every screen, under the heading a listing
528 /// shows it beneath.
529 ///
530 /// A second constructor rather than a fourth argument on
531 /// [`bind`](Self::bind): every app in the tree binds ungrouped keys and
532 /// most of them will go on doing it, so the group belongs on the call that
533 /// wants one. See [`Binding::group`].
534 #[must_use]
535 pub fn bind_in(
536 mut self,
537 group: impl Into<String>,
538 key: impl Into<String>,
539 label: impl Into<String>,
540 action: Action,
541 ) -> Self {
542 self.bindings.push(Binding {
543 key: key.into(),
544 label: label.into(),
545 action,
546 group: Some(group.into()),
547 });
548 self
549 }
550
551 /// Keep this on screen, whatever screen is showing.
552 ///
553 /// Appends since `71aa29b4`. It replaced until then, and the reason was
554 /// that two anonymous panels would be a renderer deciding which of them is
555 /// where. [`Role`] is what answers that instead, so a second panel is now a
556 /// second panel rather than a lost one.
557 ///
558 /// Declaring the same id twice is still one panel's worth of address for
559 /// two elements, and [`Self::replace`] then fills the first. Not refused
560 /// here: the router does not police an app's own names, and the failure is
561 /// visible the first time an answer lands.
562 #[must_use]
563 pub fn presenting(mut self, id: impl Into<String>, role: Role, content: Node) -> Self {
564 self.panels.push(Panel {
565 id: id.into(),
566 content,
567 role,
568 });
569 self
570 }
571
572 /// Put new contents in the panel, if this names it.
573 ///
574 /// The chrome's half of [`Screen::replace`](crate::Screen::replace), and it
575 /// answers the same way: `false` when nothing here is called `region`, so a
576 /// renderer can tell an answer aimed at the panel from one aimed at a
577 /// region that is not there.
578 pub fn replace(&mut self, region: &str, content: Node) -> bool {
579 let Some(panel) = self.panels.iter_mut().find(|panel| panel.id == region) else {
580 return false;
581 };
582 panel.content = content;
583 true
584 }
585
586 /// The panel under this id, if the app declared one.
587 #[must_use]
588 pub fn panel(&self, id: &str) -> Option<&Panel> {
589 self.panels.iter().find(|panel| panel.id == id)
590 }
591
592 /// What the key calls, if anything claimed it.
593 ///
594 /// First match wins, so an app that binds one key twice gets the one it
595 /// declared first rather than an error. Matching is exact: normalising
596 /// "ctrl+k" against "Ctrl+K" would be this crate deciding what a key name
597 /// looks like, which is the host's to decide.
598 #[must_use]
599 pub fn bound(&self, key: &str) -> Option<&Binding> {
600 self.bindings.iter().find(|binding| binding.key == key)
601 }
602
603 /// The bindings, gathered under their headings, for a listing to draw.
604 ///
605 /// Here rather than in each renderer and each app, so a shortcuts table
606 /// drawn in a terminal and one drawn in a browser cannot come out in two
607 /// different orders from one description. No renderer draws a shortcuts
608 /// listing on its own — the help screen is a described screen like any
609 /// other, which is the whole of what [`Binding::label`] bought — so this
610 /// is the shared half that stops the three of them each writing it.
611 ///
612 /// # The order is the app's
613 ///
614 /// Groups come in the order they were first bound, and within a group so do
615 /// the bindings. Not alphabetical: a help screen's headings are a reading
616 /// order somebody chose, and sorting them would be this crate overruling it
617 /// for the sake of a rule nobody asked for.
618 ///
619 /// Ungrouped bindings come back under [`None`], in one run, wherever the
620 /// first of them was bound. An app that groups nothing therefore gets one
621 /// run holding everything in declaration order, which is exactly the flat
622 /// list every listing already draws.
623 #[must_use]
624 pub fn grouped(&self) -> Vec<(Option<&str>, Vec<&Binding>)> {
625 let mut groups: Vec<(Option<&str>, Vec<&Binding>)> = Vec::new();
626 for binding in &self.bindings {
627 let group = binding.group.as_deref();
628 match groups.iter_mut().find(|(name, _)| *name == group) {
629 Some((_, members)) => members.push(binding),
630 None => groups.push((group, vec![binding])),
631 }
632 }
633 groups
634 }
635 }
636
637 #[cfg(test)]
638 mod tests {
639 use super::*;
640
641 #[test]
642 fn an_ungrouped_table_comes_back_as_one_run_in_the_order_it_was_bound() {
643 // The flat list every listing already draws, and the shape an app that
644 // says nothing new keeps.
645 let chrome = Chrome::new()
646 .bind("f1", "Show this help", Action::get("/help"))
647 .bind("s", "Toggle the sidebar", Action::post("/panels/sidebar"));
648 let grouped = chrome.grouped();
649 assert_eq!(grouped.len(), 1);
650 assert_eq!(grouped[0].0, None);
651 let keys: Vec<_> = grouped[0].1.iter().map(|binding| &binding.key).collect();
652 assert_eq!(keys, ["f1", "s"]);
653 }
654
655 #[test]
656 fn groups_come_in_the_order_they_were_first_bound_and_gather_what_follows() {
657 // Two groups declared alternately: the run is what gathers them, and
658 // the heading order is the one the app wrote rather than the alphabet.
659 let chrome = Chrome::new()
660 .bind_in(
661 "Toggles",
662 "s",
663 "Toggle the sidebar",
664 Action::post("/panels/sidebar"),
665 )
666 .bind_in(
667 "Bulk",
668 "f2",
669 "Rename the selection",
670 Action::get("/bulk/rename"),
671 )
672 .bind_in(
673 "Toggles",
674 "d",
675 "Toggle the detail panel",
676 Action::post("/panels/detail"),
677 );
678 let grouped = chrome.grouped();
679 assert_eq!(grouped.len(), 2);
680 assert_eq!(grouped[0].0, Some("Toggles"));
681 let toggles: Vec<_> = grouped[0].1.iter().map(|binding| &binding.key).collect();
682 assert_eq!(toggles, ["s", "d"]);
683 assert_eq!(grouped[1].0, Some("Bulk"));
684 assert_eq!(grouped[1].1.len(), 1);
685 }
686
687 #[test]
688 fn a_group_changes_the_listing_and_nothing_about_what_the_key_does() {
689 let chrome = Chrome::new().bind_in("System", "f1", "Show this help", Action::get("/help"));
690 let bound = chrome.bound("f1").expect("claimed");
691 assert_eq!(bound.group.as_deref(), Some("System"));
692 assert_eq!(bound.action, Action::get("/help"));
693 // And a key nobody grouped is still found the same way.
694 assert!(
695 Chrome::new()
696 .bind("f1", "Help", Action::get("/help"))
697 .bound("f1")
698 .is_some()
699 );
700 }
701
702 #[test]
703 fn an_app_with_no_chrome_claims_no_keys_and_keeps_nothing_on_screen() {
704 let chrome = Chrome::new();
705 assert!(chrome.bindings.is_empty());
706 assert!(chrome.bound("ctrl+k").is_none());
707 // The default has to be the old behaviour, or every renderer draws
708 // something new the moment this member arrives.
709 assert!(chrome.panels.is_empty());
710 assert!(chrome.nav.is_empty());
711 }
712
713 #[test]
714 fn a_panel_is_a_node_and_carries_the_address_answers_aim_at() {
715 let chrome = Chrome::new().presenting("timer", Role::Activity, Node::text("00:12:04"));
716 let panel = chrome.panel("timer").expect("declared");
717 assert_eq!(panel.id, "timer");
718 assert_eq!(panel.content, Node::text("00:12:04"));
719 assert_eq!(panel.role, Role::Activity);
720 }
721
722 #[test]
723 fn an_app_can_keep_more_than_one_thing_on_screen_and_says_what_each_is_for() {
724 // It replaced rather than appended until `71aa29b4`, because two
725 // anonymous panels would be a renderer placing them by declaration
726 // order. The role is what answers that, so the second one survives now.
727 let chrome = Chrome::new()
728 .presenting("timer", Role::Activity, Node::text("00:12:04"))
729 .presenting("sync", Role::Status, Node::text("Synced"));
730 assert_eq!(chrome.panels.len(), 2);
731 assert_eq!(
732 chrome.panel("timer").expect("declared").role,
733 Role::Activity
734 );
735 assert_eq!(chrome.panel("sync").expect("declared").role, Role::Status);
736 }
737
738 #[test]
739 fn a_nav_is_addresses_with_names_rather_than_content_that_is_always_there() {
740 // The distinction the member exists for: a renderer reads places and
741 // draws a tab bar, a sidebar or a tab line without being told which.
742 let chrome = Chrome::new()
743 .offering(Place::new("work", "Work", Action::get("/tasks")).within([
744 Place::new("tasks", "Tasks", Action::get("/tasks")),
745 Place::new("board", "Board", Action::get("/board")),
746 ]))
747 .offering(Place::new("time", "Time", Action::get("/day")));
748
749 assert_eq!(chrome.nav.len(), 2);
750 assert_eq!(chrome.nav[0].within.len(), 2);
751 // Order is declaration order, because that is the order it is offered.
752 assert_eq!(chrome.nav[1].key, "time");
753 // A group still goes somewhere: pressing it means its first sub-place.
754 assert_eq!(chrome.nav[0].action, Action::get("/tasks"));
755 }
756
757 #[test]
758 fn a_place_finds_itself_and_the_places_inside_it_and_no_deeper() {
759 let deep = Place::new("work", "Work", Action::get("/tasks")).within([Place::new(
760 "tasks",
761 "Tasks",
762 Action::get("/tasks"),
763 )
764 .within([Place::new("buried", "Buried", Action::get("/buried"))])]);
765
766 assert!(deep.holds("work"), "itself");
767 assert!(deep.holds("tasks"), "one level down");
768 // One level is the depth `within` describes, and the three renderers
769 // ask this rather than each walking the tree to a depth of its own.
770 assert!(!deep.holds("buried"), "no deeper");
771 }
772
773 #[test]
774 fn a_fresh_answer_lands_in_whichever_panel_it_names() {
775 let mut chrome = Chrome::new()
776 .presenting("timer", Role::Activity, Node::text("00:12:04"))
777 .presenting("sync", Role::Status, Node::text("Synced"));
778
779 assert!(chrome.replace("sync", Node::text("Syncing")));
780 assert_eq!(
781 chrome.panel("sync").map(|panel| &panel.content),
782 Some(&Node::text("Syncing"))
783 );
784 // And leaves the other alone, which is the whole reason they are two.
785 assert_eq!(
786 chrome.panel("timer").map(|panel| &panel.content),
787 Some(&Node::text("00:12:04"))
788 );
789 }
790
791 #[test]
792 fn a_fresh_answer_lands_in_the_panel_it_names_and_nowhere_else() {
793 let mut chrome = Chrome::new().presenting("timer", Role::Activity, Node::text("00:12:04"));
794 assert!(chrome.replace("timer", Node::text("00:12:05")));
795 assert_eq!(
796 chrome.panel("timer").map(|panel| &panel.content),
797 Some(&Node::text("00:12:05"))
798 );
799 // Not the panel, so the renderer can say so rather than swallowing it.
800 assert!(!chrome.replace("detail", Node::text("nope")));
801 assert!(!Chrome::new().replace("timer", Node::text("nope")));
802 }
803
804 #[test]
805 fn an_app_with_no_band_is_an_app_with_the_chrome_it_had_before_one_existed() {
806 // The default has to be the old behaviour, or every renderer draws a
807 // header the moment this member arrives.
808 assert!(Chrome::new().band.is_none());
809 assert!(
810 Chrome::new()
811 .offering(Place::new("work", "Work", Action::get("/tasks")))
812 .band
813 .is_none(),
814 "places without a band are still places"
815 );
816 }
817
818 #[test]
819 fn a_band_says_the_header_is_one_thing_and_leaves_the_nav_where_it_was() {
820 let chrome = Chrome::new()
821 .offering(Place::new("discover", "Discover", Action::get("/discover")))
822 .banded(
823 Band::new()
824 .branded(Brand::new("Makenot.work", Action::get("/")).marking("."))
825 .disclosing(Disclose::Narrow),
826 );
827 let band = chrome.band.as_ref().expect("declared");
828 assert_eq!(band.disclose, Disclose::Narrow);
829 // The places did not move into it. An app with a nav and no band still
830 // has a nav, which is why they are two members.
831 assert_eq!(chrome.nav.len(), 1);
832 assert!(band.search.is_none());
833 }
834
835 #[test]
836 fn a_wordmark_comes_apart_at_its_mark_once() {
837 let brand = Brand::new("Makenot.work", Action::get("/")).marking(".");
838 assert_eq!(brand.parts(), ("Makenot", ".", "work"));
839 }
840
841 #[test]
842 fn a_name_with_no_mark_is_the_whole_name_and_so_is_one_whose_mark_is_not_in_it() {
843 // One answer for all three renderers, so a webview, a terminal and an
844 // egui host cannot disagree about which run is marked.
845 let plain = Brand::new("Goingson", Action::get("/"));
846 assert_eq!(plain.parts(), ("Goingson", "", ""));
847 // A run the name does not contain marks nothing rather than failing:
848 // the name is the app's and so is this.
849 let wrong = Brand::new("Goingson", Action::get("/")).marking("@");
850 assert_eq!(wrong.parts(), ("Goingson", "", ""));
851 // And an empty mark is the same as no mark, rather than an empty span
852 // in front of the name.
853 let empty = Brand::new("Goingson", Action::get("/")).marking("");
854 assert_eq!(empty.parts(), ("Goingson", "", ""));
855 }
856
857 #[test]
858 fn only_the_first_occurrence_is_the_mark() {
859 // A wordmark has one mark. A rule that found every "." would mark both
860 // dots of a name that had two.
861 let brand = Brand::new("a.b.c", Action::get("/")).marking(".");
862 assert_eq!(brand.parts(), ("a", ".", "b.c"));
863 }
864
865 #[test]
866 fn a_search_box_in_the_band_is_an_ordinary_field() {
867 // The whole of what typing it as a `Field` buys: no renderer grows a
868 // second field emitter for a box that happens to be in the header.
869 let field = Field::new(crate::layout::FieldKind::Text, "q", "Search");
870 let band = Band::new().searching(field.clone());
871 assert_eq!(band.search.as_ref(), Some(&field));
872 assert_eq!(band.disclose, Disclose::Always, "the default is out");
873 }
874
875 #[test]
876 fn a_binding_carries_its_label_so_a_help_list_is_not_a_second_copy() {
877 let chrome = Chrome::new()
878 .bind("ctrl+k", "Search", Action::get("/palette"))
879 .bind("?", "Keys", Action::get("/help"));
880 let found = chrome.bound("ctrl+k").expect("bound");
881 assert_eq!(found.label, "Search");
882 assert_eq!(found.action, Action::get("/palette"));
883 assert_eq!(chrome.bindings.len(), 2);
884 }
885
886 #[test]
887 fn a_key_nothing_claimed_is_none_rather_than_a_guess() {
888 let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette"));
889 // Exact match: normalising case or modifier order would be this crate
890 // deciding what a key name looks like.
891 assert!(chrome.bound("Ctrl+K").is_none());
892 assert!(chrome.bound("ctrl+j").is_none());
893 }
894
895 #[test]
896 fn the_first_claim_on_a_key_wins() {
897 let chrome = Chrome::new()
898 .bind("ctrl+k", "Search", Action::get("/palette"))
899 .bind("ctrl+k", "Other", Action::get("/other"));
900 assert_eq!(chrome.bound("ctrl+k").expect("bound").label, "Search");
901 }
902 }
903