facett-core 0.1.4

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **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::Key {
                key: Key::C,
                physical_key: None,
                pressed: true,
                repeat: false,
                modifiers: Modifiers::COMMAND,
            }],
            modifiers: Modifiers::COMMAND,
            ..Default::default()
        };
        let mut fired_copy = false;
        let mut fired_paste = false;
        let _ = ctx.run(input, |ctx| {
            egui::CentralPanel::default().show(ctx, |ui| {
                fired_copy = km.consume(Action::Copy, ui);
                fired_paste = km.consume(Action::Paste, ui);
            });
        });
        assert!(fired_copy, "Cmd+C should fire Copy");
        assert!(!fired_paste, "Cmd+C must not fire Paste");
    }
}