Files
craft-libs/crates/keymap/src/lib.rs
T
2026-10-24 02:29:57 +00:00

164 lines
8.5 KiB
Rust

#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! Keyboard bindings for the Craft suite.
//!
//! [`chord`] holds the key and modifier types, the canonical text form that settings and control
//! protocols store, display text per platform, and a reader for other applications' key text.
//! [`command`] names what a binding runs, [`scope`] where it works, and [`keymap`] holds an app's
//! catalog with default bindings, the user's [`Overrides`], dispatch ([`Keymap::lookup`]),
//! editing and conflicts. [`profiles`] keeps named sets of overrides; [`reserved`] lists the
//! chords each operating system takes, and a keymap reserves no chord unless the app opts in with
//! [`Keymap::with_reserved`], e.g. `with_reserved(reserved::system(platform))`. [`import`] brings
//! in other applications' shortcut sets. [`layout`] decides which chord a key press means on the
//! user's keyboard layout, for matching, recording and labels. [`editor`] is the model of a
//! shortcut editor: undo, a chord recorder, conflict queries and search. With the `egui` feature,
//! the `egui` module
//! converts to and from egui keys, matches key presses against bindings and dispatches a frame's
//! key events.
//!
//! ```
//! use craft_keymap::{Chord, Platform};
//!
//! let chord = Chord::parse("⇧⌘Z").unwrap();
//! assert_eq!(chord.to_string(), "Cmd+Shift+Z");
//! assert_eq!(chord.display(Platform::Mac), "⇧⌘Z");
//! assert_eq!(chord.display(Platform::Windows), "Ctrl+Shift+Z");
//! ```
//!
//! # A catalog and dispatch
//!
//! The app lists every command it can bind. A command with parameters has the key
//! `window.panel {"panel":"layers"}` in settings and reports. Tools of one group share a key and
//! cycle. A binding can name a scope other than the command's home scope.
//!
//! ```
//! use craft_keymap::{Active, Bindable, Binding, Chord, CommandRef, Keymap, Kind, Lookup, Platform, Scope, Scopes};
//! use serde_json::json;
//!
//! let key = |text: &str| Binding::parse(text).unwrap();
//! let layers = CommandRef::with_params("window.panel", json!({"panel": "layers"}));
//! let select = Kind::Tool { group: Some("select".into()) };
//! let catalog = vec![
//! Bindable::new("file.save", "Save", Scope::app()).with_path(vec!["File".into(), "Save".into()]).with_default(key("Cmd+S")),
//! Bindable::new(layers.clone(), "Layers", Scope::app()).with_path(vec!["Window".into(), "Layers".into()]).with_default(key("F7")),
//! Bindable::new("tool.marquee", "Marquee Tool", Scope::app()).with_kind(select.clone()).with_default(key("M")),
//! Bindable::new("tool.ellipse", "Elliptical Marquee Tool", Scope::app()).with_kind(select).with_default(key("M")),
//! // Home scope: the Timeline panel, where it wins over the app's L.
//! Bindable::new("timeline.loop", "Loop", Scope::new("Timeline").unwrap()).with_default(key("L")),
//! Bindable::new("layer.lock", "Lock Layer", Scope::app()).with_default(key("L")),
//! // An app command whose binding works only in the Text context.
//! Bindable::new("type.bold", "Bold", Scope::app()).with_default(key("Text: Cmd+B")),
//! ];
//! let keymap = Keymap::new(catalog, Scopes::new().with_panel("Timeline").with_context("Text"), Platform::Mac);
//! assert_eq!(layers.key(), r#"window.panel {"panel":"layers"}"#);
//!
//! // Dispatch: the chord pressed, whether it is a double press, and what has the keyboard.
//! let press = |text: &str, active: &Active| keymap.lookup(&Chord::parse(text).unwrap(), false, active);
//! assert_eq!(press("F7", &Active::NONE), Lookup::Run(layers));
//! assert_eq!(press("L", &Active::panel("Timeline")), Lookup::Run("timeline.loop".into()));
//! assert_eq!(press("L", &Active::NONE), Lookup::Run("layer.lock".into()));
//! assert_eq!(press("M", &Active::NONE), Lookup::Cycle(vec!["tool.marquee".into(), "tool.ellipse".into()]));
//! let editing = Active { contexts: &["Text"], typing: true, ..Active::NONE };
//! assert_eq!(press("Cmd+B", &editing), Lookup::Run("type.bold".into()));
//! assert_eq!(press("M", &editing), Lookup::None, "plain keys go to the text field");
//! ```
//!
//! With the `egui` feature, `egui::Dispatcher` does this for a frame's key events.
//!
//! # Storing overrides
//!
//! [`Overrides`] is a field of the app's craft-settings type, marked
//! `#[serde(with = "craft_keymap::keymap::overrides_list")]` so that it is stored as an array of
//! `{ command, keys }` tables. craft-settings merges tables key by key with what the file holds,
//! but replaces an array whole, so a reset command (an entry removed) stays removed after saving.
//! Without the attribute, overrides with at most one text per command are written as a table.
//!
//! ```no_run
//! use craft_keymap::{Binding, Keymap, Overrides, Platform, Policy, Scopes};
//! use craft_settings::{Location, Settings, SettingsStore};
//! use serde::{Deserialize, Serialize};
//!
//! #[derive(Serialize, Deserialize, Default, Clone, PartialEq)]
//! #[serde(default)]
//! struct Prefs {
//! #[serde(with = "craft_keymap::keymap::overrides_list")]
//! shortcuts: Overrides,
//! }
//!
//! impl Settings for Prefs {
//! const APP_ID: &'static str = "acmecraft";
//! const SCHEMA_VERSION: u32 = 1;
//! }
//!
//! # let catalog = Vec::new();
//! let (mut store, _report) = SettingsStore::<Prefs>::open(Location::Auto);
//! let mut keymap = Keymap::new(catalog, Scopes::new(), Platform::current()).with_overrides(store.get().shortcuts.clone());
//! // Entries for unknown commands and texts that do not parse are kept and listed here.
//! let _problems = keymap.problems();
//!
//! // After each change in the shortcut editor:
//! keymap.assign(&"file.save".into(), Binding::parse("Cmd+Alt+S").unwrap(), Policy::Steal).ok();
//! store.update(|p| p.shortcuts = keymap.overrides().clone());
//! ```
//!
//! An app with named sets stores only [`Profiles`] in place of the `Overrides` field and builds
//! the keymap from the active set:
//! `with_overrides(prefs.shortcut_sets.active_overrides(&builtins).clone())`.
//!
//! # Importing a shortcut set
//!
//! The app's reader builds a [`ForeignSet`]; [`plan`] matches it without changing anything, so a
//! review UI can show [`ImportPlan::commands`] and the report first.
//!
//! ```
//! use craft_keymap::{Bindable, Binding, ContextRule, Flavor, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, Keymap, Platform};
//! use craft_keymap::{Scope, Scopes, plan};
//!
//! let save = Bindable::new("file.save", "Save", Scope::app())
//! .with_path(vec!["File".into(), "Save".into()])
//! .with_default(Binding::parse("Cmd+S").unwrap());
//! let mut keymap = Keymap::new(vec![save], Scopes::new(), Platform::Windows);
//!
//! let read = |text: &str| ForeignKey::read(Some("Default"), text, Flavor::Windows);
//! let set = ForeignSet::new(vec![
//! ForeignEntry::new(Some("File"), ["Save"]).with_key(read("Ctrl+Alt+S")).at_line(12),
//! ForeignEntry::new(Some("File"), ["Frobnicate"]).with_key(read("Ctrl+F")).at_line(13),
//! ])
//! .with_name("My Set");
//! let options = ImportOptions { contexts: vec![("Default".into(), ContextRule::Home)], ..ImportOptions::default() };
//! let planned = plan(&set, &keymap, &options);
//! assert_eq!(planned.commands.len(), 1);
//!
//! let report = planned.apply(&mut keymap);
//! assert_eq!(report.changed[0].to, ["Cmd+Alt+S"]);
//! assert_eq!((report.unmatched[0].item.as_str(), report.unmatched[0].line), ("File > Frobnicate", Some(13)));
//! assert_eq!(keymap.bindings(&"file.save".into()), [Binding::parse("Cmd+Alt+S").unwrap()]);
//! ```
pub mod chord;
pub mod command;
pub mod editor;
#[cfg(feature = "egui")]
pub mod egui;
pub mod import;
pub mod keymap;
pub mod layout;
pub mod profiles;
pub mod reserved;
pub mod scope;
#[cfg(feature = "ui")]
pub mod ui;
pub use chord::foreign::Flavor;
pub use chord::{Chord, Key, MAX_CHORD_TEXT, Mods, ParseError, Platform, Trigger};
pub use command::{CommandRef, MAX_KEY};
pub use editor::{DOUBLE_PRESS, EditSession, MAX_UNDO, MAX_UNDO_BYTES, Recorded, Recorder};
pub use import::{ContextRule, ForeignEntry, ForeignKey, ForeignSet, ImportOptions, ImportPlan, ImportReport, NoneDefined, Start, plan};
pub use keymap::{
AssignError, Assigned, Bindable, Binding, BindingError, Conflict, ConflictKind, DropReason, Holder, Keymap, Kind, Lookup, MAX_BINDING_TEXT,
MAX_BINDINGS, MAX_COMMANDS, MAX_CONFLICTS, Overrides, Policy, Problem, TypingRule, text_editing_chords,
};
pub use layout::{KeyInput, Layout};
pub use profiles::{Builtins, MAX_EXPORT_BYTES, MAX_SET_NAME, MAX_SETS, ProfileError, Profiles, SetProblem};
pub use scope::{Active, MAX_SCOPE_NAME, Scope, ScopeKind, Scopes};