Merge branch 'stk-1907' into stack/s3

# Conflicts:
#	docs/customization.md
This commit is contained in:
Tanish Manku
2026-11-06 13:32:16 -07:00
5 changed files with 295 additions and 7 deletions
+8
View File
@@ -181,6 +181,14 @@ fn services() -> Services {
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
no_file_system: false,
pick_pgp: Some(Box::new(|dir: Option<&str>| {
let dialog = rfd::FileDialog::new().add_filter(i18n::t("Alias files (PGP)"), &["pgp"]);
let dialog = match dir {
Some(dir) => dialog.set_directory(dir),
None => dialog,
};
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
})),
}
}
+263 -1
View File
@@ -33,6 +33,9 @@ pub struct AliasDlgState {
pub filter: String,
pub form: Option<Form>,
pub error: Option<String>,
/// The warnings the last imported file reported (skipped lines, external commands, unknown
/// commands); the first few are shown in the dialog and all of them on the command line.
pub warnings: Vec<String>,
/// ALIASEDIT typed again while open: bring the window to the front on the next frame.
pub raise: bool,
}
@@ -143,6 +146,82 @@ pub fn apply(app: &mut CadApp) -> bool {
}
}
/// Whether `path` names a `.pgp` file, whatever its case.
fn is_pgp(path: &str) -> bool {
std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("pgp"))
}
/// Report a failed import in the dialog (when it is open) and on the command line; returns it so
/// the caller can too.
fn refused(app: &mut CadApp, msg: String) -> String {
app.session.echo(msg.clone());
if let Some(st) = app.alias_dlg.as_mut() {
st.error = Some(msg.clone());
st.warnings.clear();
}
msg
}
/// Load the `.pgp` alias file at `path` through ALIASEDIT, replacing the user aliases as typing
/// `ALIASEDIT {file}` does. The dialog refreshes to what ALIASEDIT holds now, so OK and Apply see
/// no pending difference; the file's warnings are kept for display. Non-`.pgp` and unreadable
/// files are ordinary errors, never a panic.
pub fn import(app: &mut CadApp, path: &str) -> Result<(), String> {
if !is_pgp(path) {
return Err(refused(app, format!("Import: {path} is not a .pgp alias file")));
}
match app.run("aliasedit", json!({ "file": path })) {
Ok(v) => {
let aliases: BTreeMap<String, String> = v
.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();
let file = v.get("file").and_then(Value::as_str).unwrap_or(path).to_string();
let loaded = v.get("loaded").and_then(Value::as_u64).unwrap_or(0);
let warnings: Vec<String> = v
.get("warnings")
.and_then(Value::as_array)
.map(|w| w.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default();
if let Some(dir) = std::path::Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
app.ui.last_pgp_dir = Some(dir.to_string_lossy().to_string());
}
app.session.echo(format!("{loaded} command alias(es) imported from {path}."));
for w in &warnings {
app.session.echo(format!("Import {path}: {w}"));
}
if let Some(st) = app.alias_dlg.as_mut() {
st.user = aliases.clone();
st.applied = aliases;
st.file = Some(file);
st.form = None;
st.selected = None;
st.error = None;
st.warnings = warnings;
}
Ok(())
}
Err(e) => Err(refused(app, e)),
}
}
/// Import…: ask the host for a `.pgp` file (starting in the folder the last import came from),
/// then load it. A cancelled picker is a no-op; without a native picker (the web) the error names
/// the ALIASEDIT `text` form, since the web has no file system to read a path from.
pub fn pick_and_import(app: &mut CadApp) -> Result<(), String> {
let dir = app.ui.last_pgp_dir.clone();
let picked = app.services.pick_pgp.as_ref().map(|pick| pick(dir.as_deref()));
match picked {
None => Err(refused(
app,
crate::tl!("Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter.").to_string(),
)),
Some(None) => Ok(()),
Some(Some(path)) => import(app, &path),
}
}
/// Command Aliases window that edits the in-memory alias set in `app.alias_dlg`; OK/Apply send the differences to ALIASEDIT, read the resulting aliases and source file back, and show any error.
pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if app.alias_dlg.is_none() {
@@ -150,7 +229,7 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
}
let built = built_in();
let warn = Tokens::get().warn;
let (mut ok, mut cancel, mut apply_clicked) = (false, false, false);
let (mut ok, mut cancel, mut apply_clicked, mut import_clicked) = (false, false, false, false);
let Some(st) = app.alias_dlg.as_mut() else { return };
let id = egui::Id::unique("aliasedit_dialog");
if std::mem::take(&mut st.raise) {
@@ -222,6 +301,9 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
{
st.user.remove(s);
}
if ui.button(crate::tl!("Import…")).clicked() {
import_clicked = true;
}
});
let mut save = false;
let mut discard = false;
@@ -262,6 +344,15 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
if let Some(e) = &st.error {
ui.colored_label(warn, e.as_str());
}
if !st.warnings.is_empty() {
for w in st.warnings.iter().take(3) {
ui.colored_label(warn, w.as_str());
}
let more = st.warnings.len().saturating_sub(3);
if more > 0 {
ui.label(crate::tf!("{n} more warnings; see the command line.", n = more));
}
}
ui.separator();
ui.horizontal(|ui| {
ok = ui.button(crate::tl!("OK")).clicked();
@@ -269,6 +360,9 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
apply_clicked = ui.button(crate::tl!("Apply")).clicked();
});
});
if import_clicked {
let _ = pick_and_import(app);
}
if (ok && apply(app)) || cancel {
*open = false;
} else if apply_clicked {
@@ -278,3 +372,171 @@ pub fn dialog(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) {
app.alias_dlg = None;
}
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Mutex, MutexGuard, PoisonError};
use cadcraft_engine::Session;
use serde_json::json;
use crate::{CadApp, Services};
use super::{open, pick_and_import};
/// The alias store is process-wide, so the tests that load files take turns and clear it.
fn alone() -> MutexGuard<'static, ()> {
static LOCK: Mutex<()> = Mutex::new(());
LOCK.lock().unwrap_or_else(PoisonError::into_inner)
}
fn temp_dir(name: &str) -> PathBuf {
static N: AtomicU32 = AtomicU32::new(0);
let dir = std::env::temp_dir().join(format!("cadcraft-alias-{}-{}-{name}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
let _ = std::fs::create_dir_all(&dir);
dir
}
/// An app whose Import picker answers `answer` and records the folder it was asked to start in.
fn app_with_pick(answer: Option<String>) -> (CadApp, Rc<RefCell<Vec<Option<String>>>>) {
let asked = Rc::new(RefCell::new(Vec::new()));
let log = Rc::clone(&asked);
let answer = Rc::new(RefCell::new(answer));
let services = Services {
pick_pgp: Some(Box::new(move |dir: Option<&str>| {
log.borrow_mut().push(dir.map(str::to_string));
answer.borrow().clone()
})),
..Services::default()
};
(CadApp::new(Session::new(), services), asked)
}
fn clear(app: &mut CadApp) {
let _ = app.run("aliasedit", json!({ "clear": true }));
}
#[test]
fn import_loads_the_aliases_shows_the_path_and_warnings() {
let _guard = alone();
let dir = temp_dir("good");
let path = dir.join("aliases.pgp");
std::fs::write(&path, "; our own aliases\nXXL, *LINE\nXXC, *CIRCLE\nNOPE, *NOTACOMMAND\nEXT, notepad.exe\n").unwrap_or_default();
let picked = path.to_string_lossy().to_string();
let (mut app, asked) = app_with_pick(Some(picked.clone()));
open(&mut app);
pick_and_import(&mut app).expect("the file loads");
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user.get("xxl").map(String::as_str), Some("line"));
assert_eq!(st.user.get("xxc").map(String::as_str), Some("circle"));
assert_eq!(st.file.as_deref(), Some(picked.as_str()), "the imported path is shown");
assert_eq!(st.applied, st.user, "an import leaves nothing for Apply to resend");
assert!(st.error.is_none());
assert!(st.warnings.iter().any(|w| w.contains("unknown command")), "unknown commands are reported: {:?}", st.warnings);
assert!(st.warnings.iter().any(|w| w.contains("external command")), "external commands are reported: {:?}", st.warnings);
assert!(app.session.log.iter().any(|l| l.contains("unknown command")), "the command line shows the full warnings");
assert_eq!(app.ui.last_pgp_dir.as_deref(), Some(dir.to_string_lossy().as_ref()), "the import folder is remembered");
assert_eq!(asked.borrow().as_slice(), [None], "the first import has no folder yet");
// The next import starts where the last file was.
open(&mut app);
let _ = pick_and_import(&mut app);
assert_eq!(asked.borrow().as_slice(), [None, Some(dir.to_string_lossy().to_string())]);
clear(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_cancelled_picker_changes_nothing() {
let _guard = alone();
let (mut app, asked) = app_with_pick(None);
open(&mut app);
let before = app.alias_dlg.clone();
pick_and_import(&mut app).expect("cancelling is not an error");
assert_eq!(asked.borrow().len(), 1, "the picker was offered");
assert_eq!(app.alias_dlg, before, "a cancelled picker leaves the aliases, file and errors alone");
clear(&mut app);
}
#[test]
fn non_pgp_and_malformed_files_are_ordinary_errors() {
let _guard = alone();
let dir = temp_dir("bad");
let text = dir.join("aliases.txt");
std::fs::write(&text, "XXL, *LINE\n").unwrap_or_default();
let (mut app, _) = app_with_pick(Some(text.to_string_lossy().to_string()));
open(&mut app);
let before = app.alias_dlg.clone().unwrap_or_default();
let e = pick_and_import(&mut app).expect_err("a .txt file is not an alias file");
assert!(e.contains(".pgp"), "{e}");
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user, before.user, "the refused file changes no alias");
assert_eq!(st.file, before.file);
assert!(st.error.as_deref().is_some_and(|e| e.contains(".pgp")), "{:?}", st.error);
// Malformed lines are warnings with the aliases that did parse, never a panic.
let broken = dir.join("broken.pgp");
std::fs::write(&broken, "not an alias line\nNOSUCH, *NOTACOMMAND\n; comment\nXXL, *LINE\n").unwrap_or_default();
let (mut app, _) = app_with_pick(Some(broken.to_string_lossy().to_string()));
open(&mut app);
pick_and_import(&mut app).expect("malformed lines are warnings");
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user.get("xxl").map(String::as_str), Some("line"));
assert!(st.warnings.len() >= 2, "{:?}", st.warnings);
assert!(st.error.is_none(), "{:?}", st.error);
// A file that cannot be read is an error, reported in the dialog.
let missing = dir.join("missing.pgp");
let (mut app, _) = app_with_pick(Some(missing.to_string_lossy().to_string()));
open(&mut app);
let e = pick_and_import(&mut app).expect_err("a missing file is an ordinary error");
assert!(e.contains("missing.pgp"), "{e}");
assert!(app.alias_dlg.as_ref().is_some_and(|st| st.error.is_some()));
clear(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_last_import_folder_round_trips_through_prefs() {
let mut app = CadApp::new(Session::new(), Services::default());
app.ui.last_pgp_dir = Some("/tmp/our-aliases".into());
let saved = app.prefs_json();
assert!(saved.contains("lastPgpDir"), "{saved}");
let mut restored = CadApp::new(Session::new(), Services::default());
restored.load_prefs(&saved);
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"));
restored.load_prefs(r#"{"lastPgpDir": 3}"#);
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"), "malformed values are ignored");
restored.load_prefs(r#"{"lastPgpDir": ""}"#);
assert_eq!(restored.ui.last_pgp_dir.as_deref(), Some("/tmp/our-aliases"), "an empty value is ignored");
}
#[test]
fn no_picker_points_at_aliasedit_text_without_changing_aliases() {
let mut app = CadApp::new(Session::new(), Services::default());
open(&mut app);
let before = app.alias_dlg.clone();
let e = pick_and_import(&mut app).expect_err("the web has no native picker");
assert!(e.contains("ALIASEDIT"), "{e}");
assert!(e.contains("contents"), "the web is told to give the file's contents: {e}");
assert!(e.contains("text"), "the web is told which parameter carries the contents: {e}");
assert!(!e.contains("path"), "the web has no file system, so a path is not the supported form: {e}");
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user, before.as_ref().map(|b| b.user.clone()).unwrap_or_default());
assert!(st.error.as_deref().is_some_and(|e| e.contains("ALIASEDIT")));
}
}
+4
View File
@@ -1371,6 +1371,10 @@
built-in вбудований
Add… Додати…
Edit… Змінити…
Import… Імпортувати…
Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter. Імпорт: тут немає діалогу вибору файлів; запустіть ALIASEDIT із вмістом файлу в параметрі text.
{n} more warnings; see the command line. Ще {n} попереджень; див. командний рядок.
Alias files (PGP) Файли псевдонімів (PGP)
Save alias Зберегти псевдонім
Discard Скасувати зміни
Customize Налаштування інтерфейсу
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
+11
View File
@@ -133,6 +133,8 @@ pub struct UiState {
pub recent_files: Vec<String>,
/// The folder the last APPLOAD file came from: the next pick starts there.
pub last_lisp_dir: Option<String>,
/// The folder the Command Aliases dialog last imported a `.pgp` file from.
pub last_pgp_dir: Option<String>,
}
/// A DXF/DWG file version (2000 … 2018).
@@ -211,6 +213,7 @@ impl Default for UiState {
save_version: SaveVersion::default().year().to_string(),
recent_files: Vec::new(),
last_lisp_dir: None,
last_pgp_dir: None,
}
}
}
@@ -227,6 +230,8 @@ pub struct Services {
/// The host has no file system to read a path from (the browser): a dropped file's contents
/// are what it loads, and APPLOAD says so instead of asking for a name it cannot read.
pub no_file_system: bool,
/// Pick a `.pgp` alias file, starting in the folder given (the last import's).
pub pick_pgp: Option<Box<dyn Fn(Option<&str>) -> Option<String>>>,
}
pub struct CadApp {
@@ -490,6 +495,9 @@ impl CadApp {
if let Some(dir) = &self.ui.last_lisp_dir {
prefs["lispDir"] = json!(dir);
}
if let Some(dir) = self.ui.last_pgp_dir.as_deref().filter(|d| !d.is_empty()) {
prefs["lastPgpDir"] = json!(dir);
}
prefs.to_string()
}
@@ -523,6 +531,9 @@ impl CadApp {
if let Some(dir) = v.get("lispDir").and_then(Value::as_str).filter(|d| !d.is_empty()) {
self.ui.last_lisp_dir = Some(dir.to_string());
}
if let Some(dir) = v.get("lastPgpDir").and_then(Value::as_str).filter(|d| !d.is_empty()) {
self.ui.last_pgp_dir = Some(dir.to_string());
}
}
/// Make the system cursor the interface default (`on`) and turn it on; saved preferences and
+9 -6
View File
@@ -34,15 +34,18 @@ cadcraft-cli run drawing.dxf --script-file setup.scr --save out.dxf
## Command aliases
ALIASEDIT reads a `.pgp` alias file (`ALIAS, *COMMAND` lines) and adds its aliases. Lines that
would start external programs are skipped. From a script or the control channel:
`aliasedit {"file": "/path/to/aliases.pgp"}`.
ALIASEDIT reads a `.pgp` alias file (`ALIAS, *COMMAND` lines); its aliases replace the ones you
had. Lines that would start external programs are skipped. From a script or the control channel:
`aliasedit {"file": "/path/to/aliases.pgp"}`. The Command Aliases dialog's **Import…** button picks
a `.pgp` file, loads it the same way, shows its path and warnings, and remembers the folder it came
from; on the web (no native file dialog) type `ALIASEDIT` with the file's contents in its `text`
parameter instead.
Aliases are user settings, never part of a drawing. The desktop and web apps keep them, and the
file REINIT reads, in the user profile (`cadcraft.profile`), so aliases added, edited or removed in
file REINIT reads, with your other preferences, so aliases added, edited, imported or removed in
the Command Aliases dialog (or with ALIASEDIT's `set`, `remove` and `clear`) are there after a
restart, and REINIT reads the same file again. The headless CLI has no user profile: there, aliases
last for that run.
restart, and REINIT reads the same file again. The headless CLI keeps no preferences: there,
aliases last for that run.
## Driving CADCraft from another program