mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-08 11:31:43 +00:00
Sandboxed WebAssembly plugins whose API is the command registry: - figcraft-plugin (new crate, L2): the figcraft-plugin.json manifest (menu commands with parameters, read/write access, command allowlist) and the sandbox: wasmi (pure Rust, no JIT, also in the web build) with fuel metering, a memory cap, no WASI and seven host functions (call, selection, reply_len, reply_read, log, notify, set_output). - Engine: plugin.list / plugin.import / plugin.remove / plugin.run, plugins.runLast (Alt+Cmd+P; Ctrl+Alt+Shift+P on Windows/Linux) and plugins.manage. A run is one undo step (Session::grouped, which command.batch now uses too) and is rolled back when it fails. Files, other documents, the undo history, dialogs and plugins are off limits; read-only plugins can't change the document. Installed plugins live in the preferences; development plugins re-read their module every run. - UI: installed plugins in the Plugins menu, a parameters form drawn from the manifest, Manage plugins, Plugins > Development > Import plugin from manifest. - plugins/: a no_std Rust SDK and two example plugins (Tidy names, Swatch grid), built for wasm32 by `cargo xtask plugins` (part of `cargo xtask wasm`). - Docs: docs/plugins.md (manifest, permissions, limits, ABI, SDK), docs/mcp.md, docs/control-protocol.md, README, ROADMAP, parity. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
506 lines
19 KiB
Rust
506 lines
19 KiB
Rust
//! Default keyboard shortcuts, grouped like the public shortcut reference, with the macOS keys and
|
|
//! the Windows/Linux keys where they differ. The menus, the Keyboard shortcuts panel, the canvas,
|
|
//! the control channel and the MCP `key` tool all resolve keys here, so a binding is defined once.
|
|
//!
|
|
//! Notation: modifiers then the key, joined by `+` (`Shift+Cmd+K`). `Cmd` is the platform's
|
|
//! command key (⌘ on macOS, Ctrl elsewhere), `Ctrl` the Control key (on Windows and Linux the same
|
|
//! key as `Cmd`), `Alt` is ⌥ Option on macOS. Keys: letters, digits, `= - [ ] \ ' , . / ;` and
|
|
//! the names `Enter`, `Escape`, `Tab`, `Delete` (Backspace or Delete), `Space`, `PageUp`,
|
|
//! `PageDown`, `Home`, `End`, `ArrowLeft`/`Right`/`Up`/`Down`, `F1`…`F12`. Shifted symbols
|
|
//! (`?`, `+`, `<`, `>`, `{`, `}`, `|`, `!`) stand for the unshifted key with Shift held.
|
|
|
|
/// Which platform's keys to use.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize)]
|
|
pub enum Platform {
|
|
Mac,
|
|
/// Windows and Linux.
|
|
Other,
|
|
}
|
|
|
|
impl Platform {
|
|
/// The platform this build runs on (the web build asks the browser instead, in the UI).
|
|
pub const fn current() -> Self {
|
|
if cfg!(target_os = "macos") { Self::Mac } else { Self::Other }
|
|
}
|
|
}
|
|
|
|
/// The tabs of the Keyboard shortcuts panel.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub enum Group {
|
|
Essential,
|
|
Tools,
|
|
View,
|
|
Zoom,
|
|
Text,
|
|
Shape,
|
|
Selection,
|
|
Edit,
|
|
Transform,
|
|
Arrange,
|
|
Components,
|
|
Prototype,
|
|
}
|
|
|
|
impl Group {
|
|
pub const ALL: [Group; 12] = [
|
|
Group::Essential,
|
|
Group::Tools,
|
|
Group::View,
|
|
Group::Zoom,
|
|
Group::Text,
|
|
Group::Shape,
|
|
Group::Selection,
|
|
Group::Edit,
|
|
Group::Transform,
|
|
Group::Arrange,
|
|
Group::Components,
|
|
Group::Prototype,
|
|
];
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
Group::Essential => "Essential",
|
|
Group::Tools => "Tools",
|
|
Group::View => "View",
|
|
Group::Zoom => "Zoom",
|
|
Group::Text => "Text",
|
|
Group::Shape => "Shape",
|
|
Group::Selection => "Selection",
|
|
Group::Edit => "Edit",
|
|
Group::Transform => "Transform",
|
|
Group::Arrange => "Arrange",
|
|
Group::Components => "Components",
|
|
Group::Prototype => "Prototype",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a binding is live.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Context {
|
|
/// On the canvas (not while editing text).
|
|
Canvas,
|
|
/// Only while editing text.
|
|
Text,
|
|
/// On the canvas and while editing text.
|
|
Both,
|
|
}
|
|
|
|
/// One command's default keys.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct Binding {
|
|
pub group: Group,
|
|
/// The command id (an engine command or one the UI handles).
|
|
pub id: &'static str,
|
|
/// The label in the shortcuts panel; empty: the command's menu label.
|
|
label: &'static str,
|
|
/// macOS keys, the first shown in menus.
|
|
pub mac: &'static [&'static str],
|
|
/// Windows/Linux keys; empty: the macOS keys with `Cmd` read as Ctrl.
|
|
pub other: &'static [&'static str],
|
|
pub context: Context,
|
|
}
|
|
|
|
impl Binding {
|
|
const fn new(group: Group, id: &'static str, mac: &'static [&'static str]) -> Self {
|
|
Self { group, id, label: "", mac, other: &[], context: Context::Canvas }
|
|
}
|
|
/// Different keys on Windows and Linux.
|
|
const fn win(mut self, keys: &'static [&'static str]) -> Self {
|
|
self.other = keys;
|
|
self
|
|
}
|
|
const fn label(mut self, label: &'static str) -> Self {
|
|
self.label = label;
|
|
self
|
|
}
|
|
/// Live while editing text too.
|
|
const fn both(mut self) -> Self {
|
|
self.context = Context::Both;
|
|
self
|
|
}
|
|
/// Live only while editing text.
|
|
const fn text(mut self) -> Self {
|
|
self.context = Context::Text;
|
|
self
|
|
}
|
|
|
|
/// The keys on `platform`, the first being the one menus show.
|
|
pub fn keys(&self, platform: Platform) -> &'static [&'static str] {
|
|
match platform {
|
|
Platform::Other if !self.other.is_empty() => self.other,
|
|
_ => self.mac,
|
|
}
|
|
}
|
|
|
|
/// The label for the shortcuts panel: its own, else the menu's, else the command's.
|
|
pub fn title(&self) -> &'static str {
|
|
if !self.label.is_empty() {
|
|
return self.label;
|
|
}
|
|
crate::catalog::items()
|
|
.find(|m| m.id == self.id)
|
|
.map(|m| m.label)
|
|
.or_else(|| crate::find_command(self.id).map(|c| c.label))
|
|
.unwrap_or(self.id)
|
|
}
|
|
|
|
fn live_in(&self, context: Context) -> bool {
|
|
self.context == context || self.context == Context::Both
|
|
}
|
|
}
|
|
|
|
use Group::*;
|
|
|
|
const fn k(group: Group, id: &'static str, mac: &'static [&'static str]) -> Binding {
|
|
Binding::new(group, id, mac)
|
|
}
|
|
|
|
/// Every default shortcut, in panel order within each group.
|
|
pub static KEYS: &[Binding] = &[
|
|
// Essential
|
|
k(Essential, "app.actions", &["Cmd+K", "Cmd+/"]).label("Actions…"),
|
|
k(Essential, "help.shortcuts", &["Ctrl+Shift+?"]).both(),
|
|
k(Essential, "view.toggleUi", &["Cmd+\\"]),
|
|
k(Essential, "edit.pickColor", &["I", "Ctrl+C"]).win(&["I"]),
|
|
k(Essential, "app.preferences", &["Cmd+,"]),
|
|
k(Essential, "file.new", &["Cmd+N"]),
|
|
k(Essential, "file.open", &["Cmd+O"]),
|
|
k(Essential, "file.save", &["Cmd+S"]).both(),
|
|
k(Essential, "file.saveAs", &["Shift+Cmd+S"]).both(),
|
|
k(Essential, "file.saveVersion", &["Alt+Cmd+S"]),
|
|
k(Essential, "file.close", &["Cmd+W"]),
|
|
k(Essential, "edit.findReplace", &["Cmd+F"]),
|
|
// Ctrl+Alt+P is Pixel preview on Windows and Linux here.
|
|
k(Essential, "plugins.runLast", &["Alt+Cmd+P"]).win(&["Ctrl+Alt+Shift+P"]),
|
|
// Tools
|
|
k(Tools, "tool.move", &["V"]),
|
|
k(Tools, "tool.scale", &["K"]),
|
|
k(Tools, "tool.hand", &["H"]),
|
|
k(Tools, "tool.frame", &["F"]),
|
|
k(Tools, "tool.section", &["Shift+S"]),
|
|
k(Tools, "tool.slice", &["S"]),
|
|
k(Tools, "tool.rectangle", &["R"]),
|
|
k(Tools, "tool.line", &["L"]),
|
|
k(Tools, "tool.arrow", &["Shift+L"]),
|
|
k(Tools, "tool.ellipse", &["O"]),
|
|
k(Tools, "file.placeImage", &["Shift+Cmd+K"]).label("Place image or video"),
|
|
k(Tools, "tool.pen", &["P"]),
|
|
k(Tools, "tool.pencil", &["Shift+P"]),
|
|
k(Tools, "tool.text", &["T"]),
|
|
k(Tools, "tool.comment", &["C"]),
|
|
k(Tools, "tool.shapeBuilder", &["Shift+M"]),
|
|
// View
|
|
k(View, "view.pixelGrid", &["Cmd+'"]),
|
|
k(View, "view.layoutGrids", &["Shift+G", "Ctrl+G"]).win(&["Shift+G", "Ctrl+Shift+4"]),
|
|
k(View, "view.rulers", &["Shift+R"]),
|
|
k(View, "view.outlines", &["Shift+Cmd+O"]),
|
|
k(View, "view.pixelPreview", &["Ctrl+P"]).win(&["Ctrl+Alt+P"]),
|
|
k(View, "view.comments", &["Shift+C"]),
|
|
k(View, "view.snapPixel", &["Shift+Cmd+'"]),
|
|
k(View, "view.layers", &["Alt+1"]).label("Layers panel"),
|
|
k(View, "view.assets", &["Alt+2"]).label("Assets panel"),
|
|
k(View, "libraries.open", &["Alt+3"]),
|
|
k(View, "view.design", &["Alt+8"]).label("Design panel"),
|
|
k(View, "view.prototype", &["Alt+9"]).label("Prototype panel"),
|
|
k(View, "view.designPrototype", &["Shift+E"]),
|
|
k(View, "view.devMode", &["Shift+D"]).label("Dev Mode on/off"),
|
|
k(View, "view.leftSidebar", &["Shift+Cmd+\\"]),
|
|
k(View, "view.nextRegion", &["F6"]).label("Focus the next region (toolbar, layers, canvas, properties)").both(),
|
|
k(View, "view.previousRegion", &["Shift+F6"]).label("Focus the previous region").both(),
|
|
k(View, "layers.collapseAll", &["Alt+L"]),
|
|
k(View, "doc.nextTab", &["Ctrl+Tab"]),
|
|
k(View, "doc.previousTab", &["Ctrl+Shift+Tab"]),
|
|
k(View, "view.prevPage", &["PageUp"]),
|
|
k(View, "view.nextPage", &["PageDown"]),
|
|
// Zoom
|
|
k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]),
|
|
k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]),
|
|
k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]),
|
|
k(Zoom, "view.zoomFit", &["Shift+1"]),
|
|
k(Zoom, "view.zoomSelection", &["Shift+2"]),
|
|
k(Zoom, "view.prevFrame", &["Shift+N", "Home"]),
|
|
k(Zoom, "view.nextFrame", &["N", "End"]),
|
|
// Text (on selected text layers, and on the selected characters while editing)
|
|
k(Text, "text.bold", &["Cmd+B"]).both(),
|
|
k(Text, "text.italic", &["Cmd+I"]).both(),
|
|
k(Text, "text.underline", &["Cmd+U"]).both(),
|
|
k(Text, "text.strikethrough", &["Shift+Cmd+X"]).both(),
|
|
k(Text, "text.link", &["Cmd+K"]).text(),
|
|
k(Text, "text.bulletList", &["Shift+Cmd+8"]).both(),
|
|
k(Text, "text.numberList", &["Shift+Cmd+7"]).both(),
|
|
k(Text, "text.alignLeft", &["Alt+Cmd+L"]).both(),
|
|
k(Text, "text.alignCenter", &["Alt+Cmd+T"]).both(),
|
|
k(Text, "text.alignRight", &["Alt+Cmd+R"]).both(),
|
|
k(Text, "text.alignJustify", &["Alt+Cmd+J"]).both(),
|
|
k(Text, "text.sizeUp", &["Shift+Cmd+."]).both(),
|
|
k(Text, "text.sizeDown", &["Shift+Cmd+,"]).both(),
|
|
k(Text, "text.weightUp", &["Alt+Cmd+."]).both(),
|
|
k(Text, "text.weightDown", &["Alt+Cmd+,"]).both(),
|
|
k(Text, "text.lineHeightUp", &["Alt+Shift+."]).both(),
|
|
k(Text, "text.lineHeightDown", &["Alt+Shift+,"]).both(),
|
|
k(Text, "text.trackingUp", &["Alt+."]).both(),
|
|
k(Text, "text.trackingDown", &["Alt+,"]).both(),
|
|
// Shape
|
|
k(Shape, "vector.flatten", &["Cmd+E"]),
|
|
k(Shape, "vector.outlineStroke", &["Alt+Cmd+O"]),
|
|
k(Shape, "bool.union", &["Alt+Shift+U"]),
|
|
k(Shape, "bool.subtract", &["Alt+Shift+S"]),
|
|
k(Shape, "bool.intersect", &["Alt+Shift+I"]),
|
|
k(Shape, "bool.exclude", &["Alt+Shift+E"]),
|
|
k(Shape, "object.removeFill", &["Alt+/"]),
|
|
k(Shape, "object.removeStroke", &["Shift+/"]),
|
|
k(Shape, "object.swapFillStroke", &["Shift+X"]),
|
|
k(Shape, "vector.join", &["Cmd+J"]),
|
|
k(Shape, "vector.smoothJoin", &["Shift+Cmd+J"]),
|
|
k(Shape, "vector.deleteHeal", &["Shift+Delete"]),
|
|
// Selection
|
|
k(Selection, "select.all", &["Cmd+A"]),
|
|
k(Selection, "select.inverse", &["Shift+Cmd+A"]),
|
|
k(Selection, "select.matching", &["Alt+Cmd+A"]),
|
|
k(Selection, "select.none", &["Escape"]),
|
|
k(Selection, "select.children", &["Enter"]),
|
|
k(Selection, "select.parent", &["Shift+Enter", "\\"]),
|
|
k(Selection, "select.next", &["Tab"]),
|
|
k(Selection, "select.previous", &["Shift+Tab"]),
|
|
k(Selection, "object.group", &["Cmd+G"]),
|
|
k(Selection, "object.ungroup", &["Shift+Cmd+G"]),
|
|
k(Selection, "object.frameSelection", &["Alt+Cmd+G"]),
|
|
k(Selection, "object.toggleVisible", &["Shift+Cmd+H"]),
|
|
k(Selection, "object.toggleLock", &["Shift+Cmd+L"]),
|
|
k(Selection, "object.rename", &["Cmd+R"]),
|
|
// Edit
|
|
k(Edit, "edit.undo", &["Cmd+Z"]),
|
|
k(Edit, "edit.redo", &["Shift+Cmd+Z"]),
|
|
k(Edit, "edit.copy", &["Cmd+C"]),
|
|
k(Edit, "edit.cut", &["Cmd+X"]),
|
|
k(Edit, "edit.paste", &["Cmd+V"]),
|
|
k(Edit, "edit.pasteOver", &["Shift+Cmd+V"]),
|
|
k(Edit, "edit.pasteReplace", &["Shift+Cmd+R"]),
|
|
k(Edit, "edit.duplicate", &["Cmd+D"]),
|
|
k(Edit, "edit.delete", &["Delete"]),
|
|
k(Edit, "edit.copyProperties", &["Alt+Cmd+C"]),
|
|
k(Edit, "edit.pasteProperties", &["Alt+Cmd+V"]),
|
|
k(Edit, "edit.copyAsPng", &["Shift+Cmd+C"]),
|
|
k(Edit, "file.export", &["Shift+Cmd+E"]),
|
|
k(Edit, "file.import", &["Shift+Cmd+I"]),
|
|
// Transform
|
|
k(Transform, "object.flipH", &["Shift+H"]),
|
|
k(Transform, "object.flipV", &["Shift+V"]),
|
|
k(Transform, "object.mask", &["Ctrl+Cmd+M"]).win(&["Ctrl+Alt+M"]),
|
|
k(Transform, "object.resizeToFit", &["Alt+Shift+Cmd+R"]),
|
|
// Arrange
|
|
k(Arrange, "arrange.front", &["Alt+Cmd+]"]).win(&["Shift+Cmd+]"]),
|
|
k(Arrange, "arrange.forward", &["Cmd+]"]),
|
|
k(Arrange, "arrange.backward", &["Cmd+["]),
|
|
k(Arrange, "arrange.back", &["Alt+Cmd+["]).win(&["Shift+Cmd+["]),
|
|
k(Arrange, "align.left", &["Alt+A"]),
|
|
k(Arrange, "align.hcenter", &["Alt+H"]),
|
|
k(Arrange, "align.right", &["Alt+D"]),
|
|
k(Arrange, "align.top", &["Alt+W"]),
|
|
k(Arrange, "align.vcenter", &["Alt+V"]),
|
|
k(Arrange, "align.bottom", &["Alt+S"]),
|
|
k(Arrange, "distribute.horizontal", &["Ctrl+Alt+H"]).win(&["Alt+Shift+H"]),
|
|
k(Arrange, "distribute.vertical", &["Ctrl+Alt+V"]).win(&["Alt+Shift+V"]),
|
|
k(Arrange, "arrange.tidy", &["Ctrl+Alt+T"]).win(&["Ctrl+Alt+Shift+T"]),
|
|
k(Arrange, "layout.add", &["Shift+A"]),
|
|
k(Arrange, "layout.remove", &["Alt+Shift+A"]),
|
|
// Components
|
|
k(Components, "component.create", &["Alt+Cmd+K"]),
|
|
k(Components, "component.detach", &["Alt+Cmd+B"]),
|
|
k(Components, "view.assets", &["Shift+I"]).label("Insert a component (Assets)"),
|
|
// Prototype
|
|
k(Prototype, "proto.present", &["Alt+Cmd+Enter"]).label("Present"),
|
|
k(Prototype, "proto.preview", &["Shift+Space"]).label("Preview in the canvas"),
|
|
];
|
|
|
|
/// Pointer gestures and held keys the panel lists next to the bindings (handled by the canvas, not
|
|
/// dispatched from here): (group, label, macOS keys, Windows/Linux keys).
|
|
pub static GESTURES: &[(Group, &str, &str, &str)] = &[
|
|
(Tools, "Pan the canvas", "Space drag", "Space+drag"),
|
|
(Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"),
|
|
(Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"),
|
|
(Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"),
|
|
(Selection, "Deep select", "⌘ click", "Ctrl+click"),
|
|
(Selection, "Measure to the selection", "Hold ⌥", "Hold Alt"),
|
|
(Edit, "Duplicate by dragging", "⌥ drag", "Alt+drag"),
|
|
(Transform, "Nudge (Shift: big nudge)", "← ↑ → ↓", "← ↑ → ↓"),
|
|
(Transform, "Resize by the nudge amount", "⌘ arrows", "Ctrl+arrows"),
|
|
(Transform, "Opacity 10%…90%, 0: 100%", "1…9, 0", "1…9, 0"),
|
|
(Transform, "Exact opacity (4 then 5: 45%)", "Two digits", "Two digits"),
|
|
(View, "Next or previous control in the focused region", "⇥ / ⇧⇥", "Tab / Shift+Tab"),
|
|
(View, "Back to the canvas from a focused region", "Esc", "Esc"),
|
|
];
|
|
|
|
/// Modifier keys held (as pressed: on Windows and Linux Ctrl may come as `cmd`, `ctrl` or both).
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
pub struct Mods {
|
|
pub shift: bool,
|
|
pub alt: bool,
|
|
pub cmd: bool,
|
|
pub ctrl: bool,
|
|
}
|
|
|
|
impl Mods {
|
|
pub fn is_none(self) -> bool {
|
|
self == Mods::default()
|
|
}
|
|
}
|
|
|
|
/// A key name as canonical (`=` for `Equals`, `ArrowLeft` for `Left`, `Delete` for `Backspace`),
|
|
/// and whether the name implies Shift (`?` is Shift+`/`). `None` for names that aren't keys.
|
|
pub fn normalize(name: &str) -> Option<(&str, bool)> {
|
|
let shifted = |k: &'static str| Some((k, true));
|
|
let plain = |k: &'static str| Some((k, false));
|
|
match name {
|
|
"" => None,
|
|
"Plus" | "+" => shifted("="),
|
|
"Questionmark" | "?" => shifted("/"),
|
|
"<" => shifted(","),
|
|
">" => shifted("."),
|
|
"OpenCurlyBracket" | "{" => shifted("["),
|
|
"CloseCurlyBracket" | "}" => shifted("]"),
|
|
"Pipe" | "|" => shifted("\\"),
|
|
"Exclamationmark" | "!" => shifted("1"),
|
|
"Colon" | ":" => shifted(";"),
|
|
"\"" => shifted("'"),
|
|
"_" => shifted("-"),
|
|
"Equals" => plain("="),
|
|
"Minus" => plain("-"),
|
|
"OpenBracket" => plain("["),
|
|
"CloseBracket" => plain("]"),
|
|
"Backslash" => plain("\\"),
|
|
"Quote" => plain("'"),
|
|
"Comma" => plain(","),
|
|
"Period" => plain("."),
|
|
"Slash" => plain("/"),
|
|
"Semicolon" => plain(";"),
|
|
"Backtick" => plain("`"),
|
|
"Backspace" | "Delete" | "Del" => plain("Delete"),
|
|
"Return" | "Enter" => plain("Enter"),
|
|
"Esc" | "Escape" => plain("Escape"),
|
|
"Left" | "ArrowLeft" => plain("ArrowLeft"),
|
|
"Right" | "ArrowRight" => plain("ArrowRight"),
|
|
"Up" | "ArrowUp" => plain("ArrowUp"),
|
|
"Down" | "ArrowDown" => plain("ArrowDown"),
|
|
n if n.chars().count() == 1 => Some((n, false)),
|
|
n if NAMED.iter().any(|k| k.eq_ignore_ascii_case(n)) => Some((n, false)),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
const NAMED: &[&str] =
|
|
&["Tab", "Space", "PageUp", "PageDown", "Home", "End", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12"];
|
|
|
|
/// Parse a binding (`Shift+Cmd+K`) into its modifiers and canonical key.
|
|
pub fn parse(spec: &str) -> Option<(Mods, &str)> {
|
|
let mut m = Mods::default();
|
|
let mut key = None;
|
|
for part in spec.split('+') {
|
|
match part {
|
|
"Shift" => m.shift = true,
|
|
"Cmd" => m.cmd = true,
|
|
"Alt" | "Option" => m.alt = true,
|
|
"Ctrl" => m.ctrl = true,
|
|
k => {
|
|
if key.is_some() {
|
|
return None;
|
|
}
|
|
let (k, shift) = normalize(k)?;
|
|
m.shift |= shift;
|
|
key = Some(k);
|
|
}
|
|
}
|
|
}
|
|
key.map(|k| (m, k))
|
|
}
|
|
|
|
fn same_mods(platform: Platform, want: Mods, got: Mods) -> bool {
|
|
match platform {
|
|
Platform::Mac => want == got,
|
|
// Cmd and Ctrl are one key here.
|
|
Platform::Other => want.shift == got.shift && want.alt == got.alt && (want.cmd || want.ctrl) == (got.cmd || got.ctrl),
|
|
}
|
|
}
|
|
|
|
/// Whether a binding fires for `key` with these modifiers on `platform`.
|
|
pub fn matches(spec: &str, platform: Platform, key: &str, mods: Mods) -> bool {
|
|
let Some((key, shift)) = normalize(key) else { return false };
|
|
let mods = Mods { shift: mods.shift || shift, ..mods };
|
|
parse(spec).is_some_and(|(want, k)| k.eq_ignore_ascii_case(key) && same_mods(platform, want, mods))
|
|
}
|
|
|
|
/// The binding `key` with these modifiers fires in `context` on `platform`.
|
|
pub fn lookup(platform: Platform, context: Context, key: &str, mods: Mods) -> Option<&'static Binding> {
|
|
KEYS.iter().filter(|b| b.live_in(context)).find(|b| b.keys(platform).iter().any(|s| matches(s, platform, key, mods)))
|
|
}
|
|
|
|
/// A command's keys on `platform` (all its bindings, the menu's first).
|
|
pub fn bindings(id: &str, platform: Platform) -> impl Iterator<Item = &'static str> {
|
|
KEYS.iter().filter(move |b| b.id == id).flat_map(move |b| b.keys(platform).iter().copied())
|
|
}
|
|
|
|
/// The key a menu shows for a command on `platform`.
|
|
pub fn primary(id: &str, platform: Platform) -> Option<&'static str> {
|
|
bindings(id, platform).next()
|
|
}
|
|
|
|
/// A binding as the platform writes it: `⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere.
|
|
pub fn display(spec: &str, platform: Platform) -> String {
|
|
if spec.is_empty() {
|
|
return String::new();
|
|
}
|
|
let mut m = Mods::default();
|
|
let mut key = "";
|
|
for part in spec.split('+') {
|
|
match part {
|
|
"Shift" => m.shift = true,
|
|
"Cmd" => m.cmd = true,
|
|
"Alt" | "Option" => m.alt = true,
|
|
"Ctrl" => m.ctrl = true,
|
|
k => key = k,
|
|
}
|
|
}
|
|
// The zoom-in key reads as `+` (with Shift it types one).
|
|
if key == "=" {
|
|
key = "+";
|
|
m.shift = false;
|
|
}
|
|
let mac = platform == Platform::Mac;
|
|
let key = match (key, mac) {
|
|
("Delete", true) => "⌫",
|
|
("Enter", true) => "↵",
|
|
("Tab", true) => "⇥",
|
|
("PageUp", true) => "⇞",
|
|
("PageDown", true) => "⇟",
|
|
("Delete", false) => "Backspace",
|
|
("PageUp", false) => "PgUp",
|
|
("PageDown", false) => "PgDn",
|
|
("Escape", _) => "Esc",
|
|
("ArrowLeft", _) => "←",
|
|
("ArrowRight", _) => "→",
|
|
("ArrowUp", _) => "↑",
|
|
("ArrowDown", _) => "↓",
|
|
(k, _) => k,
|
|
};
|
|
let mut out = String::new();
|
|
if mac {
|
|
for (on, glyph) in [(m.ctrl, "⌃"), (m.alt, "⌥"), (m.shift, "⇧"), (m.cmd, "⌘")] {
|
|
if on {
|
|
out.push_str(glyph);
|
|
}
|
|
}
|
|
out.push_str(key);
|
|
} else {
|
|
for (on, name) in [(m.ctrl || m.cmd, "Ctrl"), (m.alt, "Alt"), (m.shift, "Shift"), (!key.is_empty(), key)] {
|
|
if on {
|
|
if !out.is_empty() {
|
|
out.push('+');
|
|
}
|
|
out.push_str(name);
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|