ALIASEDIT opens the Command Aliases dialog (#1381) (#1391)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Marco Arnulfo
2026-10-19 02:56:52 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent 0d23cc3cb3
commit 41e0860725
12 changed files with 553 additions and 6 deletions
+2
View File
@@ -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};
+3 -2
View File
@@ -43,13 +43,13 @@ pub(crate) fn user_alias(name: &str) -> Option<String> {
}
/// The command id `target` names (`*LINE`, `_.line`, `-LAYER`…), if there is such a command.
fn command_id(target: &str) -> Option<String> {
pub fn command_id(target: &str) -> Option<String> {
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<String, String> {
pub fn alias_name(a: &str) -> std::result::Result<String, String> {
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<Value> {
pub(crate) fn specs() -> Vec<CommandSpec> {
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(),
+278
View File
@@ -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<String>,
}
/// 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<String, String>,
/// The user aliases ALIASEDIT holds now (OK and Apply send the difference).
pub applied: BTreeMap<String, String>,
/// The alias file loaded last, which REINIT reads again.
pub file: Option<String>,
pub selected: Option<String>,
pub filter: String,
pub form: Option<Form>,
pub error: Option<String>,
/// 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<String, String>, Option<String>) {
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<String, String> {
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<String, String>) -> 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<String, Value> = 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;
}
}
+1
View File
@@ -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),
+17
View File
@@ -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)...
1 # CADCraft Italian interface catalog — original translations by Emanuele Bottelli (AI-assisted, Claude).
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
+1
View File
@@ -97,6 +97,7 @@ fn missing_marked_ui_literals(language: &str) -> BTreeSet<String> {
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"),
+17
View File
@@ -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)...
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
+4
View File
@@ -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<gpu::GpuFaults>,
/// The notice shown after one (see [`autosave::gpu_notice`]).
pub gpu_notice: Option<autosave::GpuNotice>,
/// The Command Aliases dialog while open ([`aliasdlg`]).
pub alias_dlg: Option<aliasdlg::AliasDlgState>,
/// The Hatch and Gradient dialog while open or picking ([`hatchdlg`]).
pub hatch_dlg: Option<hatchdlg::HatchDlgState>,
/// 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(),
}
+5
View File
@@ -396,6 +396,11 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
crate::hatchdlg::open_create(app, matches!(id, "gradient" | "gd"));
Ok(Value::Null)
}
// Typed or from the Tools menu: the Command Aliases dialog. JSON calls keep the JSON form.
"aliasedit" if params.is_null() => {
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)
+219
View File
@@ -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);
}
+4 -3
View File
@@ -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 |
+2 -1
View File
@@ -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 |