facett-core/src/look/keymap.rs
//! **Actions & keymap** (§12) — exactly **one** semantic [`Action`] enum and
//! **one** [`KeyMap`] (`Action → KeyboardShortcut`), themed per-OS and
//! serialisable so users remap keys without recompiling. Resolution is via
//! `InputState::consume_shortcut`; components never hardcode `Key`/`Modifiers`
//! (COH-2, anti-pattern §27).
use std::collections::BTreeMap;
use egui::{Key, KeyboardShortcut, Modifiers};
use serde::{Deserialize, Serialize};
/// The single semantic action vocabulary shared by every facett component
/// (COH-2). A given action is the **same chord in every component** because the
/// chord lives only here, in the [`KeyMap`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Action {
// Editing / clipboard.
Copy,
Cut,
Paste,
SelectAll,
Undo,
Redo,
Find,
Save,
Confirm,
Cancel,
// Cell / caret navigation.
Left,
Right,
Up,
Down,
WordLeft,
WordRight,
LineStart,
LineEnd,
DocStart,
DocEnd,
PageUp,
PageDown,
// Selection extension.
ExtendLeft,
ExtendRight,
ExtendUp,
ExtendDown,
// Spatial / view (NAV-1).
ZoomIn,
ZoomOut,
FitToView,
// Pane / component focus (FOC-1).
FocusNext,
FocusPrev,
FocusLeft,
FocusRight,
FocusUp,
FocusDown,
/// Toggle the focus-hint overlay (FOC-3) / which-key trigger.
FocusHints,
}
impl Action {
/// Every action, for serde round-trip tests + a remap UI to enumerate.
pub const ALL: &'static [Action] = &[
Action::Copy,
Action::Cut,
Action::Paste,
Action::SelectAll,
Action::Undo,
Action::Redo,
Action::Find,
Action::Save,
Action::Confirm,
Action::Cancel,
Action::Left,
Action::Right,
Action::Up,
Action::Down,
Action::WordLeft,
Action::WordRight,
Action::LineStart,
Action::LineEnd,
Action::DocStart,
Action::DocEnd,
Action::PageUp,
Action::PageDown,
Action::ExtendLeft,
Action::ExtendRight,
Action::ExtendUp,
Action::ExtendDown,
Action::ZoomIn,
Action::ZoomOut,
Action::FitToView,
Action::FocusNext,
Action::FocusPrev,
Action::FocusLeft,
Action::FocusRight,
Action::FocusUp,
Action::FocusDown,
Action::FocusHints,
];
pub fn as_str(self) -> &'static str {
match self {
Action::Copy => "Copy",
Action::Cut => "Cut",
Action::Paste => "Paste",
Action::SelectAll => "SelectAll",
Action::Undo => "Undo",
Action::Redo => "Redo",
Action::Find => "Find",
Action::Save => "Save",
Action::Confirm => "Confirm",
Action::Cancel => "Cancel",
Action::Left => "Left",
Action::Right => "Right",
Action::Up => "Up",
Action::Down => "Down",
Action::WordLeft => "WordLeft",
Action::WordRight => "WordRight",
Action::LineStart => "LineStart",
Action::LineEnd => "LineEnd",
Action::DocStart => "DocStart",
Action::DocEnd => "DocEnd",
Action::PageUp => "PageUp",
Action::PageDown => "PageDown",
Action::ExtendLeft => "ExtendLeft",
Action::ExtendRight => "ExtendRight",
Action::ExtendUp => "ExtendUp",
Action::ExtendDown => "ExtendDown",
Action::ZoomIn => "ZoomIn",
Action::ZoomOut => "ZoomOut",
Action::FitToView => "FitToView",
Action::FocusNext => "FocusNext",
Action::FocusPrev => "FocusPrev",
Action::FocusLeft => "FocusLeft",
Action::FocusRight => "FocusRight",
Action::FocusUp => "FocusUp",
Action::FocusDown => "FocusDown",
Action::FocusHints => "FocusHints",
}
}
}
/// `Action → KeyboardShortcut`. Serialisable (KeyboardShortcut is serde in egui),
/// so a host can save/load a remapped keymap (KEY-6). Uses `Modifiers::COMMAND`
/// which egui maps to ⌘ on macOS / Ctrl elsewhere (KEY-3).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyMap {
pub bindings: BTreeMap<Action, KeyboardShortcut>,
}
/// Shorthand for a `COMMAND + key` chord.
fn cmd(k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(Modifiers::COMMAND, k)
}
/// Shorthand for a `COMMAND + SHIFT + key` chord.
fn cmd_shift(k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k)
}
/// A bare key with no modifiers.
fn bare(k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(Modifiers::NONE, k)
}
fn with(m: Modifiers, k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(m, k)
}
impl KeyMap {
/// The editing/clipboard core shared by all presets (COMMAND-based, so it is
/// ⌘ on macOS, Ctrl on Windows/Linux automatically).
fn common() -> BTreeMap<Action, KeyboardShortcut> {
let mut m = BTreeMap::new();
m.insert(Action::Copy, cmd(Key::C));
m.insert(Action::Cut, cmd(Key::X));
m.insert(Action::Paste, cmd(Key::V));
m.insert(Action::SelectAll, cmd(Key::A));
m.insert(Action::Undo, cmd(Key::Z));
m.insert(Action::Redo, cmd_shift(Key::Z));
m.insert(Action::Find, cmd(Key::F));
m.insert(Action::Save, cmd(Key::S));
m.insert(Action::Confirm, bare(Key::Enter));
m.insert(Action::Cancel, bare(Key::Escape));
m.insert(Action::Left, bare(Key::ArrowLeft));
m.insert(Action::Right, bare(Key::ArrowRight));
m.insert(Action::Up, bare(Key::ArrowUp));
m.insert(Action::Down, bare(Key::ArrowDown));
m.insert(Action::PageUp, bare(Key::PageUp));
m.insert(Action::PageDown, bare(Key::PageDown));
m.insert(Action::ExtendLeft, with(Modifiers::SHIFT, Key::ArrowLeft));
m.insert(Action::ExtendRight, with(Modifiers::SHIFT, Key::ArrowRight));
m.insert(Action::ExtendUp, with(Modifiers::SHIFT, Key::ArrowUp));
m.insert(Action::ExtendDown, with(Modifiers::SHIFT, Key::ArrowDown));
m.insert(Action::ZoomIn, cmd(Key::Plus));
m.insert(Action::ZoomOut, cmd(Key::Minus));
m.insert(Action::FitToView, cmd(Key::Num0));
m.insert(Action::FocusNext, with(Modifiers::CTRL, Key::Tab));
m.insert(Action::FocusPrev, with(Modifiers::CTRL | Modifiers::SHIFT, Key::Tab));
m.insert(Action::FocusHints, cmd_shift(Key::Space));
m
}
/// Windows preset: Ctrl-based, `Ctrl+Home/End` doc, `Ctrl+←/→` word,
/// `F6`/`Shift+F6` between panes (FOC-1).
pub fn windows() -> Self {
let mut m = Self::common();
m.insert(Action::WordLeft, with(Modifiers::CTRL, Key::ArrowLeft));
m.insert(Action::WordRight, with(Modifiers::CTRL, Key::ArrowRight));
m.insert(Action::LineStart, bare(Key::Home));
m.insert(Action::LineEnd, bare(Key::End));
m.insert(Action::DocStart, with(Modifiers::CTRL, Key::Home));
m.insert(Action::DocEnd, with(Modifiers::CTRL, Key::End));
m.insert(Action::FocusRight, bare(Key::F6));
m.insert(Action::FocusLeft, with(Modifiers::SHIFT, Key::F6));
m.insert(Action::FocusUp, with(Modifiers::CTRL, Key::ArrowUp));
m.insert(Action::FocusDown, with(Modifiers::CTRL, Key::ArrowDown));
Self { bindings: m }
}
/// macOS preset: `⌘←/→` line, `⌥←/→` word, `⌘↑/↓` doc (KEY-5).
pub fn macos() -> Self {
let mut m = Self::common();
m.insert(Action::WordLeft, with(Modifiers::ALT, Key::ArrowLeft));
m.insert(Action::WordRight, with(Modifiers::ALT, Key::ArrowRight));
m.insert(Action::LineStart, cmd(Key::ArrowLeft));
m.insert(Action::LineEnd, cmd(Key::ArrowRight));
m.insert(Action::DocStart, cmd(Key::ArrowUp));
m.insert(Action::DocEnd, cmd(Key::ArrowDown));
m.insert(Action::FocusRight, with(Modifiers::CTRL, Key::F6));
m.insert(Action::FocusLeft, with(Modifiers::CTRL | Modifiers::SHIFT, Key::F6));
m.insert(Action::FocusUp, with(Modifiers::CTRL, Key::ArrowUp));
m.insert(Action::FocusDown, with(Modifiers::CTRL, Key::ArrowDown));
Self { bindings: m }
}
/// Device preset: explicit, conservative — no accidental chordless single
/// keys for destructive actions (KEY-5). Mirrors Windows nav but keeps the
/// arrows for movement only.
pub fn device() -> Self {
// Device shares the Windows editing/nav layout (Ctrl-based, explicit).
Self::windows()
}
/// The chord bound to `action`, if any.
pub fn shortcut(&self, action: Action) -> Option<KeyboardShortcut> {
self.bindings.get(&action).copied()
}
/// Remap an action to a new chord (the serialisable remap UI's mutation).
pub fn set(&mut self, action: Action, chord: KeyboardShortcut) {
self.bindings.insert(action, chord);
}
/// Consume `action`'s chord from this frame's input — the single resolution
/// point. Returns `true` if the chord fired (and consumes it). Components call
/// this; they never inspect `Key`/`Modifiers` directly (COH-2, KEY-2).
pub fn consume(&self, action: Action, ui: &mut egui::Ui) -> bool {
match self.shortcut(action) {
Some(sc) => ui.input_mut(|i| i.consume_shortcut(&sc)),
None => false,
}
}
/// Human-readable label for an action's chord (for buttons/menus, KEY-3).
pub fn label(&self, action: Action, ctx: &egui::Context) -> String {
match self.shortcut(action) {
Some(sc) => ctx.format_shortcut(&sc),
None => String::new(),
}
}
}
impl Default for KeyMap {
fn default() -> Self {
Self::windows()
}
}
const KEYMAP_ID: &str = "facett_keymap";
/// Publish the active keymap on the context so every component resolves the same
/// chords (COH-2) without each one holding a copy of the theme. Called by
/// [`crate::look::Theme::apply`].
pub fn publish_keymap(ctx: &egui::Context, km: KeyMap) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new(KEYMAP_ID), km));
}
/// The active keymap on the ui's context, or the [`KeyMap::default`] (Windows) if
/// none has been published. The single resolution source for components.
pub fn keymap(ui: &egui::Ui) -> KeyMap {
ui.data(|d| d.get_temp::<KeyMap>(egui::Id::new(KEYMAP_ID))).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_action_is_bound_in_every_preset() {
for (name, km) in [("win", KeyMap::windows()), ("mac", KeyMap::macos()), ("dev", KeyMap::device())] {
for &a in Action::ALL {
assert!(km.shortcut(a).is_some(), "{name}: action {a:?} is unbound");
}
}
}
#[test]
fn copy_is_the_same_chord_everywhere_coh2() {
// The chord lives only in the keymap → Copy resolves identically regardless
// of which component asks. (COH-2 unit-level proof.)
let km = KeyMap::windows();
let a = km.shortcut(Action::Copy).unwrap();
let b = km.shortcut(Action::Copy).unwrap();
assert_eq!(a, b);
assert_eq!(a, cmd(Key::C));
}
#[test]
fn mac_and_win_differ_on_line_nav_but_share_copy() {
let w = KeyMap::windows();
let m = KeyMap::macos();
assert_eq!(w.shortcut(Action::Copy), m.shortcut(Action::Copy), "Copy is COMMAND on both");
assert_ne!(w.shortcut(Action::LineStart), m.shortcut(Action::LineStart), "line nav differs by OS");
assert_eq!(m.shortcut(Action::LineStart), Some(cmd(Key::ArrowLeft)));
assert_eq!(w.shortcut(Action::WordLeft), Some(with(Modifiers::CTRL, Key::ArrowLeft)));
assert_eq!(m.shortcut(Action::WordLeft), Some(with(Modifiers::ALT, Key::ArrowLeft)));
}
#[test]
fn keymap_serde_round_trips_including_a_remap() {
let mut km = KeyMap::windows();
km.set(Action::Find, cmd_shift(Key::F)); // user remaps Find
let json = serde_json::to_string(&km).unwrap();
let back: KeyMap = serde_json::from_str(&json).unwrap();
assert_eq!(km, back);
assert_eq!(back.shortcut(Action::Find), Some(cmd_shift(Key::F)));
}
#[test]
fn consume_fires_only_for_the_bound_chord() {
let km = KeyMap::windows();
let ctx = egui::Context::default();
// Inject a Cmd+C key press.
let input = egui::RawInput {
events: vec![egui::Event::ModifiersChanged(Modifiers::COMMAND), egui::Event::Key {
key: Key::C,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Modifiers::COMMAND,
}],
..Default::default()
};
let mut fired_copy = false;
let mut fired_paste = false;
ctx.run_ui(input, |ui| {
egui::CentralPanel::default().show(ui, |ui| {
fired_copy = km.consume(Action::Copy, ui);
fired_paste = km.consume(Action::Paste, ui);
});
}).textures_delta.clear();
assert!(fired_copy, "Cmd+C should fire Copy");
assert!(!fired_paste, "Cmd+C must not fire Paste");
}
}