Browse / github.com/storytold/cadcraft / patch
Keyboard Shortcuts dialog: any command gets a shortcut, and the user's wins
Keyboard Shortcuts dialog: any command gets a shortcut, and the user's wins Depends on #2057 (its first commit is #2057's: the shortcut table as `builtin_shortcuts()` and the test that every shortcut a menu shows runs its command). Part of U12 / E5 (gaps.md). The recording and conflict part of @Alex-BB44's storey shortcuts (#1703), made general as agreed in https://github.com/storytold/cadcraft/pull/1703#issuecomment-6386319647. Alex is co-author; #1703 can then register its storey commands here instead of keeping its own table. ## Problem Shortcuts are fixed: a user can't give a command a key, nor change one CADCraft uses. Drafters coming from AutoCAD keep their o
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ATARYS-LAB (imported from pull request #2134, open)
- Base
e86d895ba1086a7efc2a83048348f89098282d08- Size
- +768 −15 in 9 files
- Review
- pending
- Shared
- 2026-10-28
- Id
e4aae9d53409f76e3b446f26
How it went
No other operator has reported using it yet.
Diff
raw · crates/ui-egui/src/dialogs.rs crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/tests.rs crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/keys.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs docs/gaps.md docs/ui-parity.md
@@ -23,6 +23,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "units" => units(app, ctx, &mut open), "hatch" => crate::hatchdlg::dialog(app, ctx, &mut open), "aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open),+ crate::keys::DIALOG => crate::keys::dialog(app, ctx, &mut open), "about" => about(ctx, &mut open), "preview" => crate::exchange::dialog(app, ctx, &mut open), "commands" => commands(app, ctx, &mut open),@@ -1012,3 +1012,10 @@ Not a number or distance: {text} Ni un nombre ni une distance : {text} Query Texts Interroger les textes Analyze Shapes Analyser les formes+ Keyboard Shortcuts... Raccourcis clavier...+ Keyboard Shortcuts Raccourcis clavier+ Replaces the shortcut of {commands} Remplace le raccourci de : {commands}+ Needs Ctrl, Alt or a function key Il faut Ctrl, Alt ou une touche de fonction+ Press the keys Appuyez sur les touches+ Back to CADCraft's shortcut Revenir au raccourci de CADCraft+ A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time. Un raccourci que vous définissez l'emporte sur celui de CADCraft. Les modifications s'appliquent tout de suite et sont conservées.@@ -94,6 +94,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> { include_str!("../dialogs.rs"), include_str!("../hatchdlg.rs"), include_str!("../aliasdlg.rs"),+ include_str!("../keys.rs"), include_str!("../mtext_editor.rs"), include_str!("../layers.rs"), include_str!("../canvas/compare.rs"),@@ -1368,3 +1368,10 @@ Discard Скасувати зміни Customize Налаштування інтерфейсу Edit Command Aliases (PGP)... Редагувати псевдоніми команд (PGP)...+ Keyboard Shortcuts... Комбінації клавіш...+ Keyboard Shortcuts Комбінації клавіш+ Replaces the shortcut of {commands} Замінює комбінацію клавіш команди: {commands}+ Needs Ctrl, Alt or a function key Потрібна клавіша Ctrl, Alt або функціональна клавіша+ Press the keys Натисніть клавіші+ Back to CADCraft's shortcut Повернути комбінацію CADCraft+ A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time. Ваша комбінація клавіш має перевагу над комбінацією CADCraft. Зміни застосовуються одразу й зберігаються.new file mode 100644@@ -0,0 +1,662 @@+//! The user's keyboard shortcuts (Tools ▸ Customize ▸ Keyboard Shortcuts...): any command can get+//! one, and a shortcut the user gives wins over CADCraft's own; the command that had it loses it,+//! and the dialog says so. They are kept with the other preferences (`shortcuts`).+//!+//! Recording a shortcut (click, press the keys, Esc to cancel) and the conflict rules come from+//! the storey shortcuts of #1703 (@Alex-BB44).++use std::collections::BTreeMap;++use egui::{Key, KeyboardShortcut, Modifiers, RichText, vec2};+use serde_json::{Value, json};++use crate::CadApp;+use crate::theme::Tokens;++/// The user's shortcuts: command id → registry shortcut ("Cmd+Shift+K"). Empty: the command has+/// none (its built-in one is removed).+pub type UserKeys = BTreeMap<String, String>;++/// At most this many user shortcuts are kept; longer shortcut strings are refused (preferences+/// and JSON calls are hostile input).+pub const MAX_KEYS: usize = 1000;+const MAX_LEN: usize = 64;++/// Commands whose built-in key is handled outside the shortcut table and stays as it is: the+/// clipboard keys, Shift ones included, arrive as clipboard events, Delete belongs to the command+/// line, Cmd+Q to the macOS app menu.+const FIXED: &[&str] = &["copyclip", "cutclip", "pasteclip", "copybase", "pasteblock", "erase.selection", "ui.quit"];++/// The dialog name in `UiState::dialog`.+pub const DIALOG: &str = "shortcuts";++fn same(a: &KeyboardShortcut, b: &KeyboardShortcut) -> bool {+ a.logical_key == b.logical_key && a.modifiers == b.modifiers+}++/// A registry shortcut string ("Cmd+Shift+G", "Alt+4", "F3") as a shortcut; `None` when empty or+/// not understood. Cmd is the command key (Ctrl on Windows and Linux); Ctrl alone is the Control+/// key of macOS, and Cmd+Ctrl both macOS keys.+pub fn parse(s: &str) -> Option<KeyboardShortcut> {+ let mut m = Modifiers::NONE;+ let mut key = None;+ for part in s.split('+').map(str::trim).filter(|p| !p.is_empty()) {+ match part.to_ascii_lowercase().as_str() {+ "cmd" | "command" => m |= Modifiers::COMMAND,+ // Control on macOS (on Windows and Linux, Ctrl is written Cmd: it is the command key).+ "ctrl" => m |= Modifiers::CTRL,+ "shift" => m |= Modifiers::SHIFT,+ "alt" | "option" => m |= Modifiers::ALT,+ _ if key.is_none() => key = Some(Key::from_name(part)?),+ _ => return None,+ }+ }+ if m.command && m.ctrl {+ m.mac_cmd = true;+ }+ key.map(|k| KeyboardShortcut::new(m, k))+}++/// A shortcut as a registry string ("Cmd+Shift+G"), the same on every platform: Cmd for the+/// command key, Ctrl for a Control that isn't it (macOS), so [`parse`] gives it back.+pub fn format(m: Modifiers, k: Key) -> String {+ let mut s = String::new();+ if m.command || m.mac_cmd {+ s.push_str("Cmd+");+ }+ if m.ctrl && (m.mac_cmd || !m.command) {+ s.push_str("Ctrl+");+ }+ if m.shift {+ s.push_str("Shift+");+ }+ if m.alt {+ s.push_str("Alt+");+ }+ // "+" and "-" would read as separators.+ s.push_str(match k {+ Key::Plus => "Plus",+ Key::Minus => "Minus",+ k => k.name(),+ });+ s+}++/// Whether `s` can be a user shortcut: with Cmd, Ctrl or Alt, or a function or paging key alone.+/// The keys the drawing area and the command line live on (letters as typing, Space, Enter, Tab,+/// Esc, Delete, Backspace, the arrows), the clipboard keys and Cmd+Q never are.+pub fn allowed(s: &KeyboardShortcut) -> bool {+ let k = s.logical_key;+ let never =+ [Key::Space, Key::Enter, Key::Tab, Key::Escape, Key::Delete, Key::Backspace, Key::ArrowUp, Key::ArrowDown, Key::ArrowLeft, Key::ArrowRight];+ if never.contains(&k) {+ return false;+ }+ let m = s.modifiers;+ let command = m.command || m.ctrl || m.mac_cmd;+ // Cmd+C, X and V arrive as clipboard events whatever else is held (Shift+Cmd+C is Copy with+ // Base Point, Shift+Cmd+V Paste as Block).+ let reserved = command && ([Key::C, Key::X, Key::V].contains(&k) || k == Key::Q && !m.shift && !m.alt);+ let alone_ok = matches!(k, Key::PageUp | Key::PageDown | Key::Home | Key::End) || k.name().starts_with('F') && k.name().len() > 1;+ !reserved && (command || m.alt || alone_ok)+}++/// Shortcuts are tried most specific first (egui lets a shortcut match with Shift or Alt held on+/// top), so Cmd+Shift+G reaches its own command before Cmd+G does.+pub fn by_specificity<T>(pairs: &mut [(KeyboardShortcut, T)]) {+ let weight = |s: &KeyboardShortcut| {+ let m = s.modifiers;+ u8::from(m.shift) + u8::from(m.alt) + u8::from(m.ctrl && (m.mac_cmd || !m.command)) + u8::from(m.command || m.mac_cmd)+ };+ pairs.sort_by_key(|(s, _)| std::cmp::Reverse(weight(s)));+}++/// CADCraft's own shortcuts: the shortcut table, plus Cmd+8 (QuickCalc), which the shortcut+/// handler reads before a focused text field can take it.+fn defaults() -> Vec<(KeyboardShortcut, &'static str)> {+ let mut v = crate::menus::builtin_shortcuts();+ v.push((KeyboardShortcut::new(Modifiers::COMMAND, Key::Num8), "ui.toggle.quickcalc"));+ v+}++/// The command a built-in shortcut belongs to in the editor: the table runs some commands' UI+/// form (`ui.open` for OPEN), which is the same command for the user.+fn owner(id: &str) -> &str {+ id.strip_prefix("ui.").filter(|c| cadcraft_engine::find_command(c).is_some()).unwrap_or(id)+}++fn known(id: &str) -> bool {+ cadcraft_engine::find_command(id).is_some() || crate::menus::is_ui_command(id)+}++/// The shortcuts in force: the user's first, then CADCraft's own unless the user gave that+/// command another shortcut or gave the key to another command. Most specific first.+pub fn effective(user: &UserKeys) -> Vec<(KeyboardShortcut, String)> {+ let mut out: Vec<(KeyboardShortcut, String)> = Vec::new();+ for (id, key) in user {+ if let Some(s) = parse(key).filter(allowed)+ && !out.iter().any(|(o, _)| same(o, &s))+ {+ out.push((s, id.clone()));+ }+ }+ for (s, id) in defaults() {+ if !user.contains_key(owner(id)) && !out.iter().any(|(o, _)| same(o, &s)) {+ out.push((s, id.to_string()));+ }+ }+ by_specificity(&mut out);+ out+}++/// [`effective`], worked out again only when the user's shortcuts change (the shortcut handler+/// asks every frame).+pub fn effective_cached(ctx: &egui::Context, user: &UserKeys) -> Vec<(KeyboardShortcut, String)> {+ let id = egui::Id::new("user_keys_effective");+ if let Some((k, v)) = ctx.data(|m| m.get_temp::<(UserKeys, Vec<(KeyboardShortcut, String)>)>(id))+ && k == *user+ {+ return v;+ }+ let v = effective(user);+ ctx.data_mut(|m| m.insert_temp(id, (user.clone(), v.clone())));+ v+}++/// The shortcut a menu or the editor shows for `id`, whose built-in one is `default`: the user's,+/// or the built-in one unless the user gave that key to another command.+pub fn shown(user: &UserKeys, id: &str, default: Option<&str>) -> Option<String> {+ if let Some(k) = user.get(id) {+ return (!k.is_empty()).then(|| k.clone());+ }+ let d = default?;+ let s = parse(d);+ let taken = s.is_some_and(|s| user.iter().any(|(o, k)| o != id && parse(k).is_some_and(|u| same(&u, &s))));+ (!taken).then(|| d.to_string())+}++/// The built-in shortcut a command shows (its registry `key`, or a UI command's).+pub fn default_key(id: &str) -> Option<&'static str> {+ cadcraft_engine::find_command(id).and_then(|c| c.shortcut).or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3))+}++/// Give `id` the shortcut `key` ("" removes its shortcut). The commands that had the key, built+/// in or the user's, lose it; they are returned so the caller can say so.+pub fn assign(user: &mut UserKeys, id: &str, key: &str) -> Result<Vec<String>, String> {+ if !known(id) {+ return Err(format!("{id}: unknown command"));+ }+ if FIXED.contains(&id) {+ return Err(format!("{id}: this shortcut can't be changed"));+ }+ let key = key.trim();+ if key.len() > MAX_LEN {+ return Err("shortcut too long".into());+ }+ if user.len() >= MAX_KEYS && !user.contains_key(id) {+ return Err(format!("more than {MAX_KEYS} shortcuts"));+ }+ if key.is_empty() {+ user.insert(id.to_string(), String::new());+ return Ok(Vec::new());+ }+ let s = parse(key).ok_or_else(|| format!("{key}: not a shortcut"))?;+ if !allowed(&s) {+ return Err(format!("{key}: needs Ctrl, Alt or a function key"));+ }+ let mut replaced = Vec::new();+ for (other, k) in user.iter_mut() {+ if other != id && parse(k).is_some_and(|o| same(&o, &s)) {+ k.clear();+ replaced.push(other.clone());+ }+ }+ // A built-in owner already lost the key to the user command that had it.+ let held = !replaced.is_empty();+ for (o, other) in defaults() {+ let other = owner(other);+ if !held && other != id && same(&o, &s) && !user.contains_key(other) && !replaced.iter().any(|r| r == other) {+ replaced.push(other.to_string());+ }+ }+ user.insert(id.to_string(), format(s.modifiers, s.logical_key));+ Ok(replaced)+}++/// The user's shortcuts from saved preferences: unknown commands, bad strings, a shortcut an+/// earlier command already has (it would show on both, run one) and anything past [`MAX_KEYS`]+/// are dropped.+pub fn from_prefs(v: &Value) -> UserKeys {+ let Some(m) = v.as_object() else { return UserKeys::new() };+ let mut out = UserKeys::new();+ let mut taken: Vec<KeyboardShortcut> = Vec::new();+ for (id, k) in m.iter().filter_map(|(id, k)| Some((id, k.as_str()?))) {+ if out.len() >= MAX_KEYS {+ break;+ }+ if id.len() > MAX_LEN || k.len() > MAX_LEN || !known(id) || FIXED.contains(&id.as_str()) {+ continue;+ }+ if !k.is_empty() {+ let Some(s) = parse(k).filter(allowed) else { continue };+ if taken.iter().any(|o| same(o, &s)) {+ continue;+ }+ taken.push(s);+ }+ out.insert(id.clone(), k.to_string());+ }+ out+}++/// `ui.shortcuts`: from a menu or typed, the Keyboard Shortcuts dialog; with parameters+/// `{set?: {command: shortcut or ""}, reset?: bool}` the JSON form, which answers the user's+/// shortcuts and the commands that lost theirs.+pub fn command(app: &mut CadApp, params: &Value) -> Result<Value, String> {+ if params.is_null() {+ app.ui.dialog = Some(DIALOG.into());+ return Ok(Value::Null);+ }+ let mut keys = app.ui.shortcuts.clone();+ if params.get("reset").and_then(Value::as_bool) == Some(true) {+ keys.clear();+ }+ let mut replaced = Vec::new();+ if let Some(set) = params.get("set") {+ let set = set.as_object().ok_or("`set` must be an object of command: shortcut")?;+ if set.len() > MAX_KEYS {+ return Err(format!("more than {MAX_KEYS} shortcuts"));+ }+ for (id, key) in set {+ let key = key.as_str().ok_or_else(|| format!("{id}: the shortcut must be a string"))?;+ replaced.extend(assign(&mut keys, &id.to_ascii_lowercase(), key)?);+ }+ }+ app.ui.shortcuts = keys;+ Ok(json!({ "shortcuts": app.ui.shortcuts, "replaced": replaced }))+}++const RECORDING: &str = "user_keys_recording";++/// Whether a shortcut is being recorded (every shortcut stays quiet meanwhile).+pub fn recording(ctx: &egui::Context) -> bool {+ ctx.data(|m| m.get_temp::<String>(egui::Id::new(RECORDING))).is_some()+}++pub fn stop_recording(ctx: &egui::Context) {+ ctx.data_mut(|m| m.remove::<String>(egui::Id::new(RECORDING)));+}++/// The commands the editor lists, sorted by the label shown: (id, label, what tells same labels+/// apart: the command name to type, or the menu an interface command sits in).+fn rows() -> Vec<(&'static str, String, String)> {+ let mut v: Vec<(&'static str, String, String)> = Vec::new();+ for c in cadcraft_engine::command_specs() {+ if !c.label.is_empty() && !v.iter().any(|(id, ..)| *id == c.id) {+ v.push((c.id, crate::i18n::t(c.label).to_string(), c.id.to_ascii_uppercase()));+ }+ }+ for (id, label, menu, _) in crate::menus::UI_COMMANDS {+ if !label.is_empty() && !menu.is_empty() && owner(id) == *id && !v.iter().any(|(i, ..)| i == id) {+ let path: Vec<&str> = menu.iter().take(menu.len().saturating_sub(1)).map(|m| crate::i18n::menu(m)).collect();+ v.push((id, crate::i18n::t(label).to_string(), path.join(" ▸ ")));+ }+ }+ v.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase()).then(a.0.cmp(b.0)));+ v+}++fn label_of(id: &str) -> String {+ cadcraft_engine::find_command(id)+ .map(|c| crate::i18n::t(c.label).to_string())+ .or_else(|| crate::menus::UI_COMMANDS.iter().find(|c| c.0 == id).map(|c| crate::i18n::t(c.1).to_string()))+ .unwrap_or_else(|| id.to_string())+}++/// The Keyboard Shortcuts dialog: every command with its shortcut, a filter, a click to record a+/// new one. Changes apply at once.+pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {+ let t = Tokens::get();+ let mac = ctx.os().is_mac();+ let rec_id = egui::Id::new(RECORDING);+ let filter_id = egui::Id::new("user_keys_filter");+ let note_id = egui::Id::new("user_keys_note");+ let mut recording: Option<String> = ctx.data(|m| m.get_temp(rec_id));+ let mut filter: String = ctx.data(|m| m.get_temp(filter_id)).unwrap_or_default();+ let mut note: Option<(String, bool)> = ctx.data(|m| m.get_temp(note_id));+ // A key pressed while recording becomes that command's shortcut; Esc stops recording.+ if let Some(id) = recording.clone() {+ let pressed = ctx.input(|inp| {+ inp.events.iter().find_map(|e| match e {+ egui::Event::Key { key, pressed: true, modifiers, .. } => Some((*key, *modifiers)),+ _ => None,+ })+ });+ if let Some((k, m)) = pressed {+ if k != Key::Escape {+ note = Some(match assign(&mut app.ui.shortcuts, &id, &format(m, k)) {+ Ok(lost) if lost.is_empty() => (String::new(), false),+ Ok(lost) => {+ let names: Vec<String> = lost.iter().map(|l| label_of(l)).collect();+ (crate::tf!("Replaces the shortcut of {commands}", commands = names.join(", ")), false)+ }+ Err(_) => (crate::tl!("Needs Ctrl, Alt or a function key").to_string(), true),+ });+ }+ recording = None;+ ctx.input_mut(|inp| inp.events.clear());+ }+ }+ let mut reset_all = false;+ egui::Window::new(crate::tl!("Keyboard Shortcuts"))+ .id(egui::Id::new("shortcuts_dialog"))+ .open(open)+ .collapsible(false)+ .default_size(vec2(600.0, 520.0))+ .show(ctx, |ui| {+ ui.horizontal(|ui| {+ ui.label(crate::tl!("Filter"));+ ui.text_edit_singleline(&mut filter);+ });+ let f = filter.trim().to_lowercase();+ let rows: Vec<(&'static str, String, String)> = rows()+ .into_iter()+ .filter(|(_, label, detail)| f.is_empty() || label.to_lowercase().contains(&f) || detail.to_lowercase().contains(&f))+ .collect();+ let row_h = ui.spacing().interact_size.y + 4.0;+ egui::ScrollArea::vertical().max_height(360.0).auto_shrink([false, true]).show_rows(ui, row_h, rows.len(), |ui, range| {+ for (id, label, detail) in rows.get(range).unwrap_or_default() {+ ui.horizontal(|ui| {+ let cell = |ui: &mut egui::Ui, w: f32, text: RichText| {+ ui.allocate_ui_with_layout(vec2(w, row_h - 4.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {+ ui.set_width(w);+ ui.add(egui::Label::new(text).truncate());+ });+ };+ cell(ui, 190.0, RichText::new(label.as_str()));+ cell(ui, 140.0, RichText::new(detail.as_str()).color(t.text_dim).small());+ let fixed = FIXED.contains(id);+ let key = shown(&app.ui.shortcuts, id, default_key(id));+ let text = if recording.as_deref() == Some(*id) {+ crate::tl!("Press the keys").to_string()+ } else {+ key.as_deref().map_or_else(|| crate::tl!("None").to_string(), |k| crate::menus::shortcut_label(k, mac))+ };+ let b = egui::Button::new(text).selected(recording.as_deref() == Some(*id)).min_size(vec2(120.0, 0.0));+ if ui.add_enabled(!fixed, b).clicked() {+ recording = Some(id.to_string());+ note = None;+ }+ if !fixed && key.is_some() && ui.small_button(crate::tl!("Clear")).clicked() {+ let _ = assign(&mut app.ui.shortcuts, id, "");+ }+ if app.ui.shortcuts.contains_key(*id)+ && ui.small_button(crate::tl!("Default")).on_hover_text(crate::tl!("Back to CADCraft's shortcut")).clicked()+ {+ app.ui.shortcuts.remove(*id);+ }+ });+ }+ });+ match ¬e {+ Some((n, true)) => {+ ui.colored_label(t.warn, n.as_str());+ }+ Some((n, false)) if !n.is_empty() => {+ ui.label(RichText::new(n.as_str()).color(t.text_dim));+ }+ _ => {}+ }+ ui.separator();+ ui.horizontal(|ui| {+ reset_all = ui.button(crate::tl!("Reset to Defaults")).clicked();+ });+ ui.label(+ RichText::new(crate::tl!("A shortcut you give wins over CADCraft's own. Changes apply at once and are kept for next time."))+ .color(t.text_dim)+ .small(),+ );+ });+ if reset_all {+ app.ui.shortcuts.clear();+ note = None;+ }+ if !*open {+ recording = None;+ note = None;+ }+ ctx.data_mut(|m| {+ match &recording {+ Some(r) => {+ m.insert_temp(rec_id, r.clone());+ }+ None => {+ m.remove::<String>(rec_id);+ }+ }+ m.insert_temp(filter_id, filter);+ match note {+ Some(n) => {+ m.insert_temp(note_id, n);+ }+ None => {+ m.remove::<(String, bool)>(note_id);+ }+ }+ });+}++#[cfg(test)]+mod tests {+ use super::*;+ use cadcraft_engine::Session;++ fn app() -> CadApp {+ CadApp::new(Session::new(), crate::Services::default())+ }++ fn key(s: &str) -> KeyboardShortcut {+ parse(s).unwrap_or_else(|| panic!("{s}"))+ }++ fn runs(user: &UserKeys, s: &str) -> Option<String> {+ effective(user).into_iter().find(|(o, _)| same(o, &key(s))).map(|(_, id)| id)+ }++ #[test]+ fn shortcut_strings_round_trip() {+ for s in ["Cmd+Shift+G", "Cmd+4", "F3", "Alt+PageUp", "Cmd+Plus", "Cmd+Minus", "Ctrl+K", "Cmd+Ctrl+K"] {+ let k = key(s);+ assert_eq!(format(k.modifiers, k.logical_key), s);+ }+ // Keys as egui reports them: Ctrl on Linux and Windows is the command key; macOS Control+ // and Command+Control keep their Control.+ let linux_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };+ let mac_ctrl = Modifiers { ctrl: true, ..Modifiers::NONE };+ let mac_cmd_ctrl = Modifiers { ctrl: true, command: true, mac_cmd: true, ..Modifiers::NONE };+ assert_eq!(format(linux_ctrl, Key::K), "Cmd+K");+ assert_eq!(format(mac_ctrl, Key::K), "Ctrl+K");+ assert_eq!(format(mac_cmd_ctrl, Key::K), "Cmd+Ctrl+K");+ assert!(parse("").is_none() && parse("Cmd+").is_none() && parse("Cmd+G+H").is_none() && parse("Hyper+G").is_none());+ }++ #[test]+ fn typing_keys_clipboard_keys_and_quit_are_never_user_shortcuts() {+ for s in [+ "L",+ "Shift+L",+ "Space",+ "Enter",+ "Escape",+ "Cmd+C",+ "Cmd+V",+ "Cmd+X",+ "Cmd+Shift+C",+ "Cmd+Shift+V",+ "Cmd+Alt+C",+ "Cmd+Alt+X",+ "Cmd+Alt+V",+ "Cmd+Q",+ "ArrowUp",+ "Delete",+ ] {+ assert!(!allowed(&key(s)), "{s}");+ }+ for s in ["Cmd+L", "Alt+L", "F4", "PageDown", "Cmd+Shift+Q", "Ctrl+K"] {+ assert!(allowed(&key(s)), "{s}");+ }+ }++ #[test]+ fn any_command_gets_a_shortcut_and_it_runs_it() {+ let mut user = UserKeys::new();+ assert_eq!(assign(&mut user, "line", "Cmd+L"), Ok(vec![]));+ assert_eq!(runs(&user, "Cmd+L").as_deref(), Some("line"));+ // The built-in ones are untouched.+ assert_eq!(runs(&user, "Cmd+S").as_deref(), Some("qsave"));+ assert_eq!(runs(&UserKeys::new(), "Cmd+L"), None);+ }++ #[test]+ fn control_and_command_control_shortcuts_keep_their_control() {+ let mut user = UserKeys::new();+ assert_eq!(assign(&mut user, "line", "Ctrl+K"), Ok(vec![]));+ assert_eq!(assign(&mut user, "circle", "Cmd+Ctrl+K"), Ok(vec![]));+ assert_eq!(user.get("line").map(String::as_str), Some("Ctrl+K"));+ assert_eq!(user.get("circle").map(String::as_str), Some("Cmd+Ctrl+K"));+ let eff = effective(&user);+ let of = |id: &str| eff.iter().find(|(_, i)| i == id).map(|(s, _)| s.modifiers);+ assert_eq!(of("line"), Some(Modifiers::CTRL));+ assert_eq!(of("circle"), Some(Modifiers { ctrl: true, command: true, mac_cmd: true, ..Modifiers::NONE }));+ // Command+Control+K is tried before Control+K, which it holds too.+ let at = |id: &str| eff.iter().position(|(_, i)| i == id);+ assert!(at("circle") < at("line"));+ }++ #[test]+ fn a_user_shortcut_wins_over_a_built_in_one_and_says_which() {+ let mut user = UserKeys::new();+ assert_eq!(assign(&mut user, "line", "Cmd+P"), Ok(vec!["plot".to_string()]));+ assert_eq!(runs(&user, "Cmd+P").as_deref(), Some("line"));+ assert_eq!(shown(&user, "plot", default_key("plot")), None, "Plot no longer shows Cmd+P");+ assert_eq!(shown(&user, "line", default_key("line")).as_deref(), Some("Cmd+P"));+ // Given to a third command, the first user one loses it too.+ assert_eq!(assign(&mut user, "circle", "Cmd+P"), Ok(vec!["line".to_string()]));+ assert_eq!(runs(&user, "Cmd+P").as_deref(), Some("circle"));+ assert_eq!(shown(&user, "line", default_key("line")), None);+ // A table entry that runs a command's UI form belongs to that command (Cmd+O, OPEN).+ assert_eq!(assign(&mut user, "line", "Cmd+O"), Ok(vec!["open".to_string()]));+ assert_eq!(assign(&mut user, "line", "Cmd+8"), Ok(vec!["ui.toggle.quickcalc".to_string()]));+ }++ #[test]+ fn a_new_shortcut_replaces_the_commands_own_and_clear_removes_it() {+ let mut user = UserKeys::new();+ assert_eq!(assign(&mut user, "qsave", "Cmd+Shift+K"), Ok(vec![]));+ assert_eq!(runs(&user, "Cmd+S"), None, "Save's own Cmd+S is replaced");+ assert_eq!(runs(&user, "Cmd+Shift+K").as_deref(), Some("qsave"));+ assert_eq!(assign(&mut user, "plot", ""), Ok(vec![]));+ assert_eq!(runs(&user, "Cmd+P"), None);+ assert_eq!(shown(&user, "plot", default_key("plot")), None);+ // OPEN through its UI form: its Cmd+O goes when OPEN gets another key.+ assert_eq!(assign(&mut user, "open", "F4"), Ok(vec![]));+ assert_eq!(runs(&user, "Cmd+O"), None);+ }++ #[test]+ fn bad_shortcuts_and_commands_are_refused() {+ let mut user = UserKeys::new();+ assert!(assign(&mut user, "nosuchcommand", "Cmd+L").is_err());+ assert!(assign(&mut user, "line", "L").is_err());+ assert!(assign(&mut user, "line", "Cmd+Nothing").is_err());+ assert!(assign(&mut user, "line", &"Cmd+".repeat(1000)).is_err());+ assert!(assign(&mut user, "copyclip", "Cmd+K").is_err(), "the clipboard keys stay");+ assert!(assign(&mut user, "pasteblock", "Cmd+K").is_err(), "Shift+Cmd+V is a clipboard event too");+ assert!(user.is_empty());+ }++ #[test]+ fn the_json_form_sets_resets_and_never_opens_the_dialog() {+ let mut app = app();+ let v = app.run("ui.shortcuts", json!({"set": {"LINE": "Cmd+P"}})).unwrap();+ assert_eq!(v["shortcuts"], json!({"line": "Cmd+P"}));+ assert_eq!(v["replaced"], json!(["plot"]));+ assert_eq!(app.ui.dialog, None);+ assert!(app.run("ui.shortcuts", json!({"set": {"line": 3}})).is_err());+ assert!(app.run("ui.shortcuts", json!({"set": "Cmd+P"})).is_err());+ assert_eq!(app.ui.shortcuts.get("line").map(String::as_str), Some("Cmd+P"), "a refused call changes nothing");+ let v = app.run("ui.shortcuts", json!({"reset": true})).unwrap();+ assert_eq!(v["shortcuts"], json!({}));+ app.start("ui.shortcuts");+ assert_eq!(app.ui.dialog.as_deref(), Some(DIALOG));+ }++ #[test]+ fn shortcuts_are_kept_with_the_preferences_and_hostile_ones_dropped() {+ let mut a = app();+ let _ = a.run("ui.shortcuts", json!({"set": {"line": "Cmd+L", "plot": ""}}));+ let mut b = app();+ b.load_prefs(&a.prefs_json());+ assert_eq!(b.ui.shortcuts, a.ui.shortcuts);+ let hostile =+ json!({"shortcuts": {"line": "L", "nosuch": "Cmd+K", "copyclip": "Cmd+K", "circle": 5, "arc": "Cmd+K", "x".repeat(5000): "Cmd+J"}});+ let mut c = app();+ c.load_prefs(&hostile.to_string());+ assert_eq!(c.ui.shortcuts, UserKeys::from([("arc".to_string(), "Cmd+K".to_string())]));+ let many: serde_json::Map<String, Value> = (0..5000).map(|i| (format!("c{i}"), json!("Cmd+K"))).collect();+ c.load_prefs(&json!({ "shortcuts": many }).to_string());+ assert!(c.ui.shortcuts.len() <= MAX_KEYS);+ c.load_prefs(r#"{"shortcuts": "Cmd+K"}"#);+ // One key for two commands (an edited file): the first keeps it, the other is dropped.+ c.load_prefs(r#"{"shortcuts": {"arc": "Cmd+K", "line": "Cmd+K", "circle": "Cmd+J"}}"#);+ assert_eq!(c.ui.shortcuts, UserKeys::from([("arc".to_string(), "Cmd+K".to_string()), ("circle".to_string(), "Cmd+J".to_string())]));+ }++ #[test]+ fn the_menus_show_the_shortcut_in_force() {+ let mut app = app();+ let _ = app.run("ui.shortcuts", json!({"set": {"line": "Cmd+P"}}));+ let mut found = (None, None);+ fn walk(es: &[crate::menus::Entry], found: &mut (Option<Option<String>>, Option<Option<String>>)) {+ for e in es {+ match e {+ crate::menus::Entry::Item { id, shortcut, .. } if id == "line" => found.0 = Some(shortcut.clone()),+ crate::menus::Entry::Item { id, shortcut, .. } if id == "plot" => found.1 = Some(shortcut.clone()),+ crate::menus::Entry::Sub { children, .. } => walk(children, found),+ _ => {}+ }+ }+ }+ for (_, es) in crate::menus::tree(&app) {+ walk(&es, &mut found);+ }+ assert_eq!(found, (Some(Some("Cmd+P".into())), Some(None)));+ }++ #[test]+ fn a_recorded_key_runs_its_command_and_quiets_shortcuts_while_recording() {+ let ctx = egui::Context::default();+ let mut app = app();+ let _ = app.run("ui.shortcuts", json!({"set": {"line": "F4"}}));+ let press = |key: Key| egui::RawInput {+ events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],+ ..Default::default()+ };+ let mut out = ctx.run_ui(press(Key::F4), |ui| crate::menus::shortcuts(&mut app, ui.ctx()));+ out.textures_delta.clear();+ assert_eq!(app.session.summary()["running"], json!("line"));+ app.session.cancel();+ // Recording in the dialog: F4 becomes the recorded key, nothing runs.+ app.ui.dialog = Some(DIALOG.into());+ ctx.data_mut(|m| m.insert_temp(egui::Id::new(RECORDING), "circle".to_string()));+ let mut out = ctx.run_ui(press(Key::F6), |ui| {+ crate::menus::shortcuts(&mut app, ui.ctx());+ let mut open = true;+ dialog(&mut app, ui.ctx(), &mut open);+ });+ out.textures_delta.clear();+ assert_eq!(app.session.summary()["running"], Value::Null);+ assert_eq!(app.ui.shortcuts.get("circle").map(String::as_str), Some("F6"));+ assert!(!recording(&ctx));+ }+}@@ -34,6 +34,7 @@ pub mod gpu; pub mod grip_dyn; pub mod i18n; pub mod icons;+pub mod keys; pub mod layers; pub mod managers; pub mod menus;@@ -97,6 +98,8 @@ pub struct UiState { pub start_tab: bool, pub dialog: Option<String>, pub history_lines: usize,+ /// The user's keyboard shortcuts ([`keys`]).+ pub shortcuts: keys::UserKeys, /// Interface theme: "system" (follow the OS light/dark appearance), "light" or "dark". pub theme: theme::ThemePref, /// Animation effects (dialog and menu fades, smoothed scrolling): "system" (follow the OS@@ -177,6 +180,7 @@ impl Default for UiState { start_tab: false, dialog: None, history_lines: 3,+ shortcuts: keys::UserKeys::new(), theme: theme::ThemePref::default(), animations: animation::AnimationPref::default(), save_format: SaveFormat::default(),@@ -396,6 +400,9 @@ impl CadApp { if let Some(dir) = &self.ui.last_lisp_dir { prefs["lispDir"] = json!(dir); }+ if !self.ui.shortcuts.is_empty() {+ prefs["shortcuts"] = json!(self.ui.shortcuts);+ } prefs.to_string() } @@ -426,6 +433,9 @@ impl CadApp { if let Some(dir) = v.get("lispDir").and_then(Value::as_str).filter(|d| !d.is_empty()) { self.ui.last_lisp_dir = Some(dir.to_string()); }+ if let Some(keys) = v.get("shortcuts") {+ self.ui.shortcuts = keys::from_prefs(keys);+ } } pub fn set_status(&mut self, s: impl Into<String>) {@@ -69,6 +69,7 @@ pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.cmdline.lines", "Command Line History Lines", &[], None), ("ui.cmdline.clear", "Clear Command History", &[], None), ("ui.dialog.language", "Interface Language", &["Edit", "Interface Language"], None),+ ("ui.shortcuts", "Keyboard Shortcuts...", &["Tools", "Customize", "Keyboard Shortcuts..."], None), ("ui.language", "Language", &[], None), ("ui.noop", "", &[], None), ("ui.quit", "Quit CADCraft", &[], Some("Cmd+Q")),@@ -96,6 +97,9 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re if let Some(r) = crate::managers::route(app, id, params) { return Some(r); }+ if id == "ui.shortcuts" {+ return Some(crate::keys::command(app, params));+ } if matches!(id, "ui.dock" | "ui.dock.reset") { return Some(crate::docking::command(app, id == "ui.dock.reset", params)); }@@ -497,6 +501,7 @@ pub enum Entry { /// The full menu tree from the command registry plus UI commands, in registration order. pub fn tree(app: &CadApp) -> Vec<(String, Vec<Entry>)> { let mut out: Vec<(String, Vec<Entry>)> = MENUS.iter().map(|m| (m.to_string(), Vec::new())).collect();+ let user = &app.ui.shortcuts; let mut add = |path: &[&str], label: &str, id: &str, shortcut: Option<&str>, enabled: bool| { let Some((top, rest)) = path.split_first() else { return }; let Some((_, entries)) = out.iter_mut().find(|(m, _)| m == top) else { return };@@ -507,7 +512,7 @@ pub fn tree(app: &CadApp) -> Vec<(String, Vec<Entry>)> { cur.push(Entry::Item { label: if seg.is_empty() { label.to_string() } else { seg.to_string() }, id: id.to_string(),- shortcut: shortcut.map(str::to_string),+ shortcut: crate::keys::shown(user, id, shortcut), enabled, }); } else {@@ -657,18 +662,20 @@ pub fn run_transparent(app: &mut CadApp, id: &str) { } } -/// Keyboard shortcuts and function keys.-pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {+/// CADCraft's keyboard shortcuts and function keys, each with the command it runs. Every shortcut a+/// command shows (its registry `key`, or a UI command's) must be here, or pressing it does nothing.+pub(crate) fn builtin_shortcuts() -> Vec<(egui::KeyboardShortcut, &'static str)> { use egui::{Key, KeyboardShortcut, Modifiers}; let sc = |m: Modifiers, k: Key| KeyboardShortcut::new(m, k); let cmd = Modifiers::COMMAND; let cmd_shift = Modifiers::COMMAND | Modifiers::SHIFT;- let pairs: &[(KeyboardShortcut, &str)] = &[+ vec![ (sc(cmd_shift, Key::A), "pickstyle"), (sc(cmd_shift, Key::Z), "redo"), (sc(cmd, Key::Z), "u"), (sc(cmd, Key::Y), "redo"), (sc(cmd, Key::N), "new"),+ (sc(cmd, Key::W), "close"), (sc(cmd, Key::O), "ui.open"), (sc(cmd_shift, Key::S), "ui.saveas"), (sc(cmd, Key::S), "qsave"),@@ -693,28 +700,43 @@ pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) { (sc(Modifiers::NONE, Key::F10), "polar"), (sc(Modifiers::NONE, Key::F11), "otrack"), (sc(Modifiers::NONE, Key::F12), "dynmode"),- ];+ ]+}++/// Keyboard shortcuts and function keys: the user's ([`crate::keys`]) and CADCraft's own.+pub fn shortcuts(app: &mut CadApp, ctx: &egui::Context) {+ use egui::{Key, KeyboardShortcut, Modifiers};+ // A recording ends with the dialog it was started in; while it lasts the keys are its own.+ if app.ui.dialog.as_deref() != Some(crate::keys::DIALOG) {+ crate::keys::stop_recording(ctx);+ }+ if crate::keys::recording(ctx) {+ return;+ }+ let pairs = crate::keys::effective_cached(ctx, &app.ui.shortcuts); crate::clipboard::mirror(app, ctx); if app.closing.is_some() { return; } // A palette toggle is global even while its expression field owns keyboard focus.- if ctx.input_mut(|i| i.consume_shortcut(&sc(cmd, Key::Num8))) {+ let quickcalc = KeyboardShortcut::new(Modifiers::COMMAND, Key::Num8);+ if pairs.iter().any(|(s, id)| *s == quickcalc && id == "ui.toggle.quickcalc") && ctx.input_mut(|i| i.consume_shortcut(&quickcalc)) { run_transparent(app, "ui.toggle.quickcalc"); } if ctx.egui_wants_keyboard_input() { return; } // Cmd/Ctrl+C, X and V arrive as clipboard events, not key presses.- let mut fire = crate::clipboard::shortcut_commands(app, ctx);+ let mut fire: Vec<String> = crate::clipboard::shortcut_commands(app, ctx).into_iter().map(str::to_string).collect(); ctx.input_mut(|i| {- for (s, id) in pairs {+ for (s, id) in &pairs { if i.consume_shortcut(s) && !fire.contains(id) {- fire.push(*id);+ fire.push(id.clone()); } } }); for id in fire {+ let id = id.as_str(); // ISOPLANE (F5, Ctrl+E) switches the plane without cancelling the active command. if id == "isoplane" { if let Some(m) = app.session.execute(id, &json!({})).ok().and_then(|v| v.get("message").and_then(Value::as_str).map(str::to_string)) {@@ -762,6 +784,46 @@ mod tests { (CadApp::new(Session::new(), services), asked) } + /// A registry shortcut ("Cmd+Shift+Z", "F8") as egui reads it.+ fn parse_shortcut(s: &str) -> Option<egui::KeyboardShortcut> {+ let mut m = egui::Modifiers::NONE;+ let (mods, key) = s.rsplit_once('+').unwrap_or(("", s));+ for part in mods.split('+').filter(|p| !p.is_empty()) {+ match part {+ "Cmd" => m |= egui::Modifiers::COMMAND,+ "Shift" => m |= egui::Modifiers::SHIFT,+ "Alt" => m |= egui::Modifiers::ALT,+ _ => return None,+ }+ }+ Some(egui::KeyboardShortcut::new(m, egui::Key::from_name(key)?))+ }++ #[test]+ fn every_shown_shortcut_runs_its_command() {+ // Handled outside the table: Delete by the command line (it erases the selection only when+ // the line is empty), Cmd+8 before it (QuickCalc's own field keeps focus), Cmd+Q by the+ // macOS app menu, the only place that shows it.+ let elsewhere = [("Delete", "erase.selection"), ("Cmd+8", "ui.toggle.quickcalc"), ("Cmd+Q", "ui.quit")];+ let table = super::builtin_shortcuts();+ let shown = cadcraft_engine::command_specs()+ .iter()+ .filter_map(|c| Some((c.shortcut?, c.id)))+ .chain(super::UI_COMMANDS.iter().filter_map(|(id, _, _, sc)| Some(((*sc)?, *id))));+ let mut missing = Vec::new();+ for (key, id) in shown {+ if elsewhere.contains(&(key, id)) {+ continue;+ }+ let sc = parse_shortcut(key).unwrap_or_else(|| panic!("{id}: unreadable shortcut {key}"));+ // The table may run a command's UI form (`ui.open` for `open`), which does the same.+ if !table.iter().any(|(s, i)| *s == sc && (*i == id || i.strip_prefix("ui.") == Some(id))) {+ missing.push(format!("{key} {id}"));+ }+ }+ assert!(missing.is_empty(), "shown but not run: {missing:?}");+ }+ #[test] fn plot_and_print_open_the_plot_dialog_and_exportpdf_asks_for_a_file() { set_io(IoHooks { read: |_, _| Err("no reader in tests".into()), write: |_, _| Err("no writer in tests".into()), plot: Some(fake_plot) });@@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (DIMTFILL limits in the `*D` block, EXPLODE and DXF: F7 updated) · **Target:** Autodesk AutoCAD 2027+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Keyboard Shortcuts dialog: any command gets a shortcut: U12 and E5 narrowed) · **Target:** Autodesk AutoCAD 2027 Every known shortfall, one row each, ranked within each section by user impact. This is the work list: agents pick from here (top of a section first), and remove or update a row in the same PR@@ -64,7 +64,7 @@ or allow clicks and drag (2, default), saved in the user profile. Drag-only mode | U7 | Right-click menus: no in-place layout tab rename or Move or Copy dialog, no Select All Layouts, no Paste to command line; palette menus are on the Layers rows and the Properties background only. The command menu (held right-click: Enter, Cancel, Recent Input, options, Snap Overrides, Pan/Zoom), the selection menu with object edits, command line, drawing tab, layout tab and palette menus landed (#512) | Low | 1–2 | | U10 | SETVAR covers about 150 of ~1,000 variables (UNDO options, MREDO and UNDOCTL, and header variables such as CIRCLERAD, OFFSETDIST, POLYSIDES, HPNAME and the dimension variables in SETVAR ? landed, #461; typing a bare variable name as a command runs SETVAR on it) | Low-medium | 3–5 | | U11 | MVIEW Restore (a saved viewport configuration into a layout); VIEW Orthographic, UCS and background settings; view twist is kept but not shown (Zoom Realtime and Dynamic, ZOOMFACTOR, named views (VIEW) and tiled model viewports with saved configurations (VPORTS) landed in #463) | Low | 1–2 |-| U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | Low-medium | 13–23 |+| U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381; the Keyboard Shortcuts dialog landed) | Low-medium | 13–23 | | U13 | `.dxf` from Finder fails to open on macOS (#158) (window controls fixed, #161) | Medium | 1–2 | ## File formats@@ -136,7 +136,7 @@ Detail: [localization-parity.md](localization-parity.md). | E1 | AutoLISP beyond the core (#758 landed the language, `command`, entity lists, selection sets, `get…` input, APPLOAD, `(load)`, `c:` commands and `acad.lsp`/`acaddoc.lsp` at startup; #1820 gave APPLOAD its `.lsp` file picker): Visual LISP ActiveX (`vlax-`, `vla-`), DCL dialogs, reactors, an APPLOAD Startup Suite, UCS-aware `trans` | 26–46 | | E3 | Express Tools equivalents (SUPERHATCH; NCOPY, BREAKLINE, TXT2MTXT, BURST and MOCORO exist: BREAKLINE's Block option takes a block of the drawing, not a `.dwg` file, and TXT2MTXT's Settings are command-line prompts rather than a dialog, without the uniform line spacing option) | 24–40 | | E4 | Plug-in API (owner decision: Rust/WASM plug-ins rather than ObjectARX/.NET) | 30–50 |-| E5 | CUI customisation (the alias editor, ALIASEDIT's Command Aliases dialog, landed, #1381; edits stay for the session) | 13–23 |+| E5 | CUI customisation (the alias editor, ALIASEDIT's Command Aliases dialog, landed, #1381; edits stay for the session; user keyboard shortcuts for any command landed) | 13–23 | ## AI features @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---|+| 2026-10-25 | minor | Tools ▸ Customize ▸ Keyboard Shortcuts: every command with its shortcut, a filter, click and press the keys to give one (Esc cancels), Clear, Default, Reset to Defaults; a user shortcut wins over a built-in one, whose command loses it with a note; menus show the shortcut in force; kept in the preferences; JSON form `ui.shortcuts {set, reset}`: U12 and E5 narrowed | | 2026-10-25 | minor | DIMTFILL limits: with DIMTFILL 1 (the drawing's background color) the written `*D` block and EXPLODE keep no background mask (a WIPEOUT over the text box would), and a true-color DIMTFILLCLR is written to DXF as the nearest ACI: F7 updated | | 2026-10-24 | minor | COMPAREEXPORT (typed, or Export on the comparison bar) opens the comparison as a new untitled drawing, `compare_<this> vs <other>.dwg`: this drawing's objects in one group and the other drawing's differing objects in a second group, in the comparison colors (green, red, gray for objects in both), block contents and style colors included: F22 narrowed | | 2026-10-24 | minor | DIMTFILL and DIMTFILLCLR: a dimension's text background (none, the drawing's background color, or a color) hides what is drawn under the text; SETVAR, DIMOVERRIDE and style fields, DIMSTYLE groups 69 and 70 in DXF from R2007 (CADCraft xdata before), and a SOLID in the `*D` block and from EXPLODE for DIMTFILL 2: F7 narrowed |@@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (distinct polyline midpoint grips) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Keyboard Shortcuts dialog) · **Target:** Autodesk AutoCAD 2027 for Mac (26.0) How CADCraft's interaction compares with AutoCAD's: point entry, object snaps, tracking, dynamic input, grips, selection, the command line, chrome and navigation. A drafter's speed in AutoCAD@@ -167,7 +167,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | Navigation bar, SteeringWheels | yes | no | | Workspaces | Drafting & Annotation, 3D Modeling | no | | Options / Preferences dialog | many tabs | **no** (issue #23) |-| Customisation (CUI, aliases editor, tool palettes) | yes | Command Aliases dialog (ALIASEDIT, #1381); no CUI or tool palettes |+| Customisation (CUI, aliases editor, tool palettes) | yes | Command Aliases dialog (ALIASEDIT, #1381) and Keyboard Shortcuts dialog (any command); no CUI or tool palettes | | Themes | Dark, Light | Dark, Light, System (#179) | | Accessibility | VoiceOver basics | AccessKit through eframe; screen-reader coverage untested; magnifier issue #39; animation effects follow the OS setting (View ▸ Accessibility ▸ Animation Effects: System, On, Off) | @@ -188,7 +188,8 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. - Shortcut table: 28 entries (Cmd shortcuts, F1, F2, F3, F5, F7–F12) plus 23 command `.key()` shortcuts. AutoCAD for Mac's defaults are similar in number; F4 (3D osnap) and F6 (dynamic UCS)- are missing.+ are missing. Tools ▸ Customize ▸ Keyboard Shortcuts gives any command a shortcut; a user+ shortcut wins over a built-in one (its command loses it, with a note) and the menus show it. - Delete erases the selection on macOS (#64); Escape cancels. - Right-click on the canvas is Enter while a command runs; holding it (250 ms, AutoCAD's time-sensitive right-click) opens the command menu: Enter, Cancel, Recent Input, the prompt's@@ -209,6 +210,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | 2026-10-14 | minor | Hatch Edit dialog adds boundaries from picked points or closed objects and removes selected loops | | 2026-10-14 | minor | Holding Shift overrides ortho while it is held (on when ORTHOMODE is off, off when it is on), as in AutoCAD | | 2026-10-16 | minor | ALIASEDIT opens the Command Aliases dialog (#1381) |+| 2026-10-25 | minor | Keyboard Shortcuts dialog: a shortcut for any command, winning over a built-in one; kept in the preferences | | 2026-10-18 | minor | In-window menu bar: once a menu is open, pointing at another title closes it and opens that one (#1567) | | 2026-10-13 | minor | Options ▸ Selection adds Select using two clicks only; PICKDRAG 0 disables drag-window creation/completion, 2 preserves clicks or drag, and the preference survives restart; UI pointer and profile regressions | | 2026-10-12 | minor | Selected and hovered objects redrawn in the selection colour (text and arrowheads included); Options ▸ Selection laid out as AutoCAD's: Object grouping, Show grips, grip object limit, selection preview, Visual Effect Settings (selection area, preview filter) |