//! The app's own affordances, emitted once per document. //! //! [`Chrome`] names keys that work from every screen and what they call. This //! is the webview's answer to them: one hidden element per binding, carrying //! the same transport attributes any control gets, fired by a key event on the //! body rather than by a click on itself. No custom JS, and no per-app copy of //! the palette's plumbing. //! //! Plus the overlay container, which is where an //! [`Outcome::Over`](quasi_router::Outcome::Over) lands. It is emitted empty //! and stays empty until something is drawn into it, so a document with chrome //! and no overlay open is a document with one spare `div` in it. //! //! # Interpreting a key name is this crate's job, not the description's //! //! [`Binding::key`](quasi_router::Binding::key) is text — "ctrl+k", "?" — //! because the vocabulary of keys is the host's. This is a host, so here is //! where the text becomes something concrete: an htmx trigger filter over //! `KeyboardEvent`. A name this renderer cannot parse is ignored, which is the //! rule `Act::key` already states for a key one host wants and another has //! never heard of. use makeover_webview::form::escape_into; use quasi_router::{Binding, Chrome}; use crate::node::{Fires, action_attrs}; /// The element an overlay is drawn into. /// /// A fixed id rather than a configurable one: the router names the outcome and /// the renderer names the place, and a host that could rename it is a host that /// can rename it to something the retarget header does not point at. pub const OVERLAY_ID: &str = "quasi-overlay"; /// The bindings and the overlay container, for the end of a document's body. /// /// Empty when the app declares no chrome. An app that declares none gets a /// document byte-for-byte the same as before chrome existed, which is what /// makes this additive. pub(crate) fn chrome_html(chrome: &Chrome, morphs: bool, out: &mut String) { if chrome.bindings.is_empty() { return; } for binding in &chrome.bindings { binding_html(binding, morphs, out); } // Emitted only alongside bindings, because a document with no way to open // an overlay has nothing to put in one. A host driving `Outcome::Over` from // somewhere other than a binding declares a binding for it. out.push_str("
"); } /// One binding: the transport of a control, the trigger of a keystroke. fn binding_html(binding: &Binding, morphs: bool, out: &mut String) { let Some(filter) = trigger_filter(&binding.key) else { // A key name this renderer does not understand. Ignored rather than // guessed at, and ignored silently for the same reason a webview // ignores a key a terminal wanted: it is not this host's keyboard. return; }; out.push_str(""); } /// A key name as an htmx trigger filter, or `None` if it is not one. /// /// `"ctrl+k"` becomes `key=='k'&&ctrlKey&&!altKey&&!metaKey`. The negatives are /// stated rather than left open: without them `ctrl+k` also fires on /// `ctrl+alt+k`, and an app that bound both would fire both. fn trigger_filter(key: &str) -> Option { let mut ctrl = false; let mut alt = false; let mut shift = false; let mut meta = false; let mut base = None; for part in key.split('+') { let part = part.trim(); if part.is_empty() { return None; } match part.to_ascii_lowercase().as_str() { "ctrl" | "control" => ctrl = true, "alt" | "option" => alt = true, "shift" => shift = true, "meta" | "cmd" | "super" => meta = true, // The last non-modifier wins nothing: two of them is a name this // renderer does not understand, not a chord it can guess at. _ if base.is_some() => return None, _ => base = Some(part.to_string()), } } let base = base?; // A single printable character, or a name the DOM already uses for a key // that prints nothing. `KeyboardEvent.key` is what both are compared // against, and its names are capitalised. let value = if base.chars().count() == 1 { base } else { named_key(&base)? }; let mut filter = format!("key=='{}'", js_string(&value)); for (held, name) in [ (ctrl, "ctrlKey"), (alt, "altKey"), (meta, "metaKey"), (shift, "shiftKey"), ] { // Shift is asserted when asked for and never denied: a printable key // that needs shift to type reports it held, so `?` on a US layout // arrives as shift+/ and denying shift would make it unreachable. if held { filter.push_str("&&"); filter.push_str(name); } else if name != "shiftKey" { filter.push_str("&&!"); filter.push_str(name); } } Some(filter) } /// The `KeyboardEvent.key` name for a key that prints nothing. /// /// A short list rather than every name in the spec: these are the ones a /// description plausibly binds, and a name absent here is ignored rather than /// passed through. Passing an unknown name through would emit a filter that /// silently never matches, which is worse than not emitting one. fn named_key(name: &str) -> Option { let named = match name { "escape" | "esc" => "Escape", "enter" | "return" => "Enter", "tab" => "Tab", "space" => " ", "backspace" => "Backspace", "delete" | "del" => "Delete", "up" | "arrowup" => "ArrowUp", "down" | "arrowdown" => "ArrowDown", "left" | "arrowleft" => "ArrowLeft", "right" | "arrowright" => "ArrowRight", "home" => "Home", "end" => "End", "pageup" => "PageUp", "pagedown" => "PageDown", _ => return None, }; Some(named.to_string()) } /// A key value as the inside of a single-quoted JS string. /// /// The value reaches the browser inside an attribute inside a filter, so it is /// escaped twice by two different rules: this one, then HTML escaping by /// `action_attrs`. A key of `'` is the case that needs it. fn js_string(value: &str) -> String { value.replace('\\', "\\\\").replace('\'', "\\'") } #[cfg(test)] mod tests { use super::*; #[test] fn a_modifier_key_names_what_is_held_and_what_is_not() { let filter = trigger_filter("ctrl+k").expect("parsed"); assert!(filter.contains("key=='k'")); assert!(filter.contains("&&ctrlKey")); // Stated, so ctrl+alt+k does not also fire a ctrl+k binding. assert!(filter.contains("&&!altKey")); assert!(filter.contains("&&!metaKey")); } #[test] fn shift_is_asserted_when_asked_for_and_never_denied() { // A printable key that needs shift to type reports it held, so denying // it would make `?` unreachable on a US layout. let plain = trigger_filter("?").expect("parsed"); assert!(!plain.contains("shiftKey")); let held = trigger_filter("shift+a").expect("parsed"); assert!(held.contains("&&shiftKey")); assert!(!held.contains("!shiftKey")); } #[test] fn a_key_that_prints_nothing_is_named_the_way_the_dom_names_it() { assert!( trigger_filter("escape") .expect("parsed") .contains("'Escape'") ); assert!( trigger_filter("arrowup") .expect("parsed") .contains("'ArrowUp'") ); } #[test] fn a_name_this_renderer_does_not_understand_is_ignored() { // Not guessed at: an unknown name passed through would emit a filter // that silently never matches. assert!(trigger_filter("dpad-left").is_none()); assert!(trigger_filter("ctrl+j+k").is_none()); assert!(trigger_filter("ctrl+").is_none()); assert!(trigger_filter("").is_none()); } #[test] fn a_quote_in_a_key_cannot_close_the_filter_it_sits_in() { let filter = trigger_filter("'").expect("parsed"); assert!(filter.contains("\\'"), "{filter}"); } #[test] fn an_app_with_no_chrome_emits_nothing_at_all() { let mut out = String::new(); chrome_html(&Chrome::new(), false, &mut out); assert!(out.is_empty()); } #[test] fn a_binding_carries_the_transport_and_the_overlay_lands_in_the_container() { use quasi_router::Action; let chrome = Chrome::new().bind("ctrl+k", "Search", Action::get("/palette")); let mut out = String::new(); chrome_html(&chrome, false, &mut out); assert!(out.contains("hx-get=\"/palette\""), "{out}"); assert!(out.contains("hx-target=\"#quasi-overlay\""), "{out}"); assert!(out.contains("from:body"), "{out}"); assert!(out.contains("aria-label=\"Search\""), "{out}"); assert!(out.contains("
"), "{out}"); } #[test] fn a_binding_this_renderer_cannot_read_leaves_the_rest_working() { use quasi_router::Action; let chrome = Chrome::new() .bind("dpad-left", "Nope", Action::get("/nope")) .bind("ctrl+k", "Search", Action::get("/palette")); let mut out = String::new(); chrome_html(&chrome, false, &mut out); assert!(!out.contains("/nope"), "{out}"); assert!(out.contains("/palette"), "{out}"); } }