diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index c53300816..13e4e099b 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -261,10 +261,10 @@ impl OpenCADStudio { ); return Some(Task::none()); } - let mut keys: Vec<(&String, &String)> = self.shortcut_overrides.iter().collect(); + let mut keys: Vec<(&String, &String)> = self.shortcut_bindings.iter().collect(); keys.sort_by(|a, b| a.0.cmp(b.0)); let text: String = keys.iter().map(|(k, v)| format!("{k} {v}\n")).collect(); - let count = self.shortcut_overrides.len(); + let count = self.shortcut_bindings.len(); match std::fs::write(path, text) { Ok(()) => self.command_line.push_output(crate::tf!( "CUIEXPORT: wrote {count} shortcut(s) to \"{path}\"." @@ -303,11 +303,15 @@ impl OpenCADStudio { continue; } if let Some((k, v)) = line.split_once(char::is_whitespace) { - self.shortcut_overrides - .insert(k.trim().to_uppercase(), v.trim().to_uppercase()); - n += 1; + let key = crate::app::shortcuts::normalize_key(k); + if !key.is_empty() { + self.shortcut_bindings + .insert(key, v.trim().to_uppercase()); + n += 1; + } } } + self.persist_settings_if_changed(); self.command_line.push_output(crate::tf!( "CUIIMPORT: loaded {n} shortcut(s) from \"{path}\"." ).as_ref()); @@ -335,16 +339,27 @@ impl OpenCADStudio { if key.is_empty() || cmd_str.is_empty() { self.command_line.push_error(crate::t!("Usage: SHORTCUTS SET e.g. SHORTCUTS SET CTRL+D DIST").as_ref()); } else { - self.shortcut_overrides.insert(key.clone(), cmd_str.clone()); - self.command_line - .push_output(crate::tf!("Shortcut set: {key} → {cmd_str}").as_ref()); + let key = crate::app::shortcuts::normalize_key(&key); + if key.is_empty() { + self.command_line.push_error(crate::t!("Usage: SHORTCUTS SET e.g. SHORTCUTS SET CTRL+D DIST").as_ref()); + } else { + self.shortcut_bindings.insert(key.clone(), cmd_str.clone()); + self.persist_settings_if_changed(); + self.command_line + .push_output(crate::tf!("Shortcut set: {key} → {cmd_str}").as_ref()); + } } } "CLEAR" | "DELETE" | "REMOVE" => { let key = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default(); if key.is_empty() { self.command_line.push_error(crate::t!("Usage: SHORTCUTS CLEAR ").as_ref()); - } else if self.shortcut_overrides.remove(&key).is_some() { + } else if self + .shortcut_bindings + .remove(&crate::app::shortcuts::normalize_key(&key)) + .is_some() + { + self.persist_settings_if_changed(); self.command_line .push_output(crate::tf!("Shortcut '{key}' removed.").as_ref()); } else { diff --git a/src/app/config.rs b/src/app/config.rs index 1c764d0bf..7e5791800 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -36,6 +36,8 @@ pub struct AppConfig { /// Print dialog preferences (only the persisted fields; runtime state is /// skipped by `PlotDialogState`'s serde attributes). pub plot: PlotDialogState, + /// Complete editable keyboard shortcut table. + pub shortcuts: ShortcutConfig, } impl Default for AppConfig { @@ -50,6 +52,21 @@ impl Default for AppConfig { annotation_auto_scale: -4, ribbon: RibbonConfig::default(), plot: PlotDialogState::default(), + shortcuts: ShortcutConfig::default(), + } + } +} + +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ShortcutConfig { + pub bindings: std::collections::BTreeMap, +} + +impl Default for ShortcutConfig { + fn default() -> Self { + Self { + bindings: super::shortcuts::default_bindings(), } } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 4b13eb7d2..de2ac839e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -19,6 +19,7 @@ pub mod plugin_host; mod properties; mod recent; mod settings; +mod shortcuts; mod style_ops; mod text_inline; mod update; @@ -790,8 +791,10 @@ pub(super) struct OpenCADStudio { theme_color_inputs: [String; 6], // ── Keyboard Shortcut Editor ────────────────────────────────────────── - /// User-defined function-key overrides: "F3" → command string. - shortcut_overrides: rustc_hash::FxHashMap, + /// Complete editable key → command/action table. + shortcut_bindings: rustc_hash::FxHashMap, + /// Working rows shown by the shortcut editor until Apply is pressed. + shortcut_editor_rows: Vec<(String, String)>, // ── Command Aliases ─────────────────────────────────────────────────── /// Command-line aliases: uppercase abbreviation → uppercase command @@ -2237,6 +2240,16 @@ pub enum Message { ShortcutsPanelOpen, #[allow(dead_code)] ShortcutsPanelClose, + ShortcutEditorInput { + idx: usize, + field: crate::ui::window::shortcuts::ShortcutField, + value: String, + }, + ShortcutEditorAdd, + ShortcutEditorRemove(usize), + ShortcutEditorApply, + /// Canonical key emitted by the global keyboard subscription. + ShortcutPressed(String), // ── Command Alias Editor (ALIASEDIT) ──────────────────────────────── /// Open the command-alias editor modal, seeding rows from the alias table. AliasEditorOpen, @@ -3000,7 +3013,8 @@ impl OpenCADStudio { ui_theme: config::UiThemeConfig::default(), theme_color_inputs: config::UiThemePalette::default().hex_values(), // Keyboard shortcuts - shortcut_overrides: rustc_hash::FxHashMap::default(), + shortcut_bindings: rustc_hash::FxHashMap::default(), + shortcut_editor_rows: Vec::new(), // Command aliases (populated from ocad.pgp just after construction) command_aliases: rustc_hash::FxHashMap::default(), alias_editor_rows: Vec::new(), diff --git a/src/app/shortcuts.rs b/src/app/shortcuts.rs new file mode 100644 index 000000000..a5c9e8861 --- /dev/null +++ b/src/app/shortcuts.rs @@ -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 { + [ + ("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 = 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) + } +} diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c386d96d2..94266b630 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -530,6 +530,13 @@ impl OpenCADStudio { collapse: self.ribbon.collapse_mode(), }, plot: self.plot_dialog.clone(), + shortcuts: crate::app::config::ShortcutConfig { + bindings: self + .shortcut_bindings + .iter() + .map(|(key, command)| (key.clone(), command.clone())) + .collect(), + }, } } @@ -565,6 +572,7 @@ impl OpenCADStudio { self.annotation_auto_scale = cfg.annotation_auto_scale.clamp(-4, 4); self.ribbon.set_collapse_mode(cfg.ribbon.collapse); self.plot_dialog = cfg.plot; + self.shortcut_bindings = cfg.shortcuts.bindings.into_iter().collect(); } /// Write the config only when it changed since the last write, so a toggle diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9f617b668..364d9d82a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -29,6 +29,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { | Message::TogglePolar | Message::ToggleOTrack | Message::ToggleDynInput + | Message::ShortcutPressed(_) | Message::TabNew | Message::OpenFile | Message::SaveFile @@ -171,9 +172,8 @@ impl OpenCADStudio { } // Closing (✕) discards edits made since the last Apply — matching the // style editors. Committing happens only through the Apply button. - Some(Aliases) => { - self.alias_editor_rows.clear(); - } + Some(Aliases) => self.alias_editor_rows.clear(), + Some(Shortcuts) => self.shortcut_editor_rows.clear(), Some(LayerStateEditor) => { self.layer_state_edit_draft = None; self.layer_state_edit_filter.clear(); @@ -206,7 +206,9 @@ impl OpenCADStudio { // the modal's own text fields keep working because they emit their own // (non-blocked) messages. (#126) if self.active_modal.is_some() { - if matches!(msg, Message::CommandEscape) { + if matches!(msg, Message::CommandEscape) + || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ESCAPE")) + { return self.update(Message::CloseModal); } if is_modal_blocked_key_msg(&msg) { @@ -4475,6 +4477,13 @@ impl OpenCADStudio { // ── Keyboard Shortcuts Panel ────────────────────────────────────── Message::ShortcutsPanelOpen => { + let mut rows: Vec<(String, String)> = self + .shortcut_bindings + .iter() + .map(|(key, command)| (key.clone(), command.clone())) + .collect(); + rows.sort_by(|a, b| a.0.cmp(&b.0)); + self.shortcut_editor_rows = rows; self.active_modal = Some(super::ModalKind::Shortcuts); Task::none() } @@ -4482,6 +4491,35 @@ impl OpenCADStudio { self.close_active_modal(); Task::none() } + Message::ShortcutEditorInput { idx, field, value } => { + use crate::ui::window::shortcuts::ShortcutField; + if let Some(row) = self.shortcut_editor_rows.get_mut(idx) { + match field { + ShortcutField::Key => row.0 = value.to_uppercase(), + ShortcutField::Command => row.1 = value.to_uppercase(), + } + } + Task::none() + } + Message::ShortcutEditorAdd => { + self.shortcut_editor_rows + .push((String::new(), String::new())); + Task::none() + } + Message::ShortcutEditorRemove(idx) => { + if idx < self.shortcut_editor_rows.len() { + self.shortcut_editor_rows.remove(idx); + } + Task::none() + } + Message::ShortcutEditorApply => { + self.apply_shortcut_editor_rows(); + self.command_line.push_info( + crate::tf!("{} shortcut(s) applied.", self.shortcut_bindings.len()).as_ref(), + ); + Task::none() + } + Message::ShortcutPressed(key) => self.run_shortcut(&key), // ── Command Alias Editor (ALIASEDIT) ────────────────────────────── Message::AliasEditorOpen => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 6b0858072..596a8b2f2 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -114,6 +114,47 @@ fn hatch_pattern_key_event( } } +fn shortcut_key_name(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option { + let key = match key { + keyboard::Key::Character(value) if !value.is_empty() => value.to_uppercase(), + keyboard::Key::Named(named) => { + let name = format!("{named:?}"); + match name.as_str() { + "ArrowUp" => "UP".to_string(), + "ArrowDown" => "DOWN".to_string(), + "ArrowLeft" => "LEFT".to_string(), + "ArrowRight" => "RIGHT".to_string(), + "PageUp" => "PAGEUP".to_string(), + "PageDown" => "PAGEDOWN".to_string(), + "Enter" | "Space" | "Escape" | "Delete" | "Backspace" | "Tab" | "Home" + | "End" | "Insert" => name.to_uppercase(), + _ if name.starts_with('F') + && name[1..].chars().all(|ch| ch.is_ascii_digit()) => + { + name + } + _ => return None, + } + } + _ => return None, + }; + let mut parts = Vec::with_capacity(5); + if modifiers.control() { + parts.push("CTRL".to_string()); + } + if modifiers.logo() { + parts.push("CMD".to_string()); + } + if modifiers.alt() { + parts.push("ALT".to_string()); + } + if modifiers.shift() { + parts.push("SHIFT".to_string()); + } + parts.push(key); + Some(parts.join("+")) +} + /// `ViewportRenderMode` enum carries the raw DXF integers, not a label, /// so wrap it locally with a friendly name renderer. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -1842,12 +1883,10 @@ impl OpenCADStudio { text, .. }) => { - // Platform-aware accelerator modifier: Cmd on macOS, - // Ctrl on Windows/Linux. Using `command()` rather than - // `control()` makes Cmd+C/V/S/Z etc. work on Mac, per - // the platform's keyboard conventions. + #[cfg(target_arch = "wasm32")] let accel = modifiers.command(); - let shift = modifiers.shift(); + let shortcut_modifier = + modifiers.control() || modifiers.alt() || modifiers.logo(); // Any key that produces a printable glyph types it, // even when its logical key resolves to navigation // (NumLock-on Numpad8 / Numpad2 arrive as @@ -1860,7 +1899,7 @@ impl OpenCADStudio { // (a comma on German/European layouts), which the // coordinate parser rejects. Force it to a decimal point // from the physical key, independent of layout. - if !accel + if !shortcut_modifier && status == Status::Ignored && matches!( physical_key, @@ -1872,7 +1911,7 @@ impl OpenCADStudio { { return Some(Message::CommandAppendChar(".".to_string())); } - if !accel && status == Status::Ignored { + if !shortcut_modifier && status == Status::Ignored { if let Some(t) = text.as_deref() { if !t.is_empty() && t.chars().all(|c| !c.is_control() && !c.is_whitespace()) @@ -1881,141 +1920,23 @@ impl OpenCADStudio { } } } - match key { - // Space is a literal space inside the MText preview - // but finalises a command otherwise; the handler - // decides based on editor state. - keyboard::Key::Named(keyboard::key::Named::Space) - if status == Status::Ignored => - { - Some(Message::CommandSpace) - } - keyboard::Key::Named(keyboard::key::Named::Enter) - if status == Status::Ignored => - { - Some(Message::CommandFinalize) - } - keyboard::Key::Named(keyboard::key::Named::Escape) => { - Some(Message::CommandEscape) - } - keyboard::Key::Named(keyboard::key::Named::Delete) - if status == Status::Ignored => - { - Some(Message::DeleteSelected) - } - keyboard::Key::Named(keyboard::key::Named::Backspace) - if status == Status::Ignored => - { - Some(Message::CommandBackspace) - } - keyboard::Key::Named(keyboard::key::Named::Tab) - if status == Status::Ignored => - { - Some(Message::DynTabNext) - } - keyboard::Key::Named(keyboard::key::Named::ArrowUp) - if status == Status::Ignored => - { - Some(Message::CommandHistoryPrev) - } - keyboard::Key::Named(keyboard::key::Named::ArrowDown) - if status == Status::Ignored => - { - Some(Message::CommandHistoryNext) - } - // Caret movement in the MText preview (no-op - // otherwise; these arrows are unused elsewhere). - keyboard::Key::Named(keyboard::key::Named::ArrowLeft) - if status == Status::Ignored => - { - Some(Message::MTextCaretMove(-1)) - } - keyboard::Key::Named(keyboard::key::Named::ArrowRight) - if status == Status::Ignored => - { - Some(Message::MTextCaretMove(1)) - } - keyboard::Key::Named(keyboard::key::Named::F1) => { - Some(Message::Command("HELP".to_string())) - } - keyboard::Key::Named(keyboard::key::Named::F2) => { - Some(Message::CommandHistoryToggle) - } - keyboard::Key::Named(keyboard::key::Named::F3) => { - Some(Message::ToggleSnapEnabled) - } - keyboard::Key::Named(keyboard::key::Named::F7) => { - Some(Message::ToggleGrid) - } - keyboard::Key::Named(keyboard::key::Named::F8) => { - Some(Message::ToggleOrtho) - } - keyboard::Key::Named(keyboard::key::Named::F9) => { - Some(Message::ToggleGridSnap) - } - keyboard::Key::Named(keyboard::key::Named::F10) => { - Some(Message::TogglePolar) - } - keyboard::Key::Named(keyboard::key::Named::F11) => { - Some(Message::ToggleOTrack) - } - keyboard::Key::Named(keyboard::key::Named::F12) => { - Some(Message::ToggleDynInput) - } - keyboard::Key::Character(c) if accel => match c.as_str() { - "0" => Some(Message::ToggleCleanScreen), - "1" => Some(Message::ToggleProperties), - "n" => Some(Message::TabNew), - "o" => Some(Message::OpenFile), - "p" => Some(Message::Command("PLOT".to_string())), - "q" => Some(Message::Command("QUIT".to_string())), - "s" if !shift => Some(Message::SaveFile), - "s" if shift => Some(Message::SaveAs), - "z" if !shift => Some(Message::Undo), - "z" if shift => Some(Message::Redo), - "y" => Some(Message::Redo), - "f" | "h" => Some(Message::FindReplaceOpen), - // Ctrl/Cmd+A: select all layer rows when the - // Layer Manager is open, else all objects. The - // update handler branches on the active modal. - "a" if status == Status::Ignored => { - Some(Message::SelectAllShortcut) + // A focused web text field needs the browser clipboard; + // drawing shortcuts only run for ignored C/V events. + #[cfg(target_arch = "wasm32")] + if accel && status == Status::Captured { + if let keyboard::Key::Character(value) = &key { + if value.eq_ignore_ascii_case("v") { + return Some(Message::WebFieldPaste); } - // Clipboard accelerators defer to a focused - // text widget: when the command-line history - // editor (or any text field) captures Ctrl+C/ - // X/V it copies/cuts/pastes its own text and - // marks the event Captured, so we must NOT also - // fire the drawing's COPYCLIP/CUTCLIP/paste. - // Only when nothing captured (the drawing has - // focus, status Ignored) do these run. (#232) - "c" if shift && status == Status::Ignored => { - Some(Message::Command("COPYBASE".to_string())) + if value.eq_ignore_ascii_case("c") { + return Some(Message::WebFieldCopy); } - "c" if status == Status::Ignored => { - Some(Message::Command("COPYCLIP".to_string())) - } - "x" if status == Status::Ignored => { - Some(Message::Command("CUTCLIP".to_string())) - } - "v" if shift && status == Status::Ignored => { - Some(Message::Command("PASTEBLOCK".to_string())) - } - "v" if status == Status::Ignored => Some(Message::PasteShortcut), - // Web: a focused text field captured Ctrl+C/V, - // but iced's clipboard is a no-op there — route - // through the async browser clipboard (#346). - #[cfg(target_arch = "wasm32")] - "v" => Some(Message::WebFieldPaste), - #[cfg(target_arch = "wasm32")] - "c" => Some(Message::WebFieldCopy), - _ => None, - }, - // Printable glyphs are already handled by the - // text guard above the match; anything reaching - // here is a non-typing key we don't bind. - _ => None, + } } + let shortcut = shortcut_key_name(&key, modifiers)?; + (status == Status::Ignored + || crate::app::shortcuts::is_global_key(&shortcut)) + .then_some(Message::ShortcutPressed(shortcut)) } _ => None, } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 15565caca..aa99d37e0 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -67,7 +67,7 @@ impl OpenCADStudio { 520, |flow| { crate::ui::window::shortcuts::view_window( - &self.shortcut_overrides, + &self.shortcut_editor_rows, flow, ) }, diff --git a/src/ui/window/shortcuts.rs b/src/ui/window/shortcuts.rs index 5ca095df0..13e2735de 100644 --- a/src/ui/window/shortcuts.rs +++ b/src/ui/window/shortcuts.rs @@ -1,19 +1,17 @@ -//! Keyboard Shortcuts Reference window — fills the entire OS window. +//! Editable keyboard shortcut table opened by CUI / SHORTCUTS. use crate::app::Message; -use iced::widget::{column, container, row, scrollable, text, Space}; -use iced::{Background, Element, Theme}; use crate::t; -use std::borrow::Cow; +use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; +use iced::{Background, Element, Length, Theme}; -/// Display name of the primary accelerator modifier on this platform. -/// Mirrors the runtime binding in `app::view`, which uses -/// `Modifiers::command()` — the Cmd key on macOS, Ctrl elsewhere — so the -/// reference window shows the key the user actually presses. -#[cfg(target_os = "macos")] -const MOD: &str = "Cmd"; -#[cfg(not(target_os = "macos"))] -const MOD: &str = "Ctrl"; +#[derive(Clone, Copy, Debug)] +pub enum ShortcutField { + Key, + Command, +} + +const GUTTER: f32 = 16.0; fn muted_style(theme: &Theme) -> iced::widget::text::Style { iced::widget::text::Style { @@ -21,186 +19,99 @@ fn muted_style(theme: &Theme) -> iced::widget::text::Style { } } -fn primary_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().primary.base.color), - } -} - -fn hdivider<'a>(width: iced::Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color, - )), - ..Default::default() - }) - .into() -} - -fn shortcut_row<'a>( - key: impl Into>, - action: Cow<'static, str>, -) -> Element<'a, Message> { - row![ - text(key.into()) - .size(11) - .style(primary_style) - .font(iced::Font::MONOSPACE) - .width(160), - text(action).size(11), - ] - .spacing(8) - .align_y(iced::Center) - .padding([2, 0]) - .into() -} - -fn section<'a>(title: impl Into>) -> Element<'a, Message> { - container(text(title.into()).size(11).style(muted_style)) - .padding(iced::Padding { - top: 6.0, - right: 0.0, - bottom: 2.0, - left: 0.0, - }) - .into() -} - -pub fn view_window<'a>( - overrides: &'a rustc_hash::FxHashMap, +pub fn view_window( + rows: &[(String, String)], sizing: crate::ui::modal::ModalSizing, -) -> Element<'a, Message> { - // ── Toolbar ─────────────────────────────────────────────────────────── - let toolbar = container( +) -> Element<'_, Message> { + let title = text(t!("Keyboard Shortcuts")).size(15); + let hint = text(t!( + "Type SHORTCUTS SET to add custom shortcuts." + )) + .size(11) + .style(muted_style); + let gutter = iced::Padding { + top: 0.0, + right: GUTTER, + bottom: 0.0, + left: 0.0, + }; + + let head = container( row![ - text(t!("Type SHORTCUTS SET to add custom shortcuts.")) - .size(10) - .style(muted_style), + container(text(t!("Key")).size(11).style(muted_style)) + .width(Length::Fixed(180.0)), + container(text(t!("Command")).size(11).style(muted_style)).width(sizing.width), + Space::new().width(Length::Fixed(30.0)), ] - .align_y(iced::Center), + .spacing(8), ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.weakest.color, - )), - ..Default::default() - }) - .width(sizing.width) - .padding([5, 10]); + .padding(gutter); - // ── Shortcut entries ────────────────────────────────────────────────── - let mut rows: Vec> = vec![ - section(t!("── Function Keys ──────────────────────────────────────")), - shortcut_row("F1", t!("Help")), - shortcut_row("F2", t!("Toggle Command History")), - shortcut_row("F3", t!("Toggle Object Snap")), - shortcut_row("F7", t!("Toggle Grid")), - shortcut_row("F8", t!("Toggle Ortho")), - shortcut_row("F9", t!("Toggle Grid Snap")), - shortcut_row("F10", t!("Toggle Polar Tracking")), - shortcut_row("F11", t!("Toggle Object Snap Tracking")), - shortcut_row("F12", t!("Toggle Dynamic Input")), - section(t!( - "── %{MOD} Shortcuts ──────────────────────────────────────", - MOD = MOD - )), - shortcut_row(format!("{MOD}+0"), t!("Clean Screen")), - shortcut_row(format!("{MOD}+1"), t!("Properties")), - shortcut_row(format!("{MOD}+N"), t!("New Drawing")), - shortcut_row(format!("{MOD}+O"), t!("Open File")), - shortcut_row(format!("{MOD}+P"), t!("Plot")), - shortcut_row(format!("{MOD}+Q"), t!("Close")), - shortcut_row(format!("{MOD}+S"), t!("Save")), - shortcut_row(format!("{MOD}+Shift+S"), t!("Save As")), - shortcut_row(format!("{MOD}+Z"), t!("Undo")), - shortcut_row(format!("{MOD}+Shift+Z / {MOD}+Y"), t!("Redo")), - shortcut_row(format!("{MOD}+F / {MOD}+H"), t!("Find and Replace")), - shortcut_row(format!("{MOD}+C"), t!("Copy to Clipboard")), - shortcut_row(format!("{MOD}+Shift+C"), t!("Copy with Base Point")), - shortcut_row(format!("{MOD}+X"), t!("Cut to Clipboard")), - shortcut_row(format!("{MOD}+V"), t!("Paste from Clipboard")), - shortcut_row(format!("{MOD}+Shift+V"), t!("Paste as Block")), - section(t!("── Other Keys ──────────────────────────────────────────")), - shortcut_row("Enter / Space", t!("Finalize command / Repeat last")), - shortcut_row("Escape", t!("Cancel active command")), - shortcut_row("Delete", t!("Delete selected entities")), - shortcut_row("↑ / ↓", t!("Command history navigation")), - ]; - - // Custom overrides section - rows.push(section( - t!("── Custom Overrides (SHORTCUTS SET) ──────────────────"), - )); - if overrides.is_empty() { - rows.push( - text(t!(" (none — use: SHORTCUTS SET )")) - .size(11) - .style(muted_style) - .into(), - ); - } else { - let mut sorted: Vec<_> = overrides.iter().collect(); - sorted.sort_by_key(|(k, _)| k.as_str()); - for (key, cmd) in sorted { - rows.push( - row![ - text(key.as_str()) - .size(11) - .style(primary_style) - .font(iced::Font::MONOSPACE) - .width(160), - text(cmd.as_str()).size(11), - ] + let mut list = column![].spacing(3); + for (idx, (key, command)) in rows.iter().enumerate() { + let key_box = text_input("Ctrl+Key", key) + .on_input(move |value| Message::ShortcutEditorInput { + idx, + field: ShortcutField::Key, + value, + }) + .size(13) + .padding([3, 6]) + .width(Length::Fixed(180.0)); + let command_box = text_input(t!("command").as_ref(), command) + .on_input(move |value| Message::ShortcutEditorInput { + idx, + field: ShortcutField::Command, + value, + }) + .size(13) + .padding([3, 6]) + .width(sizing.width); + let remove = button(crate::ui::icons::themed_danger_text( + crate::ui::icons::CLOSE, + 12.0, + )) + .on_press(Message::ShortcutEditorRemove(idx)) + .padding([2, 6]) + .style(button::danger); + list = list.push( + row![key_box, command_box, remove] .spacing(8) - .align_y(iced::Center) - .padding([2, 0]) - .into(), - ); - } + .align_y(iced::Center), + ); } - // ── Section headers styled separately ──────────────────────────────── - let content = scrollable(column(rows).spacing(3).padding([12, 16])) - .width(sizing.width) - .height(sizing.height); - - // ── Header row with accent ──────────────────────────────────────────── - let header = container( - row![ - text(t!("Key")).size(10).style(primary_style).width(160), - text(t!("Action")).size(10).style(primary_style), - ] - .spacing(8) - .padding([4, 16]), - ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().primary.weak.color, - )), - ..Default::default() - }) - .width(sizing.width); + let add = button(text(format!("+ {}", t!("Add"))).size(12)) + .on_press(Message::ShortcutEditorAdd) + .padding([4, 10]) + .style(button::secondary); + let apply = button(text(t!("Apply")).size(12)) + .on_press(Message::ShortcutEditorApply) + .padding([4, 16]) + .style(button::primary); container( column![ - toolbar, - hdivider(sizing.width), - header, - hdivider(sizing.width), - content + title, + hint, + Space::new().height(6), + head, + scrollable(container(list).padding(gutter)).height(sizing.height), + Space::new().height(6), + row![add, Space::new().width(sizing.width), apply].align_y(iced::Center), ] - .spacing(0), - ) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.base.color, - )), - ..Default::default() - }) + .spacing(6) .width(sizing.width) - .height(sizing.height) - .into() + .height(sizing.height), + ) + .padding(12) + .width(sizing.width) + .height(sizing.height) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.base.color, + )), + ..Default::default() + }) + .into() }