Skip to main content

facett_core/look/
keymap.rs

1//! **Actions & keymap** (§12) — exactly **one** semantic [`Action`] enum and
2//! **one** [`KeyMap`] (`Action → KeyboardShortcut`), themed per-OS and
3//! serialisable so users remap keys without recompiling. Resolution is via
4//! `InputState::consume_shortcut`; components never hardcode `Key`/`Modifiers`
5//! (COH-2, anti-pattern §27).
6
7use std::collections::BTreeMap;
8
9use egui::{Key, KeyboardShortcut, Modifiers};
10use serde::{Deserialize, Serialize};
11
12/// The single semantic action vocabulary shared by every facett component
13/// (COH-2). A given action is the **same chord in every component** because the
14/// chord lives only here, in the [`KeyMap`].
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
16pub enum Action {
17    // Editing / clipboard.
18    Copy,
19    Cut,
20    Paste,
21    SelectAll,
22    Undo,
23    Redo,
24    Find,
25    Save,
26    Confirm,
27    Cancel,
28    // Cell / caret navigation.
29    Left,
30    Right,
31    Up,
32    Down,
33    WordLeft,
34    WordRight,
35    LineStart,
36    LineEnd,
37    DocStart,
38    DocEnd,
39    PageUp,
40    PageDown,
41    // Selection extension.
42    ExtendLeft,
43    ExtendRight,
44    ExtendUp,
45    ExtendDown,
46    // Spatial / view (NAV-1).
47    ZoomIn,
48    ZoomOut,
49    FitToView,
50    // Pane / component focus (FOC-1).
51    FocusNext,
52    FocusPrev,
53    FocusLeft,
54    FocusRight,
55    FocusUp,
56    FocusDown,
57    /// Toggle the focus-hint overlay (FOC-3) / which-key trigger.
58    FocusHints,
59}
60
61impl Action {
62    /// Every action, for serde round-trip tests + a remap UI to enumerate.
63    pub const ALL: &'static [Action] = &[
64        Action::Copy,
65        Action::Cut,
66        Action::Paste,
67        Action::SelectAll,
68        Action::Undo,
69        Action::Redo,
70        Action::Find,
71        Action::Save,
72        Action::Confirm,
73        Action::Cancel,
74        Action::Left,
75        Action::Right,
76        Action::Up,
77        Action::Down,
78        Action::WordLeft,
79        Action::WordRight,
80        Action::LineStart,
81        Action::LineEnd,
82        Action::DocStart,
83        Action::DocEnd,
84        Action::PageUp,
85        Action::PageDown,
86        Action::ExtendLeft,
87        Action::ExtendRight,
88        Action::ExtendUp,
89        Action::ExtendDown,
90        Action::ZoomIn,
91        Action::ZoomOut,
92        Action::FitToView,
93        Action::FocusNext,
94        Action::FocusPrev,
95        Action::FocusLeft,
96        Action::FocusRight,
97        Action::FocusUp,
98        Action::FocusDown,
99        Action::FocusHints,
100    ];
101
102    pub fn as_str(self) -> &'static str {
103        match self {
104            Action::Copy => "Copy",
105            Action::Cut => "Cut",
106            Action::Paste => "Paste",
107            Action::SelectAll => "SelectAll",
108            Action::Undo => "Undo",
109            Action::Redo => "Redo",
110            Action::Find => "Find",
111            Action::Save => "Save",
112            Action::Confirm => "Confirm",
113            Action::Cancel => "Cancel",
114            Action::Left => "Left",
115            Action::Right => "Right",
116            Action::Up => "Up",
117            Action::Down => "Down",
118            Action::WordLeft => "WordLeft",
119            Action::WordRight => "WordRight",
120            Action::LineStart => "LineStart",
121            Action::LineEnd => "LineEnd",
122            Action::DocStart => "DocStart",
123            Action::DocEnd => "DocEnd",
124            Action::PageUp => "PageUp",
125            Action::PageDown => "PageDown",
126            Action::ExtendLeft => "ExtendLeft",
127            Action::ExtendRight => "ExtendRight",
128            Action::ExtendUp => "ExtendUp",
129            Action::ExtendDown => "ExtendDown",
130            Action::ZoomIn => "ZoomIn",
131            Action::ZoomOut => "ZoomOut",
132            Action::FitToView => "FitToView",
133            Action::FocusNext => "FocusNext",
134            Action::FocusPrev => "FocusPrev",
135            Action::FocusLeft => "FocusLeft",
136            Action::FocusRight => "FocusRight",
137            Action::FocusUp => "FocusUp",
138            Action::FocusDown => "FocusDown",
139            Action::FocusHints => "FocusHints",
140        }
141    }
142}
143
144/// `Action → KeyboardShortcut`. Serialisable (KeyboardShortcut is serde in egui),
145/// so a host can save/load a remapped keymap (KEY-6). Uses `Modifiers::COMMAND`
146/// which egui maps to ⌘ on macOS / Ctrl elsewhere (KEY-3).
147#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
148pub struct KeyMap {
149    pub bindings: BTreeMap<Action, KeyboardShortcut>,
150}
151
152/// Shorthand for a `COMMAND + key` chord.
153fn cmd(k: Key) -> KeyboardShortcut {
154    KeyboardShortcut::new(Modifiers::COMMAND, k)
155}
156/// Shorthand for a `COMMAND + SHIFT + key` chord.
157fn cmd_shift(k: Key) -> KeyboardShortcut {
158    KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k)
159}
160/// A bare key with no modifiers.
161fn bare(k: Key) -> KeyboardShortcut {
162    KeyboardShortcut::new(Modifiers::NONE, k)
163}
164fn with(m: Modifiers, k: Key) -> KeyboardShortcut {
165    KeyboardShortcut::new(m, k)
166}
167
168impl KeyMap {
169    /// The editing/clipboard core shared by all presets (COMMAND-based, so it is
170    /// ⌘ on macOS, Ctrl on Windows/Linux automatically).
171    fn common() -> BTreeMap<Action, KeyboardShortcut> {
172        let mut m = BTreeMap::new();
173        m.insert(Action::Copy, cmd(Key::C));
174        m.insert(Action::Cut, cmd(Key::X));
175        m.insert(Action::Paste, cmd(Key::V));
176        m.insert(Action::SelectAll, cmd(Key::A));
177        m.insert(Action::Undo, cmd(Key::Z));
178        m.insert(Action::Redo, cmd_shift(Key::Z));
179        m.insert(Action::Find, cmd(Key::F));
180        m.insert(Action::Save, cmd(Key::S));
181        m.insert(Action::Confirm, bare(Key::Enter));
182        m.insert(Action::Cancel, bare(Key::Escape));
183        m.insert(Action::Left, bare(Key::ArrowLeft));
184        m.insert(Action::Right, bare(Key::ArrowRight));
185        m.insert(Action::Up, bare(Key::ArrowUp));
186        m.insert(Action::Down, bare(Key::ArrowDown));
187        m.insert(Action::PageUp, bare(Key::PageUp));
188        m.insert(Action::PageDown, bare(Key::PageDown));
189        m.insert(Action::ExtendLeft, with(Modifiers::SHIFT, Key::ArrowLeft));
190        m.insert(Action::ExtendRight, with(Modifiers::SHIFT, Key::ArrowRight));
191        m.insert(Action::ExtendUp, with(Modifiers::SHIFT, Key::ArrowUp));
192        m.insert(Action::ExtendDown, with(Modifiers::SHIFT, Key::ArrowDown));
193        m.insert(Action::ZoomIn, cmd(Key::Plus));
194        m.insert(Action::ZoomOut, cmd(Key::Minus));
195        m.insert(Action::FitToView, cmd(Key::Num0));
196        m.insert(Action::FocusNext, with(Modifiers::CTRL, Key::Tab));
197        m.insert(Action::FocusPrev, with(Modifiers::CTRL | Modifiers::SHIFT, Key::Tab));
198        m.insert(Action::FocusHints, cmd_shift(Key::Space));
199        m
200    }
201
202    /// Windows preset: Ctrl-based, `Ctrl+Home/End` doc, `Ctrl+←/→` word,
203    /// `F6`/`Shift+F6` between panes (FOC-1).
204    pub fn windows() -> Self {
205        let mut m = Self::common();
206        m.insert(Action::WordLeft, with(Modifiers::CTRL, Key::ArrowLeft));
207        m.insert(Action::WordRight, with(Modifiers::CTRL, Key::ArrowRight));
208        m.insert(Action::LineStart, bare(Key::Home));
209        m.insert(Action::LineEnd, bare(Key::End));
210        m.insert(Action::DocStart, with(Modifiers::CTRL, Key::Home));
211        m.insert(Action::DocEnd, with(Modifiers::CTRL, Key::End));
212        m.insert(Action::FocusRight, bare(Key::F6));
213        m.insert(Action::FocusLeft, with(Modifiers::SHIFT, Key::F6));
214        m.insert(Action::FocusUp, with(Modifiers::CTRL, Key::ArrowUp));
215        m.insert(Action::FocusDown, with(Modifiers::CTRL, Key::ArrowDown));
216        Self { bindings: m }
217    }
218
219    /// macOS preset: `⌘←/→` line, `⌥←/→` word, `⌘↑/↓` doc (KEY-5).
220    pub fn macos() -> Self {
221        let mut m = Self::common();
222        m.insert(Action::WordLeft, with(Modifiers::ALT, Key::ArrowLeft));
223        m.insert(Action::WordRight, with(Modifiers::ALT, Key::ArrowRight));
224        m.insert(Action::LineStart, cmd(Key::ArrowLeft));
225        m.insert(Action::LineEnd, cmd(Key::ArrowRight));
226        m.insert(Action::DocStart, cmd(Key::ArrowUp));
227        m.insert(Action::DocEnd, cmd(Key::ArrowDown));
228        m.insert(Action::FocusRight, with(Modifiers::CTRL, Key::F6));
229        m.insert(Action::FocusLeft, with(Modifiers::CTRL | Modifiers::SHIFT, Key::F6));
230        m.insert(Action::FocusUp, with(Modifiers::CTRL, Key::ArrowUp));
231        m.insert(Action::FocusDown, with(Modifiers::CTRL, Key::ArrowDown));
232        Self { bindings: m }
233    }
234
235    /// Device preset: explicit, conservative — no accidental chordless single
236    /// keys for destructive actions (KEY-5). Mirrors Windows nav but keeps the
237    /// arrows for movement only.
238    pub fn device() -> Self {
239        // Device shares the Windows editing/nav layout (Ctrl-based, explicit).
240        Self::windows()
241    }
242
243    /// The chord bound to `action`, if any.
244    pub fn shortcut(&self, action: Action) -> Option<KeyboardShortcut> {
245        self.bindings.get(&action).copied()
246    }
247
248    /// Remap an action to a new chord (the serialisable remap UI's mutation).
249    pub fn set(&mut self, action: Action, chord: KeyboardShortcut) {
250        self.bindings.insert(action, chord);
251    }
252
253    /// Consume `action`'s chord from this frame's input — the single resolution
254    /// point. Returns `true` if the chord fired (and consumes it). Components call
255    /// this; they never inspect `Key`/`Modifiers` directly (COH-2, KEY-2).
256    pub fn consume(&self, action: Action, ui: &mut egui::Ui) -> bool {
257        match self.shortcut(action) {
258            Some(sc) => ui.input_mut(|i| i.consume_shortcut(&sc)),
259            None => false,
260        }
261    }
262
263    /// Human-readable label for an action's chord (for buttons/menus, KEY-3).
264    pub fn label(&self, action: Action, ctx: &egui::Context) -> String {
265        match self.shortcut(action) {
266            Some(sc) => ctx.format_shortcut(&sc),
267            None => String::new(),
268        }
269    }
270}
271
272impl Default for KeyMap {
273    fn default() -> Self {
274        Self::windows()
275    }
276}
277
278const KEYMAP_ID: &str = "facett_keymap";
279
280/// Publish the active keymap on the context so every component resolves the same
281/// chords (COH-2) without each one holding a copy of the theme. Called by
282/// [`crate::look::Theme::apply`].
283pub fn publish_keymap(ctx: &egui::Context, km: KeyMap) {
284    ctx.data_mut(|d| d.insert_temp(egui::Id::new(KEYMAP_ID), km));
285}
286
287/// The active keymap on the ui's context, or the [`KeyMap::default`] (Windows) if
288/// none has been published. The single resolution source for components.
289pub fn keymap(ui: &egui::Ui) -> KeyMap {
290    ui.data(|d| d.get_temp::<KeyMap>(egui::Id::new(KEYMAP_ID))).unwrap_or_default()
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296
297    #[test]
298    fn every_action_is_bound_in_every_preset() {
299        for (name, km) in [("win", KeyMap::windows()), ("mac", KeyMap::macos()), ("dev", KeyMap::device())] {
300            for &a in Action::ALL {
301                assert!(km.shortcut(a).is_some(), "{name}: action {a:?} is unbound");
302            }
303        }
304    }
305
306    #[test]
307    fn copy_is_the_same_chord_everywhere_coh2() {
308        // The chord lives only in the keymap → Copy resolves identically regardless
309        // of which component asks. (COH-2 unit-level proof.)
310        let km = KeyMap::windows();
311        let a = km.shortcut(Action::Copy).unwrap();
312        let b = km.shortcut(Action::Copy).unwrap();
313        assert_eq!(a, b);
314        assert_eq!(a, cmd(Key::C));
315    }
316
317    #[test]
318    fn mac_and_win_differ_on_line_nav_but_share_copy() {
319        let w = KeyMap::windows();
320        let m = KeyMap::macos();
321        assert_eq!(w.shortcut(Action::Copy), m.shortcut(Action::Copy), "Copy is COMMAND on both");
322        assert_ne!(w.shortcut(Action::LineStart), m.shortcut(Action::LineStart), "line nav differs by OS");
323        assert_eq!(m.shortcut(Action::LineStart), Some(cmd(Key::ArrowLeft)));
324        assert_eq!(w.shortcut(Action::WordLeft), Some(with(Modifiers::CTRL, Key::ArrowLeft)));
325        assert_eq!(m.shortcut(Action::WordLeft), Some(with(Modifiers::ALT, Key::ArrowLeft)));
326    }
327
328    #[test]
329    fn keymap_serde_round_trips_including_a_remap() {
330        let mut km = KeyMap::windows();
331        km.set(Action::Find, cmd_shift(Key::F)); // user remaps Find
332        let json = serde_json::to_string(&km).unwrap();
333        let back: KeyMap = serde_json::from_str(&json).unwrap();
334        assert_eq!(km, back);
335        assert_eq!(back.shortcut(Action::Find), Some(cmd_shift(Key::F)));
336    }
337
338    #[test]
339    fn consume_fires_only_for_the_bound_chord() {
340        let km = KeyMap::windows();
341        let ctx = egui::Context::default();
342        // Inject a Cmd+C key press.
343        let input = egui::RawInput {
344            events: vec![egui::Event::Key {
345                key: Key::C,
346                physical_key: None,
347                pressed: true,
348                repeat: false,
349                modifiers: Modifiers::COMMAND,
350            }],
351            modifiers: Modifiers::COMMAND,
352            ..Default::default()
353        };
354        let mut fired_copy = false;
355        let mut fired_paste = false;
356        let _ = ctx.run(input, |ctx| {
357            egui::CentralPanel::default().show(ctx, |ui| {
358                fired_copy = km.consume(Action::Copy, ui);
359                fired_paste = km.consume(Action::Paste, ui);
360            });
361        });
362        assert!(fired_copy, "Cmd+C should fire Copy");
363        assert!(!fired_paste, "Cmd+C must not fire Paste");
364    }
365}