feat(input): add editable shortcut table
Persist all active bindings and route keyboard events through the configured table.\n\nRefs #449
This commit is contained in:
parent
3f6959d7d0
commit
2f86bfde8e
9 changed files with 445 additions and 334 deletions
187
src/app/shortcuts.rs
Normal file
187
src/app/shortcuts.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
//! 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<String, String> {
|
||||
[
|
||||
("F1".to_string(), "HELP"),
|
||||
("F2".to_string(), "COMMANDHISTORY"),
|
||||
("F3".to_string(), "TOGGLEOSNAP"),
|
||||
("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<String, String> = 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<Message> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue