//! Editable keyboard shortcut table shared by the CUI dialog and key events. use super::{Message, OpenCADStudio}; use iced::Task; use rustc_hash::FxHashMap; use std::collections::BTreeMap; #[cfg(target_os = "macos")] const ACCEL: &str = "CMD"; #[cfg(not(target_os = "macos"))] const ACCEL: &str = "CTRL"; /// Every active binding shipped with a fresh configuration. Values are command /// names where possible; the small set of input actions is resolved below. pub(super) fn default_bindings() -> BTreeMap { [ ("F1".to_string(), "HELP"), ("F2".to_string(), "COMMANDHISTORY"), ("F3".to_string(), "TOGGLEOSNAP"), ("F5".to_string(), "ISOPLANE"), ("F7".to_string(), "GRID"), ("F8".to_string(), "ORTHO"), ("F9".to_string(), "SNAP"), ("F10".to_string(), "POLAR"), ("F11".to_string(), "OTRACK"), ("F12".to_string(), "DYNINPUT"), (format!("{ACCEL}+0"), "CLEANSCREEN"), (format!("{ACCEL}+1"), "PROPERTIES"), (format!("{ACCEL}+N"), "NEW"), (format!("{ACCEL}+O"), "OPEN"), (format!("{ACCEL}+P"), "PLOT"), (format!("{ACCEL}+Q"), "QUIT"), (format!("{ACCEL}+S"), "SAVE"), (format!("{ACCEL}+SHIFT+S"), "SAVEAS"), (format!("{ACCEL}+Z"), "UNDO"), (format!("{ACCEL}+SHIFT+Z"), "REDO"), (format!("{ACCEL}+Y"), "REDO"), (format!("{ACCEL}+F"), "FIND"), (format!("{ACCEL}+H"), "FIND"), (format!("{ACCEL}+A"), "SELECTALL"), (format!("{ACCEL}+C"), "COPYCLIP"), (format!("{ACCEL}+SHIFT+C"), "COPYBASE"), (format!("{ACCEL}+X"), "CUTCLIP"), (format!("{ACCEL}+V"), "PASTECLIP"), (format!("{ACCEL}+SHIFT+V"), "PASTEBLOCK"), ("ENTER".to_string(), "FINALIZE"), ("SPACE".to_string(), "COMMANDSPACE"), ("ESCAPE".to_string(), "CANCEL"), ("DELETE".to_string(), "DELETESELECTED"), ("BACKSPACE".to_string(), "BACKSPACE"), ("TAB".to_string(), "DYNTAB"), ("UP".to_string(), "HISTORYPREV"), ("DOWN".to_string(), "HISTORYNEXT"), ("LEFT".to_string(), "CARETLEFT"), ("RIGHT".to_string(), "CARETRIGHT"), ] .into_iter() .map(|(key, action)| (key, action.to_string())) .collect() } /// Canonical form used by the editor, command interface, config and key event /// resolver. Modifier order is stable so equivalent user input shares one row. pub(super) fn normalize_key(value: &str) -> String { let mut ctrl = false; let mut cmd = false; let mut alt = false; let mut shift = false; let mut key = String::new(); for part in value.split('+').map(str::trim).filter(|part| !part.is_empty()) { match part.to_uppercase().as_str() { "CTRL" | "CONTROL" => ctrl = true, "CMD" | "COMMAND" | "META" | "SUPER" => cmd = true, "ALT" | "OPTION" => alt = true, "SHIFT" => shift = true, "ESC" => key = "ESCAPE".to_string(), "RETURN" => key = "ENTER".to_string(), "ARROWUP" => key = "UP".to_string(), "ARROWDOWN" => key = "DOWN".to_string(), "ARROWLEFT" => key = "LEFT".to_string(), "ARROWRIGHT" => key = "RIGHT".to_string(), other => key = other.to_string(), } } let mut parts = Vec::with_capacity(5); if ctrl { parts.push("CTRL"); } if cmd { parts.push("CMD"); } if alt { parts.push("ALT"); } if shift { parts.push("SHIFT"); } if !key.is_empty() { parts.push(&key); } parts.join("+") } /// Keys that used to bypass focused widgets. Clipboard and selection commands /// intentionally stay out so text fields retain their native shortcuts. pub(super) fn is_global_key(key: &str) -> bool { let base = key.rsplit('+').next().unwrap_or(key); base.strip_prefix('F').is_some_and(|digits| { !digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit()) }) || matches!(base, "ESCAPE") || (key.starts_with(ACCEL) && !matches!( key.rsplit('+').next(), Some("A" | "C" | "V" | "X") )) } fn is_named_key(key: &str) -> bool { matches!( key, "ENTER" | "SPACE" | "ESCAPE" | "DELETE" | "BACKSPACE" | "TAB" | "UP" | "DOWN" | "LEFT" | "RIGHT" | "HOME" | "END" | "PAGEUP" | "PAGEDOWN" | "INSERT" ) || key.strip_prefix('F').is_some_and(|digits| { !digits.is_empty() && digits.chars().all(|ch| ch.is_ascii_digit()) }) } impl OpenCADStudio { pub(super) fn apply_shortcut_editor_rows(&mut self) { let bindings: FxHashMap = self .shortcut_editor_rows .iter() .filter_map(|(key, action)| { let key = normalize_key(key); let action = action.trim().to_uppercase(); (!key.is_empty() && !action.is_empty()).then_some((key, action)) }) .collect(); self.shortcut_bindings = bindings; self.persist_settings_if_changed(); } pub(super) fn run_shortcut(&mut self, key: &str) -> Task { let action = self.shortcut_bindings.get(key).or_else(|| { let base = key.rsplit('+').next()?; is_named_key(base).then(|| self.shortcut_bindings.get(base)).flatten() }); let Some(action) = action.cloned() else { return Task::none(); }; let message = match action.as_str() { "FINALIZE" => Message::CommandFinalize, "COMMANDSPACE" => Message::CommandSpace, "CANCEL" => Message::CommandEscape, "DELETESELECTED" => Message::DeleteSelected, "BACKSPACE" => Message::CommandBackspace, "DYNTAB" => Message::DynTabNext, "HISTORYPREV" => Message::CommandHistoryPrev, "HISTORYNEXT" => Message::CommandHistoryNext, "CARETLEFT" => Message::MTextCaretMove(-1), "CARETRIGHT" => Message::MTextCaretMove(1), "COMMANDHISTORY" => Message::CommandHistoryToggle, "TOGGLEOSNAP" => Message::ToggleSnapEnabled, "OTRACK" => Message::ToggleOTrack, "DYNINPUT" => Message::ToggleDynInput, "SELECTALL" => Message::SelectAllShortcut, "PASTECLIP" => Message::PasteShortcut, other => Message::Command(other.to_string()), }; self.update(message) } }