//! 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;
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}");
}
}