mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-29 21:36:52 -06:00
Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Brandon Thomas
Claude Opus 5.5
parent
0d23cc3cb3
commit
41e0860725
@@ -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};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)...
|
||||
|
||||
|
@@ -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"),
|
||||
|
||||
@@ -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)...
|
||||
|
||||
|
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user