use std::collections::BTreeMap;
use egui::{Key, KeyboardShortcut, Modifiers};
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum Action {
Copy,
Cut,
Paste,
SelectAll,
Undo,
Redo,
Find,
Save,
Confirm,
Cancel,
Left,
Right,
Up,
Down,
WordLeft,
WordRight,
LineStart,
LineEnd,
DocStart,
DocEnd,
PageUp,
PageDown,
ExtendLeft,
ExtendRight,
ExtendUp,
ExtendDown,
ZoomIn,
ZoomOut,
FitToView,
FocusNext,
FocusPrev,
FocusLeft,
FocusRight,
FocusUp,
FocusDown,
FocusHints,
}
impl Action {
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",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct KeyMap {
pub bindings: BTreeMap<Action, KeyboardShortcut>,
}
fn cmd(k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(Modifiers::COMMAND, k)
}
fn cmd_shift(k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, k)
}
fn bare(k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(Modifiers::NONE, k)
}
fn with(m: Modifiers, k: Key) -> KeyboardShortcut {
KeyboardShortcut::new(m, k)
}
impl KeyMap {
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
}
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 }
}
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 }
}
pub fn device() -> Self {
Self::windows()
}
pub fn shortcut(&self, action: Action) -> Option<KeyboardShortcut> {
self.bindings.get(&action).copied()
}
pub fn set(&mut self, action: Action, chord: KeyboardShortcut) {
self.bindings.insert(action, chord);
}
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,
}
}
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";
pub fn publish_keymap(ctx: &egui::Context, km: KeyMap) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new(KEYMAP_ID), km));
}
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() {
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)); 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();
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");
}
}