forked from 1b-agent/opencadstudio-upstream
317 lines
11 KiB
Rust
317 lines
11 KiB
Rust
//! 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"),
|
|
("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())
|
|
})
|
|
}
|
|
|
|
/// Input actions `run_shortcut` resolves directly instead of routing through
|
|
/// the command dispatcher — valid shortcut commands that never appear in the
|
|
/// command registry. Used to validate the shortcut editor's command column.
|
|
pub(super) const INPUT_ACTIONS: &[&str] = &[
|
|
"FINALIZE",
|
|
"COMMANDSPACE",
|
|
"CANCEL",
|
|
"DELETESELECTED",
|
|
"BACKSPACE",
|
|
"DYNTAB",
|
|
"HISTORYPREV",
|
|
"HISTORYNEXT",
|
|
"CARETLEFT",
|
|
"CARETRIGHT",
|
|
"COMMANDHISTORY",
|
|
"TOGGLEOSNAP",
|
|
"OTRACK",
|
|
"DYNINPUT",
|
|
"SELECTALL",
|
|
"PASTECLIP",
|
|
];
|
|
|
|
impl OpenCADStudio {
|
|
/// Commit the working rows to the live bindings, discarding an
|
|
/// unfinished draft and reporting duplicates. Shared by Apply and
|
|
/// Apply-and-Exit.
|
|
pub(super) fn finish_shortcut_editor(&mut self) {
|
|
// An add must end in a complete shortcut or a cancellation;
|
|
// applying with a half-filled draft discards the draft.
|
|
if self.shortcut_pending_add {
|
|
self.shortcut_editor_rows.remove(0);
|
|
self.shortcut_pending_add = false;
|
|
self.command_line
|
|
.push_error(crate::tf!("Incomplete shortcut row discarded.").as_ref());
|
|
}
|
|
self.shortcut_capture_row = None;
|
|
// Rows normalizing to the same key silently overwrite each other in
|
|
// the binding map — surface the conflict instead.
|
|
let mut seen = rustc_hash::FxHashSet::default();
|
|
let mut duplicates = Vec::new();
|
|
for (key, _) in &self.shortcut_editor_rows {
|
|
let key = normalize_key(key);
|
|
if key.is_empty() {
|
|
continue;
|
|
}
|
|
if !seen.insert(key.clone()) {
|
|
duplicates.push(key);
|
|
}
|
|
}
|
|
if !duplicates.is_empty() {
|
|
self.command_line.push_error(
|
|
crate::tf!(
|
|
"Duplicate shortcut(s) ignored on Apply: {}",
|
|
duplicates.join(", ")
|
|
)
|
|
.as_ref(),
|
|
);
|
|
}
|
|
self.apply_shortcut_editor_rows();
|
|
self.command_line.push_info(
|
|
crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(),
|
|
);
|
|
}
|
|
|
|
/// Reset the working rows and live bindings to the shipped defaults.
|
|
pub(super) fn reset_shortcuts_to_defaults(&mut self) {
|
|
let mut rows: Vec<(String, String)> = default_bindings()
|
|
.into_iter()
|
|
.collect();
|
|
rows.sort_by(|a, b| a.0.cmp(&b.0));
|
|
self.shortcut_editor_rows = rows;
|
|
self.shortcut_pending_add = false;
|
|
self.shortcut_capture_row = None;
|
|
self.shortcut_reset_confirm = false;
|
|
self.apply_shortcut_editor_rows();
|
|
}
|
|
|
|
/// True when the working rows differ from the live bindings — the editor
|
|
/// has un-applied changes a close would discard.
|
|
pub(super) fn shortcut_editor_dirty(&self) -> bool {
|
|
let rows: 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();
|
|
rows != self.shortcut_bindings
|
|
}
|
|
|
|
/// A pending add finishes successfully once its draft row (row 0) has
|
|
/// both a key and a command; it then becomes a regular row of the table.
|
|
/// Called after each edit while a draft is pending.
|
|
pub(super) fn finish_pending_add(&mut self) {
|
|
if !self.shortcut_pending_add {
|
|
return;
|
|
}
|
|
if let Some((key, command)) = self.shortcut_editor_rows.first() {
|
|
if !key.trim().is_empty() && !command.trim().is_empty() {
|
|
self.shortcut_pending_add = false;
|
|
self.shortcut_capture_row = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
|
|
"UNDO" => {
|
|
let i = self.active_tab;
|
|
|
|
// Multi-step drawing commands may consume Ctrl+Z themselves.
|
|
// For example PLINE/SPLINE should remove only the last entered
|
|
// point instead of cancelling the whole in-progress command.
|
|
let command_result = self.tabs[i]
|
|
.active_cmd
|
|
.as_mut()
|
|
.and_then(|cmd| cmd.on_undo_step());
|
|
|
|
if let Some(result) = command_result {
|
|
return self.apply_cmd_result(result);
|
|
}
|
|
|
|
Message::Undo
|
|
}
|
|
|
|
"REDO" => Message::Redo,
|
|
|
|
other => Message::Command(other.to_string()),
|
|
};
|
|
|
|
self.update(message)
|
|
}
|
|
}
|