forked from 1b-agent/opencadstudio-upstream
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:
1 parent
3f6959d7d0
commit
2f86bfde8e
9 files changed
+448
-337
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ impl OpenCADStudio {
|
||||
520,
|
||||
|flow| {
|
||||
crate::ui::window::shortcuts::view_window(
|
||||
&self.shortcut_overrides,
|
||||
&self.shortcut_editor_rows,
|
||||
flow,
|
||||
)
|
||||
},
|
||||
|
||||
+92
-181
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user