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:
Hakan Seven committed 2026-08-04 15:35:06 +03:00
1 parent 3f6959d7d0
commit 2f86bfde8e
9 files changed
+448 -337

No files matched your search

+24 -9
View File
@@ -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 <key> <command> 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 <key> <command> 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 <key>").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 {
+17
View File
@@ -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<String, String>,
}
impl Default for ShortcutConfig {
fn default() -> Self {
Self {
bindings: super::shortcuts::default_bindings(),
}
}
}
+17 -3
View File
@@ -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<String, String>,
/// Complete editable key → command/action table.
shortcut_bindings: rustc_hash::FxHashMap<String, String>,
/// 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(),
+187
View 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)
}
}
+8
View File
@@ -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
+42 -4
View File
@@ -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 => {
+60 -139
View File
@@ -114,6 +114,47 @@ fn hatch_pattern_key_event(
}
}
fn shortcut_key_name(key: &keyboard::Key, modifiers: keyboard::Modifiers) -> Option<String> {
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,
}
+1 -1
View File
@@ -67,7 +67,7 @@ impl OpenCADStudio {
520,
|flow| {
crate::ui::window::shortcuts::view_window(
&self.shortcut_overrides,
&self.shortcut_editor_rows,
flow,
)
},
+92 -181
View File
@@ -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<Cow<'static, str>>,
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<Cow<'static, str>>) -> 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<String, String>,
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 <key> <cmd> 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 <key> <cmd> 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<Element<'_, Message>> = 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 <key> <command>)"))
.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()
}