mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 11:26:50 -06:00
## What
**Layers panel from the keyboard** (two of the three open items of area 3, plus long-list QA)
- **F2 renames** the selected layer (a second key for `object.rename`, next to Cmd/Ctrl+R) from the canvas, after a click on a row, or from a layer row that has the keyboard (F6 mode, where the canvas shortcuts are off, so the layers branch of `a11y::keys` runs it). When the rename ends the row gets the keyboard back (`a11y::refocus_row`); Escape in the field cancels and stays in the panel instead of jumping to the canvas.
- **Shift+F10 opens the context menu** (`view.contextMenu`, a new command that only asks the UI): at the focused layer row when the layers panel has the keyboard (F6 or a clicked row), else on the canvas just below the selection (the middle of the canvas with nothing selected). The menu takes the keyboard like the other menus opened from the keyboard (`a11y::popup`), and Escape gives it back to the row or the canvas. The Menu key can't be supported: egui 0.36 has no key for it.
- **Keyboard box selection** (`select.area`, F8): for keyboard-only users, a selection box appears around the selection (or in the middle of the view); the arrows move it, Shift+arrows move its far corner (10 screen pixels a press), Enter selects what it covers exactly like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter takes nested layers), Escape or a click drops it. A toast and the canvas's screen-reader description say the box's size and the keys. With a box (`{x, y, width, height, add?, deep?}`) the command selects directly, for agents. The marquee's rules (top-level layers, frames only when covered, the children of the frame the box starts in, the focus view, Shift toggling) moved into `Session::marquee_within` / `marquee_selection`, shared by the pointer marquee and the command.
- **Long lists (10k layers)**: a test that a layer near the bottom of a 10k-layer list is revealed (parents expanded, list scrolled) and that F6 lands the keyboard on its row and the arrows walk on from it; the UI bench (`perf_ui_frames`) gained rows for expanding every layer, arrow keys down the expanded list, revealing a layer at the bottom and collapsing everything. Release, 10k layers: first frame with every layer expanded 1.22 ms, an arrow key 1.04 ms, reveal 0.30 ms, collapse all 0.37 ms, idle 0.17 ms.
**Stroke styles** (area 6)
- What the public documentation describes for strokes was mostly already in: colour styles apply to strokes from the stroke section's style picker (`style.apply {target: "stroke"}`, linked, following `style.update`, one undo step, `style.detach {which: "stroke"}` keeping the colour), and the stroke weight binds to number variables. A test now covers that path.
- Missing piece, added: **each side's stroke weight binds to a number variable on its own** (`strokeTopWeight`, `strokeRightWeight`, `strokeBottomWeight`, `strokeLeftWeight`): `node.set` keys (setting or binding one side turns on individual strokes with the other sides at the weight), the stroke-weight variable scope, the stroke override group for instances, and REST `boundVariables` import. The Design panel's per-side fields are bindable, two to a row like the corner radii so a variable's name fits.
New strings go through `tr(...)` with `en.toml` / `es.toml` entries, and the two new commands have Spanish labels. Shortcuts are only in `keymap.rs`. Nothing redraws while idle (the box draws only when a key moves it). Selecting from the box leaves no undo step; each stroke edit is one.
## Verified
- **Desktop app** over the control channel (`target/debug/figcraft.exe` from this worktree's target dir, `--control <free port>`, separate `APPDATA`, keys sent as real `ui.input` key events, stopped by PID): F2 on a selected layer opens its name field in the layers panel (screenshot), typing and Enter rename it; F6 to the layers panel, Shift+F10 opens the menu at the Avatar row with Copy focused (screenshot), Escape gives the keyboard back to the row, F2 + typing + Enter renames it and the row keeps the keyboard ("Profile"); Shift+F10 on the canvas opens the menu for the selected frame with Copy focused (screenshot); F8 shows a box around the selected frame, Shift+arrows stretch it over a second layer (screenshot with the hint toast), Enter selects both; a rectangle with the colour style "Brand/Accent" on its stroke and its top weight bound to a number variable (screenshot: the style chip in the stroke section, the red stroke thicker at the top, the per-side field showing the variable).
- **MCP**: `figcraft-cli mcp` (headless): created a paint style, `style.apply {target: "stroke"}`, `object.rename`, bound `strokeLeftWeight` to a variable and ran `select.area` over the layer; `inspect_document` shows the name "Outlined card", `styles.stroke: "S:1"` with the style's colour on the strokes, `strokeSides [2, 2, 2, 6]`, `boundVariables {strokeLeftWeight}`, and one undo step per command.
- **Web**: `cargo xtask wasm` passes (part of the gate).
- **Performance**: release UI bench at 10k layers (numbers above).
- `cargo +1.99.0 xtask ci` passes (fmt, clippy, tests, layers, assets, wasm), after merging #131 and #132.
## Tests
- `ui-egui` `layers_keyboard_tests` (3): F2 from the canvas and from a focused row (the row gets the keyboard back, Escape cancels without leaving the panel); Shift+F10 at a focused row, on the canvas below the selection and after a clicked row, the menu taking and giving back the keyboard; F8: the box around the selection, the screen-reader description, Shift+arrows sizing, Enter selecting, arrows moving it without nudging layers, Escape, the middle of the view with nothing selected, a click dropping it.
- `ui-egui` `perf_tests::a_layer_revealed_at_the_bottom_of_10k_layers_gets_the_keyboard_on_its_row`, plus the new bench rows.
- `engine` `select_area_tests` (2): marquee rules through the command (negative sizes, frames, starting inside a frame, deep, add toggling, empty spots), the UI request with no box, bad boxes refused (the junk sweep covers both new commands too).
- `engine` `stroke_style_tests` (2): a colour style on strokes (link, undo, style edits, detach); stroke weight and per-side weights bound to variables following the frame's mode, `node.set` per-side keys, scopes and override groups.
## Not in this PR
- The Menu (application) key: egui 0.36 doesn't deliver it.
- Enter doesn't rename: on the canvas and a focused row it already selects children / activates the row.
- The keyboard box doesn't pan the view when it is moved off screen (zoom and pan keys still work while it shows).
- Shift+F10 does nothing while a toolbar or properties control has the keyboard.
- Progress docs: area 3 95%/78% → 98%/82%, 2–4 h → 1–2 h; area 6 90%/72% → 95%/75%, 1–3 h → 1–2 h; mainstream depth 67.8% → 68.1%; percentages unchanged at this rounding; full parity ~323–621 h → ~322–618 h (target-app-parity, ROADMAP); ui-parity rows (context menus, keyboard box selection, layers from the keyboard); `docs/mcp.md` documents `object.rename` without a name, `view.contextMenu`, `select.area` and stroke styles and variables.
519 lines
20 KiB
Rust
519 lines
20 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, "view.contextMenu", &["Shift+F10"]).label("Context menu of the selection (the focused layer row or the canvas)"),
|
|
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", "F2"]),
|
|
k(Selection, "select.area", &["F8"]).label("Select with a box from the keyboard (arrows move it, Shift+arrows size it, Enter selects)"),
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
/// How far a text editing key moves or deletes on `platform`, as `(word, line)` for
|
|
/// [`crate::textedit::key`]. macOS: Alt by word, Cmd to the line's ends (Up/Down: the text's).
|
|
/// Windows and Linux: Ctrl by word, Ctrl+Home/End to the text's ends.
|
|
pub fn text_motion(platform: Platform, key: &str, m: Mods) -> (bool, bool) {
|
|
match platform {
|
|
Platform::Mac => (m.alt, m.cmd),
|
|
Platform::Other if matches!(key, "Home" | "End") => (false, m.cmd || m.ctrl),
|
|
Platform::Other => (m.cmd || m.ctrl, false),
|
|
}
|
|
}
|
|
|
|
/// 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
|
|
}
|