//! TRACES: FR-UI-5 | FR-DEV-16 //! The one place a key is spelled. //! //! A key reaches this repository in three spellings: the text Slint hands a //! `key-pressed` handler (`"\u{F702}"` for the left arrow, `"Z"` for a shifted //! z), the literal a handler compares against, and the words a `GESTURE:` tag //! uses to tell a person which key to press. The checker in //! [`crate::keymap`] only works if all three meet in one vocabulary, and this //! is it. //! //! # The canonical form //! //! `Ctrl+Alt+Shift+Meta+Key`, modifiers in that order, each present only when //! held. The key is one of: //! //! - a **named key** from [`NAMED`] — `Left`, `Enter`, `Escape`, `F1`, `Space` //! and the rest; //! - a **letter**, upper case: `Z`. Case is not a key — caps lock during a long //! cull must not stop `P` picking — so `z` and `Z` are one key, and shift is //! read from the modifier rather than from the case; //! - any **other printable character**, as itself: `[`, `6`, `\`, `=`, with //! `+` spelled `Plus` because it is the separator. //! //! **Shift and Alt count only for letters and named keys.** For every other //! character the layout has already applied them: `}` is what shift does to //! `]` on one keyboard and not another, and on the French layout this //! application's author uses, *every digit* needs shift. A binding of `6` has //! to answer the key that produces a 6, whatever it took to produce it, so the //! canonical form of a shifted 6 is `6` — and a spelling like `Shift+6` is //! refused rather than accepted as a binding no keyboard can reach. //! //! The same rules are implemented once more, in `ui/dr-ui/ui/keys.slint`, //! because the application compares keys in Slint and cannot call this crate. //! That copy is held to this one by the checker: it reads the names the Slint //! function can produce and fails if they are not exactly [`NAMED`], and it //! fails if a handler compares against a literal that is not canonical here. use std::fmt; /// A named key: its canonical spelling, what a person sees, and the other /// spellings accepted for it in a tag. /// /// `shown` is what the help sheet prints. The arrows get arrows; everything /// else is already the word on the keycap. pub struct Named { pub name: &'static str, pub shown: &'static str, pub aliases: &'static [&'static str], } /// Every named key a binding may use. Mirrored in `keys.slint`'s `named`. pub const NAMED: &[Named] = &[ Named { name: "Left", shown: "←", aliases: &["leftarrow", "left arrow", "←"], }, Named { name: "Right", shown: "→", aliases: &["rightarrow", "right arrow", "→"], }, Named { name: "Up", shown: "↑", aliases: &["uparrow", "up arrow", "↑"], }, Named { name: "Down", shown: "↓", aliases: &["downarrow", "down arrow", "↓"], }, Named { name: "Home", shown: "Home", aliases: &[], }, Named { name: "End", shown: "End", aliases: &[], }, Named { name: "PageUp", shown: "Page Up", aliases: &["page up", "pgup"], }, Named { name: "PageDown", shown: "Page Down", aliases: &["page down", "pgdn"], }, Named { name: "Enter", shown: "Enter", aliases: &["return"], }, Named { name: "Escape", shown: "Escape", aliases: &["esc"], }, Named { name: "Back", shown: "Back", aliases: &[], }, Named { name: "Tab", shown: "Tab", aliases: &["backtab"], }, Named { name: "Backspace", shown: "Backspace", aliases: &[], }, Named { name: "Delete", shown: "Delete", aliases: &["del"], }, Named { name: "Insert", shown: "Insert", aliases: &["ins"], }, Named { name: "Space", shown: "Space", aliases: &["spacebar", " "], }, Named { name: "F1", shown: "F1", aliases: &[], }, Named { name: "F2", shown: "F2", aliases: &[], }, Named { name: "F3", shown: "F3", aliases: &[], }, Named { name: "F4", shown: "F4", aliases: &[], }, Named { name: "F5", shown: "F5", aliases: &[], }, Named { name: "F6", shown: "F6", aliases: &[], }, Named { name: "F7", shown: "F7", aliases: &[], }, Named { name: "F8", shown: "F8", aliases: &[], }, Named { name: "F9", shown: "F9", aliases: &[], }, Named { name: "F10", shown: "F10", aliases: &[], }, Named { name: "F11", shown: "F11", aliases: &[], }, Named { name: "F12", shown: "F12", aliases: &[], }, ]; /// One key with the modifiers held for it. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Chord { pub ctrl: bool, pub alt: bool, pub shift: bool, pub meta: bool, /// Canonical: a [`NAMED`] name, an upper-case letter, `Plus`, or the /// printable character itself. pub key: String, } impl Chord { /// Parse any accepted spelling: `Ctrl+z`, `Control+Z`, `ctrl+shift+z`, /// `LeftArrow`, `←`, `Esc`, `Ctrl++`. pub fn parse(spelling: &str) -> Result { let s = spelling.trim(); if s.is_empty() { return Err("an empty key".into()); } // `+` is both the separator and a key. A spelling ending in `++`, or // that is `+` alone, has the plus key as its last part. let (mods, key) = if s == "+" { ("", "+") } else if let Some(head) = s.strip_suffix("++") { (head, "+") } else { match s.rsplit_once('+') { Some((head, key)) => (head, key), None => ("", s), } }; let mut c = Chord { ctrl: false, alt: false, shift: false, meta: false, key: String::new(), }; if !mods.is_empty() { for m in mods.split('+') { let slot = match m.trim().to_ascii_lowercase().as_str() { "ctrl" | "control" | "ctl" => &mut c.ctrl, "alt" | "option" | "opt" => &mut c.alt, "shift" => &mut c.shift, "meta" | "super" | "win" => &mut c.meta, other => return Err(format!("`{other}` is not a modifier in `{spelling}`")), }; if *slot { return Err(format!("a modifier named twice in `{spelling}`")); } *slot = true; } } let (key, counts_shift) = canonical_key(key) .ok_or_else(|| format!("`{}` is not a key this vocabulary knows", key.trim()))?; if !counts_shift && (c.shift || c.alt) { return Err(format!( "`{spelling}`: shift and alt are part of the character on a printable key \ (the layout decides what they make), so they cannot be bound — write the \ character itself" )); } c.key = key; Ok(c) } /// The canonical spelling: what `Keys.chord` in `keys.slint` produces and /// what a handler's literal must be. pub fn canonical(&self) -> String { self.with(&self.key) } /// What the help sheet prints: canonical, with arrows drawn as arrows. pub fn shown(&self) -> String { let key = NAMED .iter() .find(|n| n.name == self.key) .map_or(self.key.as_str(), |n| n.shown); self.with(key) } fn with(&self, key: &str) -> String { let mut s = String::new(); for (on, word) in [ (self.ctrl, "Ctrl+"), (self.alt, "Alt+"), (self.shift, "Shift+"), (self.meta, "Meta+"), ] { if on { s.push_str(word); } } s.push_str(key); s } } impl fmt::Display for Chord { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.canonical()) } } /// The canonical key for one spelling, and whether shift and alt count for it. fn canonical_key(spelling: &str) -> Option<(String, bool)> { // A lone space is a key and must not be trimmed away; anything longer is // a word and its padding is not part of it. let raw = if spelling == " " { spelling } else { spelling.trim() }; let lower = raw.to_lowercase(); for n in NAMED { if n.name.to_ascii_lowercase() == lower || n.aliases.contains(&lower.as_str()) { return Some((n.name.to_string(), true)); } } if lower == "plus" { return Some(("Plus".into(), false)); } let mut chars = raw.chars(); let c = chars.next()?; if chars.next().is_some() { return None; } if c == '+' { return Some(("Plus".into(), false)); } if c.is_control() { return None; } let upper: String = c.to_uppercase().collect(); let lower: String = c.to_lowercase().collect(); if upper != lower { Some((upper, true)) } else { Some((c.to_string(), false)) } } /// Every chord a `keys:` field names, in order, or why one would not parse. /// /// A chord is written between backticks — `` `Ctrl+Z` `` — which is what lets /// the field stay a sentence for a person ("the same key again takes it off") /// while every key in it is still read by a machine. Two single-character /// chords joined by an en dash or " to " are a range: `` `0`–`5` `` names all /// six digits, because writing out six backticked digits helps nobody. pub fn chords_in(keys: &str) -> Result, String> { let parts: Vec<&str> = keys.split('`').collect(); if parts.len().is_multiple_of(2) { return Err("an unclosed backtick".into()); } let tokens: Vec<(usize, &str)> = parts .iter() .enumerate() .filter(|(i, _)| i % 2 == 1) .map(|(i, t)| (i, *t)) .collect(); if tokens.is_empty() { return Err( "names no key — write each key between backticks, e.g. `Ctrl+Z`, so the \ checker can hold it to a handler" .into(), ); } let mut out: Vec = Vec::new(); let mut prev: Option<(usize, Chord)> = None; for (i, tok) in tokens { let chord = Chord::parse(tok)?; if let Some((pi, p)) = &prev { let between = parts[pi + 1]; if *pi + 2 == i && (between == "–" || between == " to ") { for mid in range_between(p, &chord)? { out.push(mid); } } } out.push(chord.clone()); prev = Some((i, chord)); } Ok(out) } /// The chords strictly between two ends of a written range. fn range_between(a: &Chord, b: &Chord) -> Result, String> { let (ca, cb) = (single(&a.key), single(&b.key)); let (Some(ca), Some(cb)) = (ca, cb) else { return Err(format!( "`{a}`–`{b}` is not a range: only single characters make one" )); }; if !(ca.is_ascii_alphanumeric() && cb.is_ascii_alphanumeric()) || ca >= cb { return Err(format!("`{a}`–`{b}` is not an ascending range")); } Ok(((ca as u8 + 1)..(cb as u8)) .map(|m| Chord { key: (m as char).to_string(), ..a.clone() }) .collect()) } fn single(s: &str) -> Option { let mut c = s.chars(); let first = c.next()?; c.next().is_none().then_some(first) } /// A `keys:` field as a person reads it: every chord in its shown form. /// /// `markdown` keeps the backticks, so the document sets keys as code; the /// application's sheet drops them. A chord that does not parse is left as /// written — the checker reports it, and rendering is not the place to fail. pub fn render_keys(keys: &str, markdown: bool) -> String { let mut out = String::new(); for (i, part) in keys.split('`').enumerate() { if i % 2 == 0 { out.push_str(part); continue; } let shown = Chord::parse(part).map_or_else(|_| part.to_string(), |c| c.shown()); if markdown { out.push('`'); out.push_str(&shown); out.push('`'); } else { out.push_str(&shown); } } out } #[cfg(test)] mod tests { use super::*; fn canon(s: &str) -> String { Chord::parse(s).unwrap().canonical() } /// The spellings a person writes for one key all meet in one place. #[test] fn spellings_of_one_chord_agree() { for s in ["Ctrl+Z", "Control+z", "ctrl+z", " Ctrl + Z "] { assert_eq!(canon(s), "Ctrl+Z", "{s}"); } for s in ["Left", "LeftArrow", "←", "left arrow"] { assert_eq!(canon(s), "Left", "{s}"); } assert_eq!(canon("Shift+Ctrl+z"), "Ctrl+Shift+Z"); assert_eq!(canon("Esc"), "Escape"); assert_eq!(canon("Return"), "Enter"); assert_eq!(canon("PgUp"), "PageUp"); } #[test] fn plus_is_a_key_as_well_as_the_separator() { assert_eq!(canon("Ctrl++"), "Ctrl+Plus"); assert_eq!(canon("+"), "Plus"); assert_eq!(canon("Ctrl+Plus"), "Ctrl+Plus"); assert_eq!(canon("Ctrl+-"), "Ctrl+-"); assert_eq!(canon("Ctrl+="), "Ctrl+="); } /// The French layout needs shift for every digit, so a binding of `6` is /// the character, and `Shift+6` is a binding no handler could receive. #[test] fn shift_on_a_printable_character_is_refused() { assert!(Chord::parse("Shift+6").is_err()); assert!(Chord::parse("Alt+[").is_err()); assert_eq!(canon("Shift+Left"), "Shift+Left"); assert_eq!(canon("Shift+a"), "Shift+A"); assert_eq!(canon("Ctrl+0"), "Ctrl+0"); } #[test] fn nonsense_is_refused() { assert!(Chord::parse("").is_err()); assert!(Chord::parse("Hyper+Z").is_err()); assert!(Chord::parse("Ctrl+Ctrl+Z").is_err()); assert!(Chord::parse("Banana").is_err()); } #[test] fn the_sheet_draws_arrows() { assert_eq!(Chord::parse("Shift+Left").unwrap().shown(), "Shift+←"); assert_eq!( render_keys("`Right` or `D` for the next", false), "→ or D for the next" ); assert_eq!(render_keys("`ctrl+z`", true), "`Ctrl+Z`"); } #[test] fn a_field_names_its_chords_between_backticks() { let c = chords_in("`Right`, `D` or `Space` for the next").unwrap(); let names: Vec = c.iter().map(Chord::canonical).collect(); assert_eq!(names, ["Right", "D", "Space"]); } #[test] fn a_dash_between_two_digits_is_a_range() { let c = chords_in("`0`–`5` with the pointer over it").unwrap(); let names: Vec = c.iter().map(Chord::canonical).collect(); assert_eq!(names, ["0", "1", "2", "3", "4", "5"]); let c = chords_in("`6` red, `9` blue").unwrap(); assert_eq!(c.len(), 2, "a comma is not a range"); } /// Prose naming a key without backticks would be a key nobody checks. #[test] fn a_field_with_no_backticked_key_is_refused() { assert!(chords_in("Hold backslash").is_err()); assert!(chords_in("`Ctrl+Z").is_err()); } }