mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-05 10:10:46 +00:00
* Add craft-menu: Mac menu-bar layout and native-menu driver A platform-free menu model (built directly or from flat rows), the macOS layout (app menu, Window, Help, system-key clash resolution, translatable labels), and NativeMenu, which feeds a backend's key equivalents to egui as key presses, runs Minimize and craft-quit's Quit, and re-syncs only when the app's state changes. Generalised from PhotoCraft's native_menu.rs. * craft-menu: macOS menu bar through muda * craft-menu: clipboard keys, layout-assigned shortcuts, Hide Others and atomic rebuilds * craft-menu: menu key equivalents run while a text field has the keys * craft-menu: commands as CommandRef, item kinds, craft-keymap chords Items and rows run a craft_keymap::CommandRef and take a craft_keymap::Chord shortcut; crates/menu/src/chord.rs is gone. Native ids are the command's key, made unique with #2, #3 when a command is shown twice. Item.checked becomes ItemKind { Plain, Check, Radio } (radio shows as a check item on macOS). The built-in items' commands are Builtin::{Hide, HideOthers, Minimize, Quit}. MenuRole gains Edit, View and Other; tag_submenu finds its menu by role. While text editing, the keys left to the field are the clipboard keys and craft_keymap::text_editing_chords. Adds the Platforms, Document and Gate types (data only) and MenuBar::documents. * craft-menu: Gate::allows, the one rule for what may run from the menus * craft-menu: Windows, Linux and web layouts, documents in Window, keymap adapter layout(bar, Target, options) dispatches to mac_layout, desktop_layout or web_layout. Every layout drops what the target's Platforms leave out and lists MenuBar::documents as radio items last in the Window menu (ItemRole::Document is gone). Windows and Linux put About in Help, Settings in Edit as Preferences and Quit in File (Exit on Windows); the web layout has no Quit and takes the browser's own shortcuts off the items, reporting clashes. tidy drops empty submenus. apply_keymap sets shortcuts and toggle check items from a craft-keymap Keymap; apply_state sets enabled and checked per frame. * craft-menu: the egui-drawn menu bar for Windows, Linux and the web * craft-menu: gate, command hook and one muda handler for the native menu - NativeMenu::set_gate: app items greyed in place while modal or recording a shortcut; take_clicks drops clicks the gate forbids when they are taken. While recording, key equivalents reach egui as plain key presses. - NativeMenu::on_command: a hook called from the menu event handler for each allowed app click; take_clicks still returns every command. - Dispatch, shared between the driver and the backend's handler (Backend::attach replaces Backend::set_text_editing). - Only Cmd or Ctrl chords become key equivalents; other shortcuts are not shown natively (building an attributed title needs unsafe objc2 calls). - mac: one muda event handler per process, routed to the latest install; mac::install_app_menu ports craft-quit's minimal app menu onto it. - craft_quit::install_app_menu is deprecated in favor of craft_menu::mac::install_app_menu. * craft-menu: the bar takes Target, README section and NOTICE credit * craft-menu: bar tests beside the layout and native ones; only a click outside closes the menus * craft-menu: clippy (complex types, Copy clones, elided lifetimes) * craft-menu: keyboard access to the egui bar A tap on Alt alone or F10 focuses the bar, Alt+letter opens a menu and a letter in an open menu runs or opens the row with that mnemonic, as in a Windows menu bar. Mnemonics are assigned per level from the shown labels, underlined only while the keyboard drives the bar, and a label without an ASCII letter shows its English letter after it ("文件(F)") through the optional english hook. Preferred letters come from MenuBarUi::mnemonics. A Mac target has none, and a dialog or the shortcut recorder keeps the keys. Ported from PhotoCraft's menu_keys.rs and menu_keys_tests.rs. * craft-menu: one installer or the other; sort mnemonics before checking uniqueness * craft-menu: egui 0.37 * craft-menu: text fields keep their Edit items; web names by the browser's system; sync per frame; stable check kinds; handled hook clicks * craft-menu: sync reports a clash change on an unchanged bar * craft-menu: shrink-to-fit bar, bar runs Quit and Minimize, mnemonic before an ellipsis documented; craft-keymap: parse the minus sign * craft-keymap: test the arrow glyphs as keys * craft-menu: mac::install_with and MacOptions::window_list, for apps that list their documents themselves * craft-menu: a negative fit limit draws no bar; Window list docs name the Window menu
682 lines
23 KiB
Rust
682 lines
23 KiB
Rust
//! Keys, modifiers, chords and their text forms.
|
||
//!
|
||
//! Canonical text is `Cmd+Ctrl+Alt+Shift+Key`, written by [`Chord`]'s `Display` and read back by
|
||
//! [`Chord::parse`]. A double press is the chord twice, separated by a space: `A A`.
|
||
|
||
use std::fmt;
|
||
|
||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||
|
||
use crate::layout::Layout;
|
||
|
||
pub mod foreign;
|
||
|
||
/// The longest chord or trigger text [`Chord::parse`] and [`Trigger::parse`] accept, in bytes.
|
||
pub const MAX_CHORD_TEXT: usize = 64;
|
||
|
||
/// The platform a keymap is used on. It decides display text and whether `ctrl` folds into `cmd`.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
#[non_exhaustive]
|
||
pub enum Platform {
|
||
Mac,
|
||
Windows,
|
||
Linux,
|
||
}
|
||
|
||
impl Platform {
|
||
/// The platform this build runs on. Web builds count as Linux; with the `egui` feature,
|
||
/// `egui::platform` gives the browser's operating system.
|
||
pub const fn current() -> Platform {
|
||
if cfg!(target_os = "macos") {
|
||
Platform::Mac
|
||
} else if cfg!(target_os = "windows") {
|
||
Platform::Windows
|
||
} else {
|
||
Platform::Linux
|
||
}
|
||
}
|
||
|
||
pub const fn is_mac(self) -> bool {
|
||
matches!(self, Platform::Mac)
|
||
}
|
||
}
|
||
|
||
macro_rules! keys {
|
||
($($variant:ident => $name:literal),* $(,)?) => {
|
||
/// A key on the keyboard, independent of any UI toolkit.
|
||
///
|
||
/// Digits are the main row; numeric keypad keys map to the same keys.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
#[non_exhaustive]
|
||
pub enum Key {
|
||
$($variant),*
|
||
}
|
||
|
||
impl Key {
|
||
/// Every key, in declaration order.
|
||
pub const ALL: &'static [Key] = &[$(Key::$variant),*];
|
||
|
||
/// The canonical name: `K`, `5`, `F5`, `PageUp`, `;`.
|
||
pub const fn name(self) -> &'static str {
|
||
match self {
|
||
$(Key::$variant => $name),*
|
||
}
|
||
}
|
||
|
||
// The Rust variant name, which is also egui's `Key` variant name except for arrows.
|
||
const fn variant_name(self) -> &'static str {
|
||
match self {
|
||
$(Key::$variant => stringify!($variant)),*
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
keys! {
|
||
A => "A", B => "B", C => "C", D => "D", E => "E", F => "F", G => "G", H => "H", I => "I",
|
||
J => "J", K => "K", L => "L", M => "M", N => "N", O => "O", P => "P", Q => "Q", R => "R",
|
||
S => "S", T => "T", U => "U", V => "V", W => "W", X => "X", Y => "Y", Z => "Z",
|
||
Num0 => "0", Num1 => "1", Num2 => "2", Num3 => "3", Num4 => "4",
|
||
Num5 => "5", Num6 => "6", Num7 => "7", Num8 => "8", Num9 => "9",
|
||
F1 => "F1", F2 => "F2", F3 => "F3", F4 => "F4", F5 => "F5", F6 => "F6", F7 => "F7",
|
||
F8 => "F8", F9 => "F9", F10 => "F10", F11 => "F11", F12 => "F12", F13 => "F13",
|
||
F14 => "F14", F15 => "F15", F16 => "F16", F17 => "F17", F18 => "F18", F19 => "F19",
|
||
F20 => "F20", F21 => "F21", F22 => "F22", F23 => "F23", F24 => "F24", F25 => "F25",
|
||
F26 => "F26", F27 => "F27", F28 => "F28", F29 => "F29", F30 => "F30", F31 => "F31",
|
||
F32 => "F32", F33 => "F33", F34 => "F34", F35 => "F35",
|
||
Escape => "Escape", Tab => "Tab", Backspace => "Backspace", Enter => "Enter",
|
||
Space => "Space", Insert => "Insert", Delete => "Delete", Home => "Home", End => "End",
|
||
PageUp => "PageUp", PageDown => "PageDown",
|
||
Up => "Up", Down => "Down", Left => "Left", Right => "Right",
|
||
Minus => "-", Equals => "=", Plus => "+", OpenBracket => "[", CloseBracket => "]",
|
||
Backslash => "\\", Semicolon => ";", Quote => "'", Backtick => "`",
|
||
Comma => ",", Period => ".", Slash => "/",
|
||
Colon => ":", Pipe => "|", Questionmark => "?", Exclamationmark => "!",
|
||
OpenCurlyBracket => "{", CloseCurlyBracket => "}",
|
||
}
|
||
|
||
/// Key aliases beyond the canonical and egui variant names. Matched without regard to ASCII case.
|
||
const KEY_ALIASES: &[(&str, Key)] = &[
|
||
("esc", Key::Escape),
|
||
("⎋", Key::Escape),
|
||
("return", Key::Enter),
|
||
("⏎", Key::Enter),
|
||
("↩", Key::Enter),
|
||
("del", Key::Delete),
|
||
("⌦", Key::Delete),
|
||
("⌫", Key::Backspace),
|
||
("⇥", Key::Tab),
|
||
("pgup", Key::PageUp),
|
||
("pgdn", Key::PageDown),
|
||
("page up", Key::PageUp),
|
||
("page down", Key::PageDown),
|
||
("arrowup", Key::Up),
|
||
("arrowdown", Key::Down),
|
||
("arrowleft", Key::Left),
|
||
("arrowright", Key::Right),
|
||
("↑", Key::Up),
|
||
("↓", Key::Down),
|
||
("←", Key::Left),
|
||
("→", Key::Right),
|
||
("spacebar", Key::Space),
|
||
("ins", Key::Insert),
|
||
("equal", Key::Equals),
|
||
("grave", Key::Backtick),
|
||
("backquote", Key::Backtick),
|
||
("apostrophe", Key::Quote),
|
||
// The minus sign menus and docs print (U+2212), not the hyphen-minus the key types.
|
||
("−", Key::Minus),
|
||
];
|
||
|
||
impl Key {
|
||
/// Reads a key name: canonical (`K`, `PageUp`, `;`), egui variant names (`ArrowUp`, `Num5`,
|
||
/// `Semicolon`) and the aliases `esc`, `return`, `del`, `pgup`, `pgdn`, `spacebar`, `ins`,
|
||
/// the Mac key glyphs and the minus sign `−`. Case does not matter.
|
||
pub fn parse(name: &str) -> Option<Key> {
|
||
Key::ALL
|
||
.iter()
|
||
.copied()
|
||
.find(|k| k.name().eq_ignore_ascii_case(name) || k.variant_name().eq_ignore_ascii_case(name))
|
||
.or_else(|| KEY_ALIASES.iter().find(|(alias, _)| alias.eq_ignore_ascii_case(name)).map(|&(_, k)| k))
|
||
}
|
||
|
||
/// `A` to `Z`.
|
||
pub const fn is_letter(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Key::A
|
||
| Key::B
|
||
| Key::C
|
||
| Key::D
|
||
| Key::E
|
||
| Key::F
|
||
| Key::G
|
||
| Key::H
|
||
| Key::I
|
||
| Key::J
|
||
| Key::K
|
||
| Key::L
|
||
| Key::M
|
||
| Key::N
|
||
| Key::O
|
||
| Key::P
|
||
| Key::Q
|
||
| Key::R
|
||
| Key::S
|
||
| Key::T
|
||
| Key::U
|
||
| Key::V
|
||
| Key::W
|
||
| Key::X
|
||
| Key::Y
|
||
| Key::Z
|
||
)
|
||
}
|
||
|
||
/// `0` to `9`.
|
||
pub const fn is_digit(self) -> bool {
|
||
matches!(self, Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9)
|
||
}
|
||
|
||
/// Punctuation and symbol keys: `-`, `=`, `+`, brackets, `\`, `;`, `'`, `` ` ``, `,`, `.`,
|
||
/// `/` and the shifted symbols `:`, `|`, `?`, `!`, `{`, `}`.
|
||
pub const fn is_symbol(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Key::Minus
|
||
| Key::Equals
|
||
| Key::Plus
|
||
| Key::OpenBracket
|
||
| Key::CloseBracket
|
||
| Key::Backslash
|
||
| Key::Semicolon
|
||
| Key::Quote
|
||
| Key::Backtick
|
||
| Key::Comma
|
||
| Key::Period
|
||
| Key::Slash
|
||
| Key::Colon
|
||
| Key::Pipe
|
||
| Key::Questionmark
|
||
| Key::Exclamationmark
|
||
| Key::OpenCurlyBracket
|
||
| Key::CloseCurlyBracket
|
||
)
|
||
}
|
||
|
||
/// The letter, digit or symbol key for a typed character, ignoring case.
|
||
pub fn from_char(c: char) -> Option<Key> {
|
||
let mut buf = [0; 4];
|
||
let text: &str = c.encode_utf8(&mut buf);
|
||
Key::ALL.iter().copied().find(|k| (k.is_letter() || k.is_digit() || k.is_symbol()) && k.name().eq_ignore_ascii_case(text))
|
||
}
|
||
|
||
/// `F1` to `F35`.
|
||
pub const fn is_function_key(self) -> bool {
|
||
matches!(
|
||
self,
|
||
Key::F1
|
||
| Key::F2
|
||
| Key::F3
|
||
| Key::F4
|
||
| Key::F5
|
||
| Key::F6
|
||
| Key::F7
|
||
| Key::F8
|
||
| Key::F9
|
||
| Key::F10
|
||
| Key::F11
|
||
| Key::F12
|
||
| Key::F13
|
||
| Key::F14
|
||
| Key::F15
|
||
| Key::F16
|
||
| Key::F17
|
||
| Key::F18
|
||
| Key::F19
|
||
| Key::F20
|
||
| Key::F21
|
||
| Key::F22
|
||
| Key::F23
|
||
| Key::F24
|
||
| Key::F25
|
||
| Key::F26
|
||
| Key::F27
|
||
| Key::F28
|
||
| Key::F29
|
||
| Key::F30
|
||
| Key::F31
|
||
| Key::F32
|
||
| Key::F33
|
||
| Key::F34
|
||
| Key::F35
|
||
)
|
||
}
|
||
|
||
/// The key as macOS menus show it: arrows and editing keys as glyphs.
|
||
pub(crate) fn mac_symbol(self) -> &'static str {
|
||
match self {
|
||
Key::Up => "↑",
|
||
Key::Down => "↓",
|
||
Key::Left => "←",
|
||
Key::Right => "→",
|
||
Key::Enter => "⏎",
|
||
Key::Backspace => "⌫",
|
||
Key::Delete => "⌦",
|
||
Key::Escape => "⎋",
|
||
Key::Tab => "⇥",
|
||
k => k.text_name(),
|
||
}
|
||
}
|
||
|
||
/// The key as Windows and Linux show it.
|
||
pub(crate) fn text_name(self) -> &'static str {
|
||
match self {
|
||
Key::PageUp => "Page Up",
|
||
Key::PageDown => "Page Down",
|
||
k => k.name(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for Key {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
f.write_str(self.name())
|
||
}
|
||
}
|
||
|
||
impl Serialize for Key {
|
||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||
serializer.serialize_str(self.name())
|
||
}
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for Key {
|
||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Key, D::Error> {
|
||
let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
|
||
if text.len() > MAX_CHORD_TEXT {
|
||
return Err(serde::de::Error::custom(ParseError::TooLong));
|
||
}
|
||
Key::parse(&text).ok_or_else(|| serde::de::Error::custom(ParseError::UnknownKey(text.into_owned())))
|
||
}
|
||
}
|
||
|
||
/// Modifier keys held with a chord's key.
|
||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||
pub struct Mods {
|
||
/// The command modifier: ⌘ on macOS, Ctrl on Windows and Linux.
|
||
pub cmd: bool,
|
||
/// The Control key on macOS. Off macOS it folds into `cmd`.
|
||
pub ctrl: bool,
|
||
/// Alt, Option on macOS.
|
||
pub alt: bool,
|
||
pub shift: bool,
|
||
}
|
||
|
||
impl Mods {
|
||
pub const NONE: Mods = Mods { cmd: false, ctrl: false, alt: false, shift: false };
|
||
pub const CMD: Mods = Mods { cmd: true, ctrl: false, alt: false, shift: false };
|
||
pub const CTRL: Mods = Mods { cmd: false, ctrl: true, alt: false, shift: false };
|
||
pub const ALT: Mods = Mods { cmd: false, ctrl: false, alt: true, shift: false };
|
||
pub const SHIFT: Mods = Mods { cmd: false, ctrl: false, alt: false, shift: true };
|
||
|
||
pub const fn is_none(self) -> bool {
|
||
!(self.cmd || self.ctrl || self.alt || self.shift)
|
||
}
|
||
|
||
/// The modifiers as `platform` sees them: off macOS `ctrl` becomes `cmd`.
|
||
pub const fn effective(self, platform: Platform) -> Mods {
|
||
if platform.is_mac() || !self.ctrl { self } else { Mods { cmd: true, ctrl: false, ..self } }
|
||
}
|
||
}
|
||
|
||
/// A key with modifiers.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub struct Chord {
|
||
pub mods: Mods,
|
||
pub key: Key,
|
||
}
|
||
|
||
/// Why chord or trigger text was refused.
|
||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
#[non_exhaustive]
|
||
pub enum ParseError {
|
||
Empty,
|
||
/// Longer than [`MAX_CHORD_TEXT`].
|
||
TooLong,
|
||
/// Modifiers with no key, or a trailing `+`.
|
||
MissingKey,
|
||
UnknownKey(String),
|
||
UnknownModifier(String),
|
||
DuplicateModifier(String),
|
||
/// Two different chords: key sequences are not supported.
|
||
Sequence,
|
||
}
|
||
|
||
impl fmt::Display for ParseError {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
match self {
|
||
ParseError::Empty => f.write_str("empty shortcut"),
|
||
ParseError::TooLong => write!(f, "shortcut longer than {MAX_CHORD_TEXT} bytes"),
|
||
ParseError::MissingKey => f.write_str("shortcut has no key"),
|
||
ParseError::UnknownKey(k) => write!(f, "unknown key `{k}`"),
|
||
ParseError::UnknownModifier(m) => write!(f, "unknown modifier `{m}`"),
|
||
ParseError::DuplicateModifier(m) => write!(f, "modifier `{m}` given twice"),
|
||
ParseError::Sequence => f.write_str("key sequences are not supported"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for ParseError {}
|
||
|
||
#[derive(Clone, Copy)]
|
||
enum Modifier {
|
||
Cmd,
|
||
Ctrl,
|
||
Alt,
|
||
Shift,
|
||
}
|
||
|
||
fn modifier_named(name: &str) -> Option<Modifier> {
|
||
const NAMES: &[(&str, Modifier)] = &[
|
||
("cmd", Modifier::Cmd),
|
||
("command", Modifier::Cmd),
|
||
("meta", Modifier::Cmd),
|
||
("super", Modifier::Cmd),
|
||
("mod", Modifier::Cmd),
|
||
("⌘", Modifier::Cmd),
|
||
("ctrl", Modifier::Ctrl),
|
||
("control", Modifier::Ctrl),
|
||
("ctl", Modifier::Ctrl),
|
||
("⌃", Modifier::Ctrl),
|
||
("alt", Modifier::Alt),
|
||
("opt", Modifier::Alt),
|
||
("option", Modifier::Alt),
|
||
("⌥", Modifier::Alt),
|
||
("shift", Modifier::Shift),
|
||
("⇧", Modifier::Shift),
|
||
];
|
||
NAMES.iter().find(|(n, _)| n.eq_ignore_ascii_case(name)).map(|&(_, m)| m)
|
||
}
|
||
|
||
/// A modifier glyph at the start of `text`, and the text after it.
|
||
fn strip_glyph(text: &str) -> Option<(Modifier, &str)> {
|
||
let mut chars = text.chars();
|
||
let m = match chars.next()? {
|
||
'⌘' => Modifier::Cmd,
|
||
'⌃' => Modifier::Ctrl,
|
||
'⌥' => Modifier::Alt,
|
||
'⇧' => Modifier::Shift,
|
||
_ => return None,
|
||
};
|
||
Some((m, chars.as_str()))
|
||
}
|
||
|
||
fn add_modifier(mods: &mut Mods, m: Modifier, written: &str) -> Result<(), ParseError> {
|
||
let slot = match m {
|
||
Modifier::Cmd => &mut mods.cmd,
|
||
Modifier::Ctrl => &mut mods.ctrl,
|
||
Modifier::Alt => &mut mods.alt,
|
||
Modifier::Shift => &mut mods.shift,
|
||
};
|
||
if *slot {
|
||
return Err(ParseError::DuplicateModifier(written.to_owned()));
|
||
}
|
||
*slot = true;
|
||
Ok(())
|
||
}
|
||
|
||
impl Chord {
|
||
pub const fn new(mods: Mods, key: Key) -> Chord {
|
||
Chord { mods, key }
|
||
}
|
||
|
||
/// The key with no modifiers.
|
||
pub const fn plain(key: Key) -> Chord {
|
||
Chord { mods: Mods::NONE, key }
|
||
}
|
||
|
||
/// Reads canonical text (`Cmd+Shift+Z`) and the common aliases: modifier words in any order
|
||
/// and case (`cmd command meta super mod`, `ctrl control ctl`, `alt opt option`, `shift`),
|
||
/// Mac glyphs, glued (`⇧⌘Z`) or with `+`, and the key names [`Key::parse`] reads. `Cmd++` is
|
||
/// Cmd with the Plus key.
|
||
pub fn parse(text: &str) -> Result<Chord, ParseError> {
|
||
if text.len() > MAX_CHORD_TEXT {
|
||
return Err(ParseError::TooLong);
|
||
}
|
||
let mut rest = text.trim();
|
||
if rest.is_empty() {
|
||
return Err(ParseError::Empty);
|
||
}
|
||
let mut mods = Mods::NONE;
|
||
// Each pass consumes at least one byte, so the loop ends within MAX_CHORD_TEXT passes.
|
||
loop {
|
||
if let Some((m, after)) = strip_glyph(rest) {
|
||
add_modifier(&mut mods, m, rest.get(..rest.len() - after.len()).unwrap_or_default())?;
|
||
// `⌘+K` and `⌘K` are the same; in `⌘+` the `+` is the key.
|
||
rest = match after.strip_prefix('+') {
|
||
Some(r) if !r.is_empty() => r,
|
||
_ => after,
|
||
};
|
||
if rest.trim().is_empty() {
|
||
return Err(ParseError::MissingKey);
|
||
}
|
||
continue;
|
||
}
|
||
// A `+` in first position is the Plus key, never a separator.
|
||
let first = rest.chars().next().map_or(0, char::len_utf8);
|
||
let separator = rest.get(first..).and_then(|r| r.find('+')).map(|i| i + first);
|
||
match separator {
|
||
Some(i) => {
|
||
let token = rest.get(..i).unwrap_or_default().trim();
|
||
let after = rest.get(i + 1..).unwrap_or_default();
|
||
if after.trim().is_empty() {
|
||
return Err(ParseError::MissingKey);
|
||
}
|
||
let m = modifier_named(token).ok_or_else(|| ParseError::UnknownModifier(token.to_owned()))?;
|
||
add_modifier(&mut mods, m, token)?;
|
||
rest = after;
|
||
}
|
||
None => {
|
||
let token = rest.trim();
|
||
if modifier_named(token).is_some() {
|
||
return Err(ParseError::MissingKey);
|
||
}
|
||
let key = Key::parse(token).ok_or_else(|| ParseError::UnknownKey(token.to_owned()))?;
|
||
return Ok(Chord { mods, key });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Canonical text, the same as `to_string()`: `Cmd+Ctrl+Alt+Shift+Key`.
|
||
pub fn portable(&self) -> String {
|
||
self.to_string()
|
||
}
|
||
|
||
/// The chord as `platform` sees it: off macOS `ctrl` folds into `cmd`. Lookups and conflict
|
||
/// checks compare effective chords.
|
||
pub const fn effective(self, platform: Platform) -> Chord {
|
||
Chord { mods: self.mods.effective(platform), key: self.key }
|
||
}
|
||
|
||
/// Text for menus and settings: `⌃⌥⇧⌘K` on macOS, `Ctrl+Alt+Shift+K` elsewhere.
|
||
pub fn display(&self, platform: Platform) -> String {
|
||
self.display_key(platform, None)
|
||
}
|
||
|
||
/// [`display`](Self::display) for the keys of `layout`: a punctuation key shows the character
|
||
/// the layout types at its position when that character has no key of its own (`Ctrl+Ü` for
|
||
/// Ctrl+[ on German). Letters and digits show as in `display`.
|
||
pub fn display_for(&self, platform: Platform, layout: &Layout) -> String {
|
||
self.display_key(platform, layout.key_label(self.key).as_deref())
|
||
}
|
||
|
||
fn display_key(&self, platform: Platform, key_text: Option<&str>) -> String {
|
||
let m = self.mods.effective(platform);
|
||
let mut s = String::new();
|
||
if platform.is_mac() {
|
||
for (on, glyph) in [(m.ctrl, "⌃"), (m.alt, "⌥"), (m.shift, "⇧"), (m.cmd, "⌘")] {
|
||
if on {
|
||
s.push_str(glyph);
|
||
}
|
||
}
|
||
s.push_str(key_text.unwrap_or(self.key.mac_symbol()));
|
||
} else {
|
||
for (on, name) in [(m.cmd, "Ctrl+"), (m.alt, "Alt+"), (m.shift, "Shift+")] {
|
||
if on {
|
||
s.push_str(name);
|
||
}
|
||
}
|
||
s.push_str(key_text.unwrap_or(self.key.text_name()));
|
||
}
|
||
s
|
||
}
|
||
|
||
pub const fn is_function_key(&self) -> bool {
|
||
self.key.is_function_key()
|
||
}
|
||
|
||
/// No cmd, ctrl or alt. Shift alone still counts as plain: such chords are tool keys that
|
||
/// typing in a text field would trigger.
|
||
pub const fn is_plain(&self) -> bool {
|
||
!(self.mods.cmd || self.mods.ctrl || self.mods.alt)
|
||
}
|
||
|
||
/// Holds the command modifier: cmd, or ctrl (Control on macOS, folded into cmd elsewhere).
|
||
pub const fn has_command(&self) -> bool {
|
||
self.mods.cmd || self.mods.ctrl
|
||
}
|
||
}
|
||
|
||
impl From<Key> for Chord {
|
||
fn from(key: Key) -> Chord {
|
||
Chord::plain(key)
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for Chord {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
for (on, name) in [(self.mods.cmd, "Cmd+"), (self.mods.ctrl, "Ctrl+"), (self.mods.alt, "Alt+"), (self.mods.shift, "Shift+")] {
|
||
if on {
|
||
f.write_str(name)?;
|
||
}
|
||
}
|
||
f.write_str(self.key.name())
|
||
}
|
||
}
|
||
|
||
impl std::str::FromStr for Chord {
|
||
type Err = ParseError;
|
||
fn from_str(s: &str) -> Result<Chord, ParseError> {
|
||
Chord::parse(s)
|
||
}
|
||
}
|
||
|
||
impl Serialize for Chord {
|
||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||
serializer.collect_str(self)
|
||
}
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for Chord {
|
||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Chord, D::Error> {
|
||
let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
|
||
Chord::parse(&text).map_err(serde::de::Error::custom)
|
||
}
|
||
}
|
||
|
||
/// What starts a command: a chord, pressed once or twice in a row.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub struct Trigger {
|
||
pub chord: Chord,
|
||
/// The chord pressed twice.
|
||
pub double: bool,
|
||
}
|
||
|
||
impl Trigger {
|
||
pub const fn single(chord: Chord) -> Trigger {
|
||
Trigger { chord, double: false }
|
||
}
|
||
|
||
pub const fn double(chord: Chord) -> Trigger {
|
||
Trigger { chord, double: true }
|
||
}
|
||
|
||
/// Reads a chord as [`Chord::parse`] does, or the same chord twice separated by whitespace
|
||
/// (`A A`) as a double press. Two different chords are [`ParseError::Sequence`].
|
||
pub fn parse(text: &str) -> Result<Trigger, ParseError> {
|
||
if text.len() > MAX_CHORD_TEXT {
|
||
return Err(ParseError::TooLong);
|
||
}
|
||
let single = match Chord::parse(text) {
|
||
Ok(chord) => return Ok(Trigger::single(chord)),
|
||
Err(e) => e,
|
||
};
|
||
let mut parts = text.split_whitespace();
|
||
match (parts.next(), parts.next(), parts.next()) {
|
||
(Some(first), Some(second), None) => {
|
||
let (first, second) = (Chord::parse(first)?, Chord::parse(second)?);
|
||
if first == second { Ok(Trigger::double(first)) } else { Err(ParseError::Sequence) }
|
||
}
|
||
_ => Err(single),
|
||
}
|
||
}
|
||
|
||
/// Canonical text, the same as `to_string()`.
|
||
pub fn portable(&self) -> String {
|
||
self.to_string()
|
||
}
|
||
|
||
pub const fn effective(self, platform: Platform) -> Trigger {
|
||
Trigger { chord: self.chord.effective(platform), double: self.double }
|
||
}
|
||
|
||
/// [`Chord::display`], twice with a space between for a double press.
|
||
pub fn display(&self, platform: Platform) -> String {
|
||
let one = self.chord.display(platform);
|
||
if self.double { format!("{one} {one}") } else { one }
|
||
}
|
||
|
||
/// [`Chord::display_for`], twice with a space between for a double press.
|
||
pub fn display_for(&self, platform: Platform, layout: &Layout) -> String {
|
||
let one = self.chord.display_for(platform, layout);
|
||
if self.double { format!("{one} {one}") } else { one }
|
||
}
|
||
}
|
||
|
||
impl From<Chord> for Trigger {
|
||
fn from(chord: Chord) -> Trigger {
|
||
Trigger::single(chord)
|
||
}
|
||
}
|
||
|
||
impl fmt::Display for Trigger {
|
||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||
if self.double { write!(f, "{0} {0}", self.chord) } else { write!(f, "{}", self.chord) }
|
||
}
|
||
}
|
||
|
||
impl std::str::FromStr for Trigger {
|
||
type Err = ParseError;
|
||
fn from_str(s: &str) -> Result<Trigger, ParseError> {
|
||
Trigger::parse(s)
|
||
}
|
||
}
|
||
|
||
impl Serialize for Trigger {
|
||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||
serializer.collect_str(self)
|
||
}
|
||
}
|
||
|
||
impl<'de> Deserialize<'de> for Trigger {
|
||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Trigger, D::Error> {
|
||
let text = <std::borrow::Cow<'de, str>>::deserialize(deserializer)?;
|
||
Trigger::parse(&text).map_err(serde::de::Error::custom)
|
||
}
|
||
}
|