Skip to main content

max / quasi

9.9 KB · 271 lines History Blame Raw
1 //! The app's own affordances, emitted once per document.
2 //!
3 //! [`Chrome`] names keys that work from every screen and what they call. This
4 //! is the webview's answer to them: one hidden element per binding, carrying
5 //! the same transport attributes any control gets, fired by a key event on the
6 //! body rather than by a click on itself. No custom JS, and no per-app copy of
7 //! the palette's plumbing.
8 //!
9 //! Plus the overlay container, which is where an
10 //! [`Outcome::Over`](quasi_router::Outcome::Over) lands. It is emitted empty
11 //! and stays empty until something is drawn into it, so a document with chrome
12 //! and no overlay open is a document with one spare `div` in it.
13 //!
14 //! # Interpreting a key name is this crate's job, not the description's
15 //!
16 //! [`Binding::key`](quasi_router::Binding::key) is text — "ctrl+k", "?" —
17 //! because the vocabulary of keys is the host's. This is a host, so here is
18 //! where the text becomes something concrete: an htmx trigger filter over
19 //! `KeyboardEvent`. A name this renderer cannot parse is ignored, which is the
20 //! rule `Act::key` already states for a key one host wants and another has
21 //! never heard of.
22
23 use makeover_webview::form::escape;
24 use quasi_router::{Binding, Chrome};
25
26 use crate::node::{Fires, action_attrs};
27
28 /// The element an overlay is drawn into.
29 ///
30 /// A fixed id rather than a configurable one: the router names the outcome and
31 /// the renderer names the place, and a host that could rename it is a host that
32 /// can rename it to something the retarget header does not point at.
33 pub const OVERLAY_ID: &str = "quasi-overlay";
34
35 /// The bindings and the overlay container, for the end of a document's body.
36 ///
37 /// Empty when the app declares no chrome. An app that declares none gets a
38 /// document byte-for-byte the same as before chrome existed, which is what
39 /// makes this additive.
40 pub(crate) fn chrome_html(chrome: &Chrome, morphs: bool, out: &mut String) {
41 if chrome.bindings.is_empty() {
42 return;
43 }
44 for binding in &chrome.bindings {
45 binding_html(binding, morphs, out);
46 }
47 // Emitted only alongside bindings, because a document with no way to open
48 // an overlay has nothing to put in one. A host driving `Outcome::Over` from
49 // somewhere other than a binding declares a binding for it.
50 out.push_str("<div id=\"");
51 out.push_str(OVERLAY_ID);
52 out.push_str("\"></div>");
53 }
54
55 /// One binding: the transport of a control, the trigger of a keystroke.
56 fn binding_html(binding: &Binding, morphs: bool, out: &mut String) {
57 let Some(filter) = trigger_filter(&binding.key) else {
58 // A key name this renderer does not understand. Ignored rather than
59 // guessed at, and ignored silently for the same reason a webview
60 // ignores a key a terminal wanted: it is not this host's keyboard.
61 return;
62 };
63
64 out.push_str("<button type=\"button\" hidden data-chrome aria-label=\"");
65 out.push_str(&escape(&binding.label));
66 out.push('"');
67
68 // The answer goes into the overlay container. A binding whose route
69 // answers with something other than `Over` overrides this by naming its
70 // own target on the action, which `action_attrs` emits after this.
71 out.push_str(" hx-target=\"#");
72 out.push_str(OVERLAY_ID);
73 out.push('"');
74
75 action_attrs(
76 &binding.action,
77 Fires::Key(&filter),
78 None,
79 morphs,
80 None,
81 out,
82 );
83 out.push_str("></button>");
84 }
85
86 /// A key name as an htmx trigger filter, or `None` if it is not one.
87 ///
88 /// `"ctrl+k"` becomes `key=='k'&&ctrlKey&&!altKey&&!metaKey`. The negatives are
89 /// stated rather than left open: without them `ctrl+k` also fires on
90 /// `ctrl+alt+k`, and an app that bound both would fire both.
91 fn trigger_filter(key: &str) -> Option<String> {
92 let mut ctrl = false;
93 let mut alt = false;
94 let mut shift = false;
95 let mut meta = false;
96 let mut base = None;
97
98 for part in key.split('+') {
99 let part = part.trim();
100 if part.is_empty() {
101 return None;
102 }
103 match part.to_ascii_lowercase().as_str() {
104 "ctrl" | "control" => ctrl = true,
105 "alt" | "option" => alt = true,
106 "shift" => shift = true,
107 "meta" | "cmd" | "super" => meta = true,
108 // The last non-modifier wins nothing: two of them is a name this
109 // renderer does not understand, not a chord it can guess at.
110 _ if base.is_some() => return None,
111 _ => base = Some(part.to_string()),
112 }
113 }
114
115 let base = base?;
116 // A single printable character, or a name the DOM already uses for a key
117 // that prints nothing. `KeyboardEvent.key` is what both are compared
118 // against, and its names are capitalised.
119 let value = if base.chars().count() == 1 {
120 base
121 } else {
122 named_key(&base)?
123 };
124
125 let mut filter = format!("key=='{}'", js_string(&value));
126 for (held, name) in [
127 (ctrl, "ctrlKey"),
128 (alt, "altKey"),
129 (meta, "metaKey"),
130 (shift, "shiftKey"),
131 ] {
132 // Shift is asserted when asked for and never denied: a printable key
133 // that needs shift to type reports it held, so `?` on a US layout
134 // arrives as shift+/ and denying shift would make it unreachable.
135 if held {
136 filter.push_str("&&");
137 filter.push_str(name);
138 } else if name != "shiftKey" {
139 filter.push_str("&&!");
140 filter.push_str(name);
141 }
142 }
143 Some(filter)
144 }
145
146 /// The `KeyboardEvent.key` name for a key that prints nothing.
147 ///
148 /// A short list rather than every name in the spec: these are the ones a
149 /// description plausibly binds, and a name absent here is ignored rather than
150 /// passed through. Passing an unknown name through would emit a filter that
151 /// silently never matches, which is worse than not emitting one.
152 fn named_key(name: &str) -> Option<String> {
153 let named = match name {
154 "escape" | "esc" => "Escape",
155 "enter" | "return" => "Enter",
156 "tab" => "Tab",
157 "space" => " ",
158 "backspace" => "Backspace",
159 "delete" | "del" => "Delete",
160 "up" | "arrowup" => "ArrowUp",
161 "down" | "arrowdown" => "ArrowDown",
162 "left" | "arrowleft" => "ArrowLeft",
163 "right" | "arrowright" => "ArrowRight",
164 "home" => "Home",
165 "end" => "End",
166 "pageup" => "PageUp",
167 "pagedown" => "PageDown",
168 _ => return None,
169 };
170 Some(named.to_string())
171 }
172
173 /// A key value as the inside of a single-quoted JS string.
174 ///
175 /// The value reaches the browser inside an attribute inside a filter, so it is
176 /// escaped twice by two different rules: this one, then HTML escaping by
177 /// `action_attrs`. A key of `'` is the case that needs it.
178 fn js_string(value: &str) -> String {
179 value.replace('\\', "\\\\").replace('\'', "\\'")
180 }
181
182 #[cfg(test)]
183 mod tests {
184 use super::*;
185
186 #[test]
187 fn a_modifier_key_names_what_is_held_and_what_is_not() {
188 let filter = trigger_filter("ctrl+k").expect("parsed");
189 assert!(filter.contains("key=='k'"));
190 assert!(filter.contains("&&ctrlKey"));
191 // Stated, so ctrl+alt+k does not also fire a ctrl+k binding.
192 assert!(filter.contains("&&!altKey"));
193 assert!(filter.contains("&&!metaKey"));
194 }
195
196 #[test]
197 fn shift_is_asserted_when_asked_for_and_never_denied() {
198 // A printable key that needs shift to type reports it held, so denying
199 // it would make `?` unreachable on a US layout.
200 let plain = trigger_filter("?").expect("parsed");
201 assert!(!plain.contains("shiftKey"));
202 let held = trigger_filter("shift+a").expect("parsed");
203 assert!(held.contains("&&shiftKey"));
204 assert!(!held.contains("!shiftKey"));
205 }
206
207 #[test]
208 fn a_key_that_prints_nothing_is_named_the_way_the_dom_names_it() {
209 assert!(
210 trigger_filter("escape")
211 .expect("parsed")
212 .contains("'Escape'")
213 );
214 assert!(
215 trigger_filter("arrowup")
216 .expect("parsed")
217 .contains("'ArrowUp'")
218 );
219 }
220
221 #[test]
222 fn a_name_this_renderer_does_not_understand_is_ignored() {
223 // Not guessed at: an unknown name passed through would emit a filter
224 // that silently never matches.
225 assert!(trigger_filter("dpad-left").is_none());
226 assert!(trigger_filter("ctrl+j+k").is_none());
227 assert!(trigger_filter("ctrl+").is_none());
228 assert!(trigger_filter("").is_none());
229 }
230
231 #[test]
232 fn a_quote_in_a_key_cannot_close_the_filter_it_sits_in() {
233 let filter = trigger_filter("'").expect("parsed");
234 assert!(filter.contains("\\'"), "{filter}");
235 }
236
237 #[test]
238 fn an_app_with_no_chrome_emits_nothing_at_all() {
239 let mut out = String::new();
240 chrome_html(&Chrome::new(), false, &mut out);
241 assert!(out.is_empty());
242 }
243
244 #[test]
245 fn a_binding_carries_the_transport_and_the_overlay_lands_in_the_container() {
246 use quasi_router::Action;
247
248 let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette"));
249 let mut out = String::new();
250 chrome_html(&chrome, false, &mut out);
251 assert!(out.contains("hx-get=\"/palette\""), "{out}");
252 assert!(out.contains("hx-target=\"#quasi-overlay\""), "{out}");
253 assert!(out.contains("from:body"), "{out}");
254 assert!(out.contains("aria-label=\"Search\""), "{out}");
255 assert!(out.contains("<div id=\"quasi-overlay\"></div>"), "{out}");
256 }
257
258 #[test]
259 fn a_binding_this_renderer_cannot_read_leaves_the_rest_working() {
260 use quasi_router::Action;
261
262 let chrome = Chrome::new()
263 .bind("dpad-left", "Nope", Action::get("/nope"))
264 .bind("ctrl+k", "Search", Action::get("/palette"));
265 let mut out = String::new();
266 chrome_html(&chrome, false, &mut out);
267 assert!(!out.contains("/nope"), "{out}");
268 assert!(out.contains("/palette"), "{out}");
269 }
270 }
271