Skip to main content

max / quasi

5.9 KB · 151 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 use crate::screen::Action;
32
33 /// The affordances the app offers from every screen.
34 ///
35 /// Built once by the app and held beside the [`Router`](crate::Router), never
36 /// per request. That is what "outlives any one screen" means concretely: a
37 /// request answers with a screen, and this is not part of that answer.
38 ///
39 /// Beside the router rather than inside it, decided while building this: a
40 /// `Router` is a route table, and a key binding is not a route. The two are
41 /// held together by whatever the host is, which already holds both. Nothing
42 /// here would break if it moved inside, so this is a tidiness argument rather
43 /// than a correctness one.
44 ///
45 /// An app declaring no chrome behaves exactly as it did before this existed.
46 #[derive(Debug, Clone, Default, PartialEq, Eq)]
47 pub struct Chrome {
48 /// The keys that work from anywhere.
49 pub bindings: Vec<Binding>,
50 }
51
52 /// A key that works from every screen, and what it calls.
53 #[derive(Debug, Clone, PartialEq, Eq)]
54 pub struct Binding {
55 /// The key, as text.
56 ///
57 /// Text rather than a modelled chord — "ctrl+k", "?" — for the reason
58 /// [`Act::key`](crate::Act::key) is: the vocabulary of keys is the host's,
59 /// and a description that modelled it would be naming one host's keyboard.
60 /// A renderer that does not know a name ignores it, which is what a webview
61 /// does with a key a terminal wants.
62 pub key: String,
63 /// What a shortcuts list shows for it.
64 ///
65 /// The reason this is a struct and not a `(String, Action)` pair. A help
66 /// overlay that lists the bindings is otherwise a second, hand-written copy
67 /// of them, free to drift from what the keys actually do.
68 pub label: String,
69 /// What pressing it calls.
70 ///
71 /// Ordinarily a route answering with [`Response::over`](crate::Response::over),
72 /// which is what makes the palette an overlay rather than a navigation. It
73 /// is not required to: a binding that navigates is a binding that navigates.
74 pub action: Action,
75 }
76
77 impl Chrome {
78 /// No chrome. What an app that declares none has.
79 #[must_use]
80 pub fn new() -> Self {
81 Self::default()
82 }
83
84 /// Add a key that works from every screen.
85 #[must_use]
86 pub fn bind(
87 mut self,
88 key: impl Into<String>,
89 label: impl Into<String>,
90 action: Action,
91 ) -> Self {
92 self.bindings.push(Binding {
93 key: key.into(),
94 label: label.into(),
95 action,
96 });
97 self
98 }
99
100 /// What the key calls, if anything claimed it.
101 ///
102 /// First match wins, so an app that binds one key twice gets the one it
103 /// declared first rather than an error. Matching is exact: normalising
104 /// "ctrl+k" against "Ctrl+K" would be this crate deciding what a key name
105 /// looks like, which is the host's to decide.
106 #[must_use]
107 pub fn bound(&self, key: &str) -> Option<&Binding> {
108 self.bindings.iter().find(|binding| binding.key == key)
109 }
110 }
111
112 #[cfg(test)]
113 mod tests {
114 use super::*;
115
116 #[test]
117 fn an_app_with_no_chrome_claims_no_keys() {
118 let chrome = Chrome::new();
119 assert!(chrome.bindings.is_empty());
120 assert!(chrome.bound("ctrl+k").is_none());
121 }
122
123 #[test]
124 fn a_binding_carries_its_label_so_a_help_list_is_not_a_second_copy() {
125 let chrome = Chrome::new()
126 .bind("ctrl+k", "Search", Action::get("/palette"))
127 .bind("?", "Keys", Action::get("/help"));
128 let found = chrome.bound("ctrl+k").expect("bound");
129 assert_eq!(found.label, "Search");
130 assert_eq!(found.action, Action::get("/palette"));
131 assert_eq!(chrome.bindings.len(), 2);
132 }
133
134 #[test]
135 fn a_key_nothing_claimed_is_none_rather_than_a_guess() {
136 let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette"));
137 // Exact match: normalising case or modifier order would be this crate
138 // deciding what a key name looks like.
139 assert!(chrome.bound("Ctrl+K").is_none());
140 assert!(chrome.bound("ctrl+j").is_none());
141 }
142
143 #[test]
144 fn the_first_claim_on_a_key_wins() {
145 let chrome = Chrome::new()
146 .bind("ctrl+k", "Search", Action::get("/palette"))
147 .bind("ctrl+k", "Other", Action::get("/other"));
148 assert_eq!(chrome.bound("ctrl+k").expect("bound").label, "Search");
149 }
150 }
151