diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index b08b9d7e..4d184c79 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -14,6 +14,8 @@ use serde_json::{Value, json}; use super::*; use crate::{Accept, Input, Prompt, Result, Session, Step}; +/// The rules the Command Aliases dialog checks before it sends an alias to ALIASEDIT. +pub use pgp::{MAX_ALIASES, alias_name, command_id}; pub(crate) use pgp::{specs as alias_specs, user_alias}; pub use template::{qnew_template, set_qnew_template}; diff --git a/crates/engine/src/cmd/file/pgp.rs b/crates/engine/src/cmd/file/pgp.rs index e92ae233..e502ad80 100644 --- a/crates/engine/src/cmd/file/pgp.rs +++ b/crates/engine/src/cmd/file/pgp.rs @@ -43,13 +43,13 @@ pub(crate) fn user_alias(name: &str) -> Option { } /// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command. -fn command_id(target: &str) -> Option { +pub fn command_id(target: &str) -> Option { let t = target.trim().trim_start_matches('*').trim().trim_start_matches(['_', '.']).to_ascii_lowercase(); find_command(&t).map(|c| c.id.to_string()) } /// Whether `a` can be typed as an alias: one word of printable characters, not a command name. -fn alias_name(a: &str) -> std::result::Result { +pub fn alias_name(a: &str) -> std::result::Result { let a = a.trim(); if a.is_empty() || a.chars().count() > MAX_ALIAS_CHARS || a.chars().any(|c| c.is_whitespace() || c.is_control() || c == '\'' || c == '{') { return Err(format!("`{a}` can't be an alias")); @@ -193,6 +193,7 @@ fn run_reinit(s: &mut Session, p: &Value) -> Result { pub(crate) fn specs() -> Vec { vec![ CommandSpec::new("aliasedit", "Command Aliases", run_aliasedit) + .menu(&["Tools", "Customize", "Edit Command Aliases (PGP)..."]) .params("{file?: path to a .pgp alias file | text?: its contents, set?: {alias: command}, remove?: [alias], clear?: bool} → {aliases, file, loaded, warnings}") .enabled(always) .noundo(), diff --git a/crates/ui-egui/src/aliasdlg.rs b/crates/ui-egui/src/aliasdlg.rs new file mode 100644 index 00000000..e643edcf --- /dev/null +++ b/crates/ui-egui/src/aliasdlg.rs @@ -0,0 +1,278 @@ +//! The Command Aliases dialog (ALIASEDIT typed or from a menu): the user's aliases next to the +//! built-in ones, Add, Edit and Remove, then OK or Apply. Built-in aliases are read-only; Edit on +//! one adds a user alias of the same name, which wins over it. The dialog edits a copy and sends +//! only the differences to ALIASEDIT, so it never forgets the alias file REINIT reads. + +use std::collections::BTreeMap; + +use egui::{RichText, vec2}; +use serde_json::{Map, Value, json}; + +use crate::CadApp; +use crate::theme::Tokens; + +/// The Add or Edit form. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Form { + pub alias: String, + pub command: String, + /// The user alias Edit was opened on (`None` when adding, or overriding a built-in). + pub editing: Option, +} + +/// The dialog while open. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AliasDlgState { + /// The user aliases as edited: lower-case alias → command id. + pub user: BTreeMap, + /// The user aliases ALIASEDIT holds now (OK and Apply send the difference). + pub applied: BTreeMap, + /// The alias file loaded last, which REINIT reads again. + pub file: Option, + pub selected: Option, + pub filter: String, + pub form: Option
, + pub error: Option, + /// ALIASEDIT typed again while open: bring the window to the front on the next frame. + pub raise: bool, +} + +/// The user aliases and alias file ALIASEDIT holds now. +fn current(app: &mut CadApp) -> (BTreeMap, Option) { + let listing = app.session.execute("aliasedit", &json!({})).unwrap_or_default(); + let aliases = listing + .get("aliases") + .and_then(Value::as_object) + .map(|m| m.iter().filter_map(|(a, c)| Some((a.clone(), c.as_str()?.to_string()))).collect()) + .unwrap_or_default(); + (aliases, listing.get("file").and_then(Value::as_str).map(str::to_string)) +} + +/// The built-in aliases: lower-case alias → command id, the first command naming it winning as +/// it does when typed. Aliases that are also command names never reach their command and are left out. +fn built_in() -> BTreeMap { + let mut m = BTreeMap::new(); + for c in cadcraft_engine::command_specs() { + for a in c.aliases { + let a = a.to_ascii_lowercase(); + if cadcraft_engine::find_command(&a).is_none() { + m.entry(a).or_insert_with(|| c.id.to_string()); + } + } + } + m +} + +/// Open the dialog on the aliases ALIASEDIT holds now. +pub fn open(app: &mut CadApp) { + // Already open: keep the edits not applied yet. + if app.ui.dialog.as_deref() == Some("aliasedit") + && let Some(st) = app.alias_dlg.as_mut() + { + st.raise = true; + return; + } + let (user, file) = current(app); + app.alias_dlg = Some(AliasDlgState { applied: user.clone(), user, file, ..Default::default() }); + app.ui.dialog = Some("aliasedit".into()); +} + +/// The alias and command a form names, with the rules ALIASEDIT applies, or why not. +fn check(form: &Form, user: &BTreeMap) -> Result<(String, String), String> { + if form.alias.trim().is_empty() { + return Err(crate::tl!("Type an alias name.").into()); + } + let name = cadcraft_engine::cmd::file::alias_name(&form.alias)?; + if form.command.trim().trim_start_matches('*').trim().is_empty() { + return Err(crate::tl!("Choose the command the alias starts.").into()); + } + let Some(id) = cadcraft_engine::cmd::file::command_id(&form.command) else { + return Err(crate::tf!("{command} is an unknown command.", command = form.command.trim().to_ascii_uppercase())); + }; + let renamed_from = form.editing.as_ref().filter(|old| **old != name); + if user.contains_key(&name) && form.editing.as_deref() != Some(name.as_str()) { + return Err(crate::tf!("{alias} is already defined; select it and use Edit.", alias = name.to_ascii_uppercase())); + } + let after = user.len().saturating_add(usize::from(!user.contains_key(&name))).saturating_sub(usize::from(renamed_from.is_some())); + if after > cadcraft_engine::cmd::file::MAX_ALIASES { + return Err(crate::tf!("More than {n} aliases.", n = cadcraft_engine::cmd::file::MAX_ALIASES)); + } + Ok((name, id)) +} + +/// Save alias: put the form's alias in the list, or say why not (the form stays open). +pub fn save_form(st: &mut AliasDlgState) { + let Some(form) = st.form.clone() else { return }; + match check(&form, &st.user) { + Ok((name, id)) => { + if let Some(old) = &form.editing { + st.user.remove(old); + } + st.user.insert(name.clone(), id); + st.selected = Some(name); + st.form = None; + st.error = None; + } + Err(e) => st.error = Some(e), + } +} + +/// Send the changes to ALIASEDIT; true when it took them all. ALIASEDIT applies aliases one by +/// one, so after a refusal the dialog reads back what it holds instead of assuming nothing changed. +pub fn apply(app: &mut CadApp) -> bool { + let Some(st) = app.alias_dlg.as_ref() else { return true }; + let set: Map = st.user.iter().filter(|(a, c)| st.applied.get(*a) != Some(*c)).map(|(a, c)| (a.clone(), json!(c))).collect(); + let remove: Vec<&String> = st.applied.keys().filter(|a| !st.user.contains_key(*a)).collect(); + if set.is_empty() && remove.is_empty() { + return true; + } + let r = app.run("aliasedit", json!({ "set": set, "remove": remove })); + let (applied, file) = current(app); + let Some(st) = app.alias_dlg.as_mut() else { return r.is_ok() }; + st.applied = applied; + st.file = file; + match r { + Ok(_) => { + st.error = None; + true + } + Err(e) => { + st.error = Some(e); + false + } + } +} + +pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { + if app.alias_dlg.is_none() { + crate::aliasdlg::open(app); + } + let built = built_in(); + let warn = Tokens::get().warn; + let (mut ok, mut cancel, mut apply_clicked) = (false, false, false); + let Some(st) = app.alias_dlg.as_mut() else { return }; + let id = egui::Id::new("aliasedit_dialog"); + if std::mem::take(&mut st.raise) { + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id)); + } + egui::Window::new(crate::tl!("Command Aliases")).id(id).open(open).collapsible(false).default_size(vec2(480.0, 460.0)).show(ctx, |ui| { + match &st.file { + Some(f) => ui.label(crate::tf!("Alias file: {file}", file = f)), + None => ui.label(crate::tl!("No alias file loaded. REINIT replaces these aliases with a file's.")), + }; + ui.horizontal(|ui| { + ui.label(crate::tl!("Filter")); + ui.text_edit_singleline(&mut st.filter); + }); + let f = st.filter.trim().to_ascii_lowercase(); + let mut names: Vec<&String> = st.user.keys().chain(built.keys().filter(|a| !st.user.contains_key(*a))).collect(); + names.sort(); + // Edit and Remove act only on a selected row the filter shows. + let mut selected_shown = false; + egui::ScrollArea::vertical().max_height(240.0).auto_shrink([false, true]).show(ui, |ui| { + egui::Grid::new("alias_rows").striped(true).num_columns(3).show(ui, |ui| { + ui.strong(crate::tl!("Alias")); + ui.strong(crate::tl!("Command")); + ui.label(""); + ui.end_row(); + for name in names { + let (command, mine) = match st.user.get(name) { + Some(c) => (c, true), + None => (built.get(name).unwrap_or(name), false), + }; + if !f.is_empty() && !name.contains(&f) && !command.contains(&f) { + continue; + } + let selected = st.selected.as_deref() == Some(name.as_str()); + selected_shown |= selected; + let text = RichText::new(name.to_ascii_uppercase()); + if ui.selectable_label(selected, if mine { text } else { text.weak() }).clicked() { + st.selected = Some(name.clone()); + } + ui.label(if mine { RichText::new(command.to_ascii_uppercase()) } else { RichText::new(command.to_ascii_uppercase()).weak() }); + let note = match (mine, built.contains_key(name)) { + (true, true) => crate::tl!("overrides built-in"), + (true, false) => "", + (false, _) => crate::tl!("built-in"), + }; + ui.label(RichText::new(note).small()); + ui.end_row(); + } + }); + }); + let sel = st.selected.clone().filter(|_| selected_shown); + let sel_mine = sel.as_ref().is_some_and(|s| st.user.contains_key(s)); + let sel_known = sel.as_ref().is_some_and(|s| sel_mine || built.contains_key(s)); + ui.horizontal(|ui| { + if ui.button(crate::tl!("Add…")).clicked() { + st.form = Some(Form::default()); + st.error = None; + } + if ui.add_enabled(sel_known, egui::Button::new(crate::tl!("Edit…"))).clicked() + && let Some(s) = &sel + { + let command = st.user.get(s).or_else(|| built.get(s)).cloned().unwrap_or_default(); + st.form = Some(Form { alias: s.to_ascii_uppercase(), command: command.to_ascii_uppercase(), editing: sel_mine.then(|| s.clone()) }); + st.error = None; + } + if ui.add_enabled(sel_mine, egui::Button::new(crate::tl!("Remove"))).clicked() + && let Some(s) = &sel + { + st.user.remove(s); + } + }); + let mut save = false; + let mut discard = false; + if let Some(form) = st.form.as_mut() { + ui.group(|ui| { + egui::Grid::new("alias_form").num_columns(2).show(ui, |ui| { + ui.label(crate::tl!("Alias")); + ui.text_edit_singleline(&mut form.alias); + ui.end_row(); + ui.label(crate::tl!("Command")); + ui.text_edit_singleline(&mut form.command); + ui.end_row(); + }); + // A few commands starting with what was typed, to pick from. + let typed = form.command.trim().trim_start_matches('*').to_ascii_lowercase(); + if !typed.is_empty() && cadcraft_engine::find_command(&typed).is_none() { + ui.horizontal_wrapped(|ui| { + for c in cadcraft_engine::command_specs().iter().filter(|c| c.id.starts_with(&typed)).take(8) { + if ui.small_button(c.id.to_ascii_uppercase()).clicked() { + form.command = c.id.to_ascii_uppercase(); + } + } + }); + } + ui.horizontal(|ui| { + save = ui.button(crate::tl!("Save alias")).clicked(); + discard = ui.button(crate::tl!("Discard")).clicked(); + }); + }); + } + if save { + save_form(st); + } + if discard { + st.form = None; + st.error = None; + } + if let Some(e) = &st.error { + ui.colored_label(warn, e.as_str()); + } + ui.separator(); + ui.horizontal(|ui| { + ok = ui.button(crate::tl!("OK")).clicked(); + cancel = ui.button(crate::tl!("Cancel")).clicked(); + apply_clicked = ui.button(crate::tl!("Apply")).clicked(); + }); + }); + if (ok && apply(app)) || cancel { + *open = false; + } else if apply_clicked { + apply(app); + } + if !*open { + app.alias_dlg = None; + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fc853d57..12a7d8d0 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -22,6 +22,7 @@ pub fn show(app: &mut CadApp, ctx: &egui::Context) { "dsettings" => dsettings(app, ctx, &mut open), "units" => units(app, ctx, &mut open), "hatch" => crate::hatchdlg::dialog(app, ctx, &mut open), + "aliasedit" => crate::aliasdlg::dialog(app, ctx, &mut open), "about" => about(ctx, &mut open), "preview" => crate::exchange::dialog(app, ctx, &mut open), "commands" => commands(app, ctx, &mut open), diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index febf838b..c7a09417 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1242,3 +1242,20 @@ menu Modify Edita Search for pattern Cerca modello Run Script... Esegui script... Select closed objects (regions, closed polylines, circles). Selezionare oggetti chiusi (regioni, polilinee chiuse, cerchi). + Type an alias name. Digitare un nome di alias. + Choose the command the alias starts. Scegliere il comando che l'alias avvia. + {command} is an unknown command. {command} è un comando sconosciuto. + {alias} is already defined; select it and use Edit. {alias} è già definito; selezionarlo e usare Modifica. + More than {n} aliases. Più di {n} alias. + Alias file: {file} File degli alias: {file} + No alias file loaded. REINIT replaces these aliases with a file's. Nessun file di alias caricato. REINIT sostituisce questi alias con quelli di un file. + Alias Alias + Command Comando + overrides built-in sostituisce il predefinito + built-in predefinito + Add… Aggiungi… + Edit… Modifica… + Save alias Salva alias + Discard Scarta + Customize Personalizza + Edit Command Aliases (PGP)... Modifica alias dei comandi (PGP)... diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs index 12cf4fc6..286444b9 100644 --- a/crates/ui-egui/src/i18n/tests.rs +++ b/crates/ui-egui/src/i18n/tests.rs @@ -97,6 +97,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet { include_str!("../palettes/patterns.rs"), include_str!("../dialogs.rs"), include_str!("../hatchdlg.rs"), + include_str!("../aliasdlg.rs"), include_str!("../mtext_editor.rs"), include_str!("../layers.rs"), include_str!("../canvas/compare.rs"), diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index fa386e3b..8987b071 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1341,3 +1341,20 @@ Click the ruler to add a tab stop, right-click to remove one Натисніть на лінійку, щоб додати табуляцію; права кнопка видаляє її Select using two clicks only Вибирати лише двома клацаннями Define each corner of the selection window with a separate click. Задайте кожен кут рамки вибору окремим клацанням. + Type an alias name. Введіть назву псевдоніма. + Choose the command the alias starts. Виберіть команду, яку запускає псевдонім. + {command} is an unknown command. {command} — невідома команда. + {alias} is already defined; select it and use Edit. {alias} уже визначено; виберіть його та натисніть «Змінити». + More than {n} aliases. Понад {n} псевдонімів. + Alias file: {file} Файл псевдонімів: {file} + No alias file loaded. REINIT replaces these aliases with a file's. Файл псевдонімів не завантажено. REINIT замінює ці псевдоніми псевдонімами з файлу. + Alias Псевдонім + Command Команда + overrides built-in замінює вбудований + built-in вбудований + Add… Додати… + Edit… Змінити… + Save alias Зберегти псевдонім + Discard Скасувати зміни + Customize Налаштування інтерфейсу + Edit Command Aliases (PGP)... Редагувати псевдоніми команд (PGP)... diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f80b4aa9..9f3204b5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,6 +8,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod aliasdlg; pub mod animation; pub mod autosave; pub mod bedit; @@ -241,6 +242,8 @@ pub struct CadApp { pub gpu_faults: std::sync::Arc, /// The notice shown after one (see [`autosave::gpu_notice`]). pub gpu_notice: Option, + /// The Command Aliases dialog while open ([`aliasdlg`]). + pub alias_dlg: Option, /// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]). pub hatch_dlg: Option, /// The Edit Block Definition dialog ([`bedit`]). @@ -282,6 +285,7 @@ impl CadApp { recovery: autosave::Recovery::default(), gpu_faults: std::sync::Arc::default(), gpu_notice: None, + alias_dlg: None, hatch_dlg: None, bedit: bedit::State::default(), } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dcba3494..b7d25cc8 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -396,6 +396,11 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { + crate::aliasdlg::open(app); + Ok(Value::Null) + } "hatchedit" | "he" if params.is_null() && app.session.state().is_ok() => { crate::hatchdlg::open_edit(app); Ok(Value::Null) diff --git a/crates/ui-egui/tests/alias_dialog.rs b/crates/ui-egui/tests/alias_dialog.rs new file mode 100644 index 00000000..f37779d1 --- /dev/null +++ b/crates/ui-egui/tests/alias_dialog.rs @@ -0,0 +1,219 @@ +//! ALIASEDIT typed or started from a menu opens the Command Aliases dialog (#1381): the user's +//! aliases next to the built-in ones, Add, Edit and Remove, and OK, Cancel and Apply. JSON calls +//! keep the JSON form. The aliases are process-wide, so these tests run one at a time and use +//! their own names. + +use std::sync::Mutex; + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::resolve_alias; +use cadcraft_ui_egui::aliasdlg::Form; +use cadcraft_ui_egui::{CadApp, Services, dialogs, theme}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +static ONE_AT_A_TIME: Mutex<()> = Mutex::new(()); + +fn harness(app: CadApp, render: bool) -> Harness<'static, CadApp> { + let builder = Harness::builder().with_size(egui::vec2(900.0, 700.0)); + let builder = if render { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( + |ui, app: &mut CadApp| { + theme::apply(ui.ctx(), egui::Theme::Dark, false); + dialogs::show(app, ui.ctx()); + }, + app, + ); + h.run_steps(3); + h +} + +fn opened() -> CadApp { + let mut app = CadApp::new(Session::new(), Services::default()); + app.cmdline("aliasedit"); + app +} + +fn fill(h: &mut Harness<'_, CadApp>, alias: &str, command: &str) { + let form = h.state_mut().alias_dlg.as_mut().and_then(|s| s.form.as_mut()).expect("the alias form"); + form.alias = alias.into(); + form.command = command.into(); + h.run_steps(2); + h.get_by_label("Save alias").click(); + h.run_steps(2); +} + +fn user(h: &Harness<'_, CadApp>) -> Vec<(String, String)> { + h.state().alias_dlg.as_ref().map(|s| s.user.clone().into_iter().collect()).unwrap_or_default() +} + +#[test] +fn typed_and_menu_aliasedit_open_the_dialog_but_json_does_not() { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let mut app = CadApp::new(Session::new(), Services::default()); + for typed in ["aliasedit", "ALIASEDIT"] { + app.ui.dialog = None; + app.cmdline(typed); + assert_eq!(app.ui.dialog.as_deref(), Some("aliasedit"), "{typed}"); + } + app.ui.dialog = None; + app.start("aliasedit"); + assert_eq!(app.ui.dialog.as_deref(), Some("aliasedit"), "menu"); + app.ui.dialog = None; + let listing = app.run("aliasedit", json!({})).unwrap(); + assert!(listing.get("aliases").is_some()); + app.cmdline("aliasedit {}"); + assert_eq!(app.ui.dialog, None); +} + +#[test] +fn add_then_ok_defines_the_alias_and_cancel_drops_it() { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let mut h = harness(opened(), false); + h.get_by_label("Add…").click(); + h.run_steps(2); + fill(&mut h, "ZZAQ", "qsave"); + assert!(user(&h).contains(&("zzaq".into(), "qsave".into()))); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(h.state().ui.dialog, None); + assert_eq!(resolve_alias("zzaq"), "zzaq", "Cancel applies nothing"); + + h.state_mut().cmdline("aliasedit"); + h.run_steps(2); + h.get_by_label("Add…").click(); + h.run_steps(2); + fill(&mut h, "zzaq", "QSAVE"); + h.get_by_label("Apply").click(); + h.run_steps(2); + assert_eq!(resolve_alias("zzaq"), "qsave", "Apply applies and keeps the dialog"); + assert_eq!(h.state().ui.dialog.as_deref(), Some("aliasedit")); + h.state_mut().alias_dlg.as_mut().unwrap().selected = Some("zzaq".into()); + h.run_steps(2); + h.get_by_label("Edit…").click(); + h.run_steps(2); + fill(&mut h, "zzaq", "circle"); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(h.state().ui.dialog, None); + assert_eq!(resolve_alias("zzaq"), "circle"); + h.state_mut().run("aliasedit", json!({ "remove": ["zzaq"] })).unwrap(); +} + +#[test] +fn aliasedit_typed_while_open_keeps_the_changes_not_applied() { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let mut h = harness(opened(), false); + h.get_by_label("Add…").click(); + h.run_steps(2); + fill(&mut h, "zzkeep", "line"); + h.state_mut().cmdline("aliasedit"); + h.run_steps(2); + assert!(user(&h).contains(&("zzkeep".into(), "line".into()))); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(resolve_alias("zzkeep"), "zzkeep"); +} + +#[test] +fn remove_is_offered_for_user_aliases_only() { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("aliasedit", json!({ "set": { "zzrm": "move" } })).unwrap(); + app.cmdline("aliasedit"); + let mut h = harness(app, false); + // L is built in (LINE): the dialog lists it but can't remove it. + h.state_mut().alias_dlg.as_mut().unwrap().selected = Some("l".into()); + h.run_steps(2); + assert!(h.get_by_label("Remove").accesskit_node().is_disabled(), "Remove on a built-in alias"); + h.state_mut().alias_dlg.as_mut().unwrap().selected = Some("zzrm".into()); + h.run_steps(2); + assert!(!h.get_by_label("Remove").accesskit_node().is_disabled()); + // A selected row the filter hides is not edited or removed. + h.state_mut().alias_dlg.as_mut().unwrap().filter = "qsave".into(); + h.run_steps(2); + assert!(h.get_by_label("Remove").accesskit_node().is_disabled(), "Remove on a hidden row"); + assert!(h.get_by_label("Edit…").accesskit_node().is_disabled(), "Edit on a hidden row"); + h.state_mut().alias_dlg.as_mut().unwrap().filter.clear(); + h.run_steps(2); + h.get_by_label("Remove").click(); + h.run_steps(2); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(resolve_alias("zzrm"), "zzrm"); + assert_eq!(resolve_alias("l"), "line"); +} + +#[test] +fn edit_on_a_built_in_alias_overrides_it() { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let mut h = harness(opened(), false); + h.state_mut().alias_dlg.as_mut().unwrap().selected = Some("l".into()); + h.run_steps(2); + h.get_by_label("Edit…").click(); + h.run_steps(2); + let form = h.state().alias_dlg.as_ref().unwrap().form.clone().unwrap(); + assert_eq!((form.alias.as_str(), form.command.as_str()), ("L", "LINE")); + fill(&mut h, "L", "pline"); + h.get_by_label("OK").click(); + h.run_steps(2); + assert_eq!(resolve_alias("l"), "pline"); + h.state_mut().run("aliasedit", json!({ "remove": ["l"] })).unwrap(); + assert_eq!(resolve_alias("l"), "line"); +} + +#[test] +fn refused_aliases_stay_out_of_the_list_and_say_why() { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let mut h = harness(opened(), false); + h.get_by_label("Add…").click(); + h.run_steps(2); + fill(&mut h, "zzdup", "line"); + let before = user(&h); + for (alias, command, why) in [ + ("", "line", "Type an alias"), + (" ", "line", "Type an alias"), + ("LINE", "circle", "is a command name"), + ("zz q", "line", "can't be an alias"), + ("zz{", "line", "can't be an alias"), + ("zzdup", "circle", "already defined"), + ("zznew", "nosuchcommand", "unknown command"), + ("zznew", "", "Choose the command"), + ] { + h.state_mut().alias_dlg.as_mut().unwrap().form = Some(Form::default()); + fill(&mut h, alias, command); + assert_eq!(user(&h), before, "{alias:?} → {command:?}"); + assert!(h.state().alias_dlg.as_ref().unwrap().form.is_some(), "the form stays open for {alias:?}"); + assert!(h.query_by_label_contains(why).is_some(), "{alias:?} → {command:?}: no message containing {why:?}"); + } + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert_eq!(resolve_alias("zzdup"), "zzdup"); +} + +#[test] +fn the_dialog_names_the_alias_file_reinit_reads() { + let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("cadcraft-aliasdlg-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("mine.pgp"); + std::fs::write(&file, "ZZFC, *CIRCLE\n").unwrap(); + let mut app = CadApp::new(Session::new(), Services::default()); + app.run("aliasedit", json!({ "file": file.to_string_lossy() })).unwrap(); + app.cmdline("aliasedit"); + let directory = std::env::var_os("CADCRAFT_ALIAS_SHOTS").map(std::path::PathBuf::from); + let mut h = harness(app, directory.is_some()); + assert!(h.query_by_label_contains("mine.pgp").is_some(), "the loaded file is shown"); + assert!(user(&h).contains(&("zzfc".into(), "circle".into()))); + if let Some(directory) = directory { + let st = h.state_mut().alias_dlg.as_mut().unwrap(); + st.selected = Some("zzfc".into()); + st.filter = "circle".into(); + h.run_steps(3); + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(directory.join("cadcraft-aliasedit.png")).unwrap(); + } + h.state_mut().run("aliasedit", json!({ "clear": true })).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/gaps.md b/docs/gaps.md index bd212442..e0377307 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where CADCraft falls short of AutoCAD -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (MOCORO landed: E3 narrowed) · **Target:** Autodesk AutoCAD 2027 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Command Aliases dialog for ALIASEDIT: 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, alias editor, ViewCube drag (#7) | Low-medium | 15–25 | +| U12 | Workspaces, navigation bar, CUI customisation, ViewCube drag (#7) (the Command Aliases dialog landed, #1381) | 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): Visual LISP ActiveX (`vlax-`, `vla-`), DCL dialogs, reactors, an APPLOAD dialog with a Startup Suite, UCS-aware `trans` | 30–50 | | 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 and alias (`.pgp`) editor | 15–25 | +| E5 | CUI customisation (the alias editor, ALIASEDIT's Command Aliases dialog, landed, #1381; edits stay for the session) | 13–23 | ## AI features @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | minor | ALIASEDIT typed or from Tools ▸ Customize opens the Command Aliases dialog (user aliases next to the built-in ones; Add, Edit, Remove; OK, Cancel, Apply), #1381: U12 and E5 narrowed | | 2026-10-16 | minor | MOCORO (Tools ▸ Express Tools ▸ Move/Copy/Rotate): select objects and a base point once, then Move, Copy, Rotate, Scale, Base and Undo until eXit, one undo step; JSON form `{base, ops}`: E3 narrowed | | 2026-10-16 | minor | MLEDIT Merged Tee (MT): the first multiline's outer elements stop at the second one's near side, which opens, and its inner elements run on to the second one's inner element: F21 narrowed, hours unchanged | | 2026-10-15 | minor | BURST (Tools ▸ Express Tools ▸ Explode Attributes to Text): block references explode as EXPLODE does, visible attribute values become text with the attribute's style, height, position, layer and colour, invisible attributes and the definitions go: E3 narrowed | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6776f70f..80481f15 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 | no | +| Customisation (CUI, aliases editor, tool palettes) | yes | Command Aliases dialog (ALIASEDIT, #1381); 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) | @@ -207,6 +207,7 @@ down-arrow options menu (DYNPROMPT), and DYNDIM. | 2026-10-14 | minor | Selection cycling (#1143): the list and badge of SELECTIONCYCLING, Shift+Space through overlapping objects (#1145) | | 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-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) | | 2026-10-12 | minor | Animation effects (dialog and menu fades, smoothed wheel scrolling and ⌘/Ctrl+wheel zoom) follow the operating system's setting; View ▸ Accessibility ▸ Animation Effects chooses System, On or Off |