From 5cfe1a41492698a82ef600b93e3e73de97da2565 Mon Sep 17 00:00:00 2001 From: Nicholas Gower Date: Fri, 23 Oct 2026 22:57:06 -0700 Subject: [PATCH] appload: load a .lsp file from a path or the file picker (#1903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * appload: load a .lsp file from a path or the file picker (#1820) APPLOAD had no user interface: typed or chosen from Tools > Load Application it started a command-line prompt instead of a file browser, and the JSON form did not say which commands the file defined. - The desktop's APPLOAD opens an rfd file browser filtered to .lsp, starting in the folder the last routine came from (kept in the preferences as lispDir). A cancelled picker is a no-op; the web (no native dialog) keeps the engine's name prompt. - The JSON form `appload {path}` loads to the end, never opens a dialog and now returns `commands`, the `c:` commands the file defined; a non-.lsp path is refused and unreadable or unparsable files return an ordinary error. - Tests: a synthetic .lsp in a temp folder loads by path and its command runs; the picker path, its cancelled answer and the remembered folder; refused, missing and broken files. * appload: load a picked file interactively; keep path whitespace; web hint Reviewer follow-ups on #1820: - A picked file now loads through the interactive APPLOAD command (started and answered with the picked path, as a typed name is), so a routine it starts that calls getpoint or another get… waits for the user instead of receiving nil. JSON calls keep the blocking run. - A JSON `path` is read as given: only a path of blanks is refused, so a relative name with leading or trailing spaces is not trimmed into another file. - The web has no file system, so APPLOAD no longer asks for a file name it cannot read: it says to drop a .lsp file on the window, which loads the file's contents. The host declares this through Services::no_file_system. Tests: a picked file whose load asks for a point waits for it; a relative path in a folder whose name starts with a space loads; on a host without a file system APPLOAD gives the drop hint instead of prompting. --------- Co-authored-by: Brandon Thomas --- apps/cadcraft-web/src/main.rs | 6 +- apps/cadcraft/src/main.rs | 7 + crates/engine/src/cmd/lisp.rs | 23 ++- crates/engine/src/lisp/mod.rs | 14 ++ crates/engine/src/lisp/tests.rs | 57 ++++++++ crates/ui-egui/src/exchange.rs | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/lib.rs | 16 ++ crates/ui-egui/src/menus.rs | 24 +++ crates/ui-egui/src/opening.rs | 2 +- crates/ui-egui/tests/appload_dialog.rs | 162 +++++++++++++++++++++ crates/ui-egui/tests/it/dialog_dispatch.rs | 1 + docs/autolisp.md | 14 +- docs/gaps.md | 3 +- 14 files changed, 320 insertions(+), 11 deletions(-) create mode 100644 crates/ui-egui/tests/appload_dialog.rs diff --git a/apps/cadcraft-web/src/main.rs b/apps/cadcraft-web/src/main.rs index 210ffd9f..035e1d6c 100644 --- a/apps/cadcraft-web/src/main.rs +++ b/apps/cadcraft-web/src/main.rs @@ -90,9 +90,11 @@ mod web { } /// No file picker on the web: Save / Save As from the menu (and the close prompt) download the - /// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. + /// drawing under its current name; `saveas {"path": "name.dxf"}` picks another name. There is + /// no file system either, so a dropped file is opened from its contents and APPLOAD says to + /// drop a `.lsp` file rather than asking for a name it cannot read. fn services() -> Services { - Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), ..Services::default() } + Services { pick_save: Some(Box::new(|name: &str| Some(name.to_string()))), no_file_system: true, ..Services::default() } } pub fn start() { diff --git a/apps/cadcraft/src/main.rs b/apps/cadcraft/src/main.rs index 50131191..e80b838b 100644 --- a/apps/cadcraft/src/main.rs +++ b/apps/cadcraft/src/main.rs @@ -143,6 +143,13 @@ fn services() -> Services { .save_file() .map(|p| p.to_string_lossy().to_string()) })), + // APPLOAD: AutoLISP routines only, starting where the last one was loaded from. + pick_lisp: Some(Box::new(|dir: &str| { + let dialog = rfd::FileDialog::new().add_filter(i18n::t("AutoLISP (LSP)"), &["lsp"]).add_filter(i18n::t("All files"), &["*"]); + let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) }; + dialog.pick_file().map(|p| p.to_string_lossy().to_string()) + })), + no_file_system: false, } } diff --git a/crates/engine/src/cmd/lisp.rs b/crates/engine/src/cmd/lisp.rs index edb0b409..b9d307a3 100644 --- a/crates/engine/src/cmd/lisp.rs +++ b/crates/engine/src/cmd/lisp.rs @@ -12,7 +12,7 @@ pub fn specs() -> Vec { CommandSpec::new("appload", "Load Application...", run_appload) .menu(&["Tools", "Load Application..."]) .alias(&["ap"]) - .params("{path: .lsp file} | {name, data: base64 | text} → {value?, error?}") + .params("{path: .lsp file} | {name, data: base64 | text} → {value?, commands: [commands the file defined], error?}") .noundo() .interactive(|_| Ok(Box::new(ApploadM))), CommandSpec::new("lisp", "Evaluate LISP", run_lisp) @@ -23,6 +23,11 @@ pub fn specs() -> Vec { ] } +/// Whether `path` names an AutoLISP routine: APPLOAD loads `.lsp` files only, in any case. +fn is_lisp_path(path: &str) -> bool { + std::path::Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("lsp")) +} + /// The text of a LISP file from `{path}` or `{name, data | text}`, and its name. fn source(p: &Value) -> Result<(String, String)> { if let Some(text) = str_param(p, "text") { @@ -32,7 +37,14 @@ fn source(p: &Value) -> Result<(String, String)> { let bytes = super::file::base64_decode(data).ok_or_else(|| bad("appload", "invalid base64"))?; return Ok((str_param(p, "name").unwrap_or("untitled.lsp").to_string(), String::from_utf8_lossy(&bytes).into_owned())); } - let path = str_param(p, "path").ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?; + // The path is read as it is given (a JSON caller may name a file or folder with spaces); + // only a path of blanks alone is refused. + let path = str_param(p, "path") + .filter(|p| !p.trim().is_empty()) + .ok_or_else(|| bad("appload", "`path`, or `name` with `data` (base64) or `text`, is required"))?; + if !is_lisp_path(path) { + return Err(bad("appload", format!("{path}: not an AutoLISP file (APPLOAD loads .lsp files)"))); + } let text = crate::lisp::builtins::read_text_file(path).ok_or_else(|| bad("appload", format!("{path}: can't read the file")))?; Ok((path.to_string(), text)) } @@ -49,9 +61,14 @@ fn report(s: &Session, from_log: usize, r: Option Result { let (name, text) = source(p)?; + let before = s.lisp_commands(); let from = s.log.len(); let r = s.lisp_load(&name, &text, true)?; - Ok(report(s, from, r)) + let mut out = report(s, from, r); + // The commands the file defined, so a caller can run them next (a failed file may still + // have defined some before the error). + out["commands"] = json!(s.lisp_commands().into_iter().filter(|c| !before.contains(c)).collect::>()); + Ok(out) } /// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter. diff --git a/crates/engine/src/lisp/mod.rs b/crates/engine/src/lisp/mod.rs index 95bc556a..d596fa93 100644 --- a/crates/engine/src/lisp/mod.rs +++ b/crates/engine/src/lisp/mod.rs @@ -577,6 +577,20 @@ impl Session { self.lisp.active } + /// The commands the active drawing's LISP defines (`c:` functions), uppercase and sorted. + pub fn lisp_commands(&self) -> Vec { + let Ok(uid) = self.state().map(|s| s.uid) else { return Vec::new() }; + let Some(env) = self.lisp.envs.get(&uid) else { return Vec::new() }; + let mut names: Vec = env + .globals + .iter() + .filter(|(name, f)| name.name().starts_with("C:") && matches!(f, Value::Func(_) | Value::Subr(_))) + .filter_map(|(name, _)| name.name().strip_prefix("C:").filter(|n| !n.is_empty()).map(str::to_string)) + .collect(); + names.sort(); + names + } + /// A `c:` function for a command name typed at the command line. pub(crate) fn lisp_command(&self, name: &str) -> Option { if self.lisp.active { diff --git a/crates/engine/src/lisp/tests.rs b/crates/engine/src/lisp/tests.rs index f7ec79ec..820a9812 100644 --- a/crates/engine/src/lisp/tests.rs +++ b/crates/engine/src/lisp/tests.rs @@ -542,6 +542,63 @@ fn appload_and_load() { assert!(r["error"].as_str().is_some_and(|e| e.contains("bad argument type"))); } +#[test] +fn appload_reads_a_lsp_path_and_reports_its_commands() { + let dir = std::env::temp_dir().join(format!("cadcraft-appload-lisp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Tools.LSP"); + std::fs::write(&path, "(defun c:hi () (princ \"hi\") (princ)) (defun c:bye () (princ))").unwrap(); + let mut s = session(); + let r = s.execute("appload", &serde_json::json!({"path": path.to_string_lossy()})).unwrap(); + assert!(r.get("error").is_none(), "{r}"); + assert_eq!(r["commands"], serde_json::json!(["BYE", "HI"])); + assert!(s.log.iter().any(|l| l == "Tools.LSP successfully loaded.")); + s.cmdline("HI").unwrap(); + assert_eq!(s.log.last().map(String::as_str), Some("hi")); + // Only `.lsp` files are APPLOADed, whatever the case of the extension. + let txt = dir.join("notes.txt"); + std::fs::write(&txt, "(defun c:no () (princ))").unwrap(); + let e = s.execute("appload", &serde_json::json!({"path": txt.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains(".lsp"), "{e}"); + // A missing file and a file that fails to parse are ordinary errors. + let missing = dir.join("missing.lsp"); + let e = s.execute("appload", &serde_json::json!({"path": missing.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("can't read"), "{e}"); + let broken = dir.join("broken.lsp"); + std::fs::write(&broken, "(defun c:oops (").unwrap(); + let e = s.execute("appload", &serde_json::json!({"path": broken.to_string_lossy()})).unwrap_err().to_string(); + assert!(!e.is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn appload_keeps_the_whitespace_of_a_relative_path() { + /// A folder in the test's current directory, removed even when an assertion fails. + struct TempDir(std::path::PathBuf); + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + // A JSON call's path is read as it is given: a relative path whose folder starts with a + // space names that folder, not the one its trimmed name would. + let dir = TempDir(std::path::PathBuf::from(format!(" cadcraft-appload-space-{}", std::process::id()))); + let _ = std::fs::remove_dir_all(&dir.0); + std::fs::create_dir_all(&dir.0).unwrap(); + let path = dir.0.join("tools.lsp"); + std::fs::write(&path, "(setq space-loaded 1)").unwrap(); + let mut s = session(); + let r = s.execute("appload", &serde_json::json!({"path": path.to_string_lossy()})).unwrap(); + assert!(r.get("error").is_none(), "{r}"); + assert_eq!(ok(&mut s, "space-loaded"), "1"); + // A path of blanks only is still refused, not read as "". + for blank in ["", " ", "\t "] { + let e = s.execute("appload", &serde_json::json!({"path": blank})).unwrap_err().to_string(); + assert!(e.contains("`path`"), "{blank:?}: {e}"); + } +} + #[test] fn hostile_lisp_never_panics() { let mut s = session(); diff --git a/crates/ui-egui/src/exchange.rs b/crates/ui-egui/src/exchange.rs index d78464b5..7ece5425 100644 --- a/crates/ui-egui/src/exchange.rs +++ b/crates/ui-egui/src/exchange.rs @@ -176,6 +176,7 @@ mod tests { b.borrow_mut().push(name.to_string()); None })), + ..Services::default() }; (CadApp::new(Session::new(), services), asked) } diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 70ec74cd..25037305 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -255,6 +255,7 @@ Drawing Template (DWT) Шаблон креслення (DWT) Drawing Utilities Утиліти креслення Drawings (DWG, DXF) Креслення (DWG, DXF) + AutoLISP (LSP) AutoLISP (LSP) SVG drawings (SVG, SVGZ) Креслення SVG (SVG, SVGZ) Dynamic Динамічно Dynamic Dimensions Динамічні розміри diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9f3204b5..087e5cbf 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -109,6 +109,8 @@ pub struct UiState { pub save_version: String, /// Drawings opened from a path, newest first (File ▸ Open Recent and the Start page). pub recent_files: Vec, + /// The folder the last APPLOAD file came from: the next pick starts there. + pub last_lisp_dir: Option, } /// A DXF/DWG file version (2000 … 2018). @@ -180,6 +182,7 @@ impl Default for UiState { save_format: SaveFormat::default(), save_version: SaveVersion::default().year().to_string(), recent_files: Vec::new(), + last_lisp_dir: None, } } } @@ -189,6 +192,13 @@ impl Default for UiState { pub struct Services { pub pick_open: Option Option>>, pub pick_save: Option Option>>, + /// Pick a `.lsp` file for APPLOAD, starting in the given folder (empty for the default one). + /// `None` where there is no native file dialog: the desktop without one asks for the name, + /// and the web (no file system) says to drop the file (`no_file_system`). + pub pick_lisp: Option Option>>, + /// 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, } pub struct CadApp { @@ -383,6 +393,9 @@ impl CadApp { if !self.ui.recent_files.is_empty() { prefs["recentFiles"] = json!(self.ui.recent_files); } + if let Some(dir) = &self.ui.last_lisp_dir { + prefs["lispDir"] = json!(dir); + } prefs.to_string() } @@ -410,6 +423,9 @@ impl CadApp { if let Some(files) = v.get("recentFiles").and_then(Value::as_array) { self.ui.recent_files = files.iter().filter_map(Value::as_str).filter(|p| !p.is_empty()).take(RECENT_FILES).map(str::to_string).collect(); } + 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()); + } } pub fn set_status(&mut self, s: impl Into) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e72195e1..8a4476e3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -156,6 +156,29 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option { + if app.services.no_file_system { + let hint = "APPLOAD: there is no file system here; drop a .lsp file on the window to load it"; + app.session.echo(hint.to_string()); + app.set_status(hint); + return Some(Ok(Value::Null)); + } + let pick = app.services.pick_lisp.as_ref()?; + let dir = app.ui.last_lisp_dir.clone().unwrap_or_default(); + let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) }; + if let Some(dir) = std::path::Path::new(&path).parent().and_then(|p| p.to_str()).filter(|d| !d.is_empty()) { + app.ui.last_lisp_dir = Some(dir.to_string()); + } + if let Err(e) = app.session.start("appload") { + return Some(Err(e.to_string())); + } + app.cmdline(&path); + Ok(Value::Null) + } // Pick a template (.dwt or .dxf) and start a new drawing from it. "ui.newtemplate" => { if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) { @@ -734,6 +757,7 @@ mod tests { log.borrow_mut().push(name.to_string()); answer.clone() })), + ..Services::default() }; (CadApp::new(Session::new(), services), asked) } diff --git a/crates/ui-egui/src/opening.rs b/crates/ui-egui/src/opening.rs index 4db230c5..a839850d 100644 --- a/crates/ui-egui/src/opening.rs +++ b/crates/ui-egui/src/opening.rs @@ -110,7 +110,7 @@ mod tests { /// cannot be read is reported like a failed OPEN without adding a drawing. #[test] fn open_path_reads_in_the_background_and_reports_failure() { - let mut app = CadApp::new(Session::new(), Services { pick_open: None, pick_save: None }); + let mut app = CadApp::new(Session::new(), Services::default()); let before = app.session.docs.len(); let missing = std::env::temp_dir().join(format!("cadcraft-missing-{}.dxf", std::process::id())); let p = missing.to_string_lossy().to_string(); diff --git a/crates/ui-egui/tests/appload_dialog.rs b/crates/ui-egui/tests/appload_dialog.rs new file mode 100644 index 00000000..07565e22 --- /dev/null +++ b/crates/ui-egui/tests/appload_dialog.rs @@ -0,0 +1,162 @@ +//! APPLOAD (#1820): typed or chosen from the menu it picks a `.lsp` file in the desktop's file +//! dialog; a JSON call carries its path and never opens one; a load that fails reports an error +//! on the command line. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; + +use cadcraft_engine::Session; +use cadcraft_ui_egui::{CadApp, Services}; +use serde_json::json; + +fn temp_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("cadcraft-appload-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("a temporary directory"); + dir +} + +fn write(dir: &Path, name: &str, text: &str) -> String { + let path = dir.join(name); + std::fs::write(&path, text).expect("a test file"); + path.to_string_lossy().to_string() +} + +/// An app whose `.lsp` picker answers `answer` and records the directory it was given. +fn app_with_picker(answer: Option) -> (CadApp, Rc>>) { + let asked = Rc::new(RefCell::new(Vec::new())); + let log = Rc::clone(&asked); + let services = Services { + pick_lisp: Some(Box::new(move |dir: &str| { + log.borrow_mut().push(dir.to_string()); + answer.clone() + })), + ..Services::default() + }; + (CadApp::new(Session::new(), services), asked) +} + +#[test] +fn a_json_path_loads_the_file_and_lists_its_commands_and_never_picks() { + let dir = temp_dir("json"); + let path = write(&dir, "tools.lsp", "(defun c:hi () (princ \"hi\") (princ)) (defun c:bye () (princ))"); + let (mut app, asked) = app_with_picker(Some(path.clone())); + + let r = app.run("appload", json!({ "path": path })).unwrap(); + assert!(r.get("error").is_none(), "{r}"); + assert_eq!(r["commands"], json!(["BYE", "HI"])); + assert!(asked.borrow().is_empty(), "a JSON call never opens a file dialog"); + assert!(app.session.log.iter().any(|l| l == "tools.lsp successfully loaded."), "{:?}", app.session.log); + app.session.cmdline("HI").unwrap(); + assert_eq!(app.session.log.last().map(String::as_str), Some("hi")); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn refused_and_failed_loads_are_ordinary_errors_and_never_pick() { + let dir = temp_dir("errors"); + let (mut app, asked) = app_with_picker(Some(write(&dir, "picked.lsp", ""))); + + // A file that is not a LISP routine is refused before it is read. + let text = write(&dir, "notes.txt", "(defun c:no () (princ))"); + let e = app.run("appload", json!({ "path": text })).unwrap_err(); + assert!(e.contains(".lsp"), "{e}"); + assert!(app.session.log.iter().any(|l| l.contains(".lsp")), "reported on the command line: {:?}", app.session.log); + + // A missing file is an ordinary error, not a crash. + let missing = dir.join("missing.lsp").to_string_lossy().to_string(); + let e = app.run("appload", json!({ "path": missing })).unwrap_err(); + assert!(e.contains("can't read"), "{e}"); + + // A file that does not parse is reported too. + let broken = write(&dir, "broken.lsp", "(defun c:oops ("); + let e = app.run("appload", json!({ "path": broken })).unwrap_err(); + assert!(!e.is_empty()); + assert!(app.session.log.iter().any(|l| l.contains("error")), "{:?}", app.session.log); + + assert!(asked.borrow().is_empty(), "failed loads never open a file dialog"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn typed_appload_opens_the_picker_and_remembers_the_directory() { + let dir = temp_dir("pick"); + let path = write(&dir, "tools.lsp", "(defun c:hi () (princ \"hi\") (princ))"); + let (mut app, asked) = app_with_picker(Some(path)); + + app.cmdline("appload"); + assert_eq!(*asked.borrow(), vec![String::new()], "the first pick starts in the default directory"); + assert!(app.session.log.iter().any(|l| l == "tools.lsp successfully loaded."), "{:?}", app.session.log); + app.session.cmdline("HI").unwrap(); + assert_eq!(app.session.log.last().map(String::as_str), Some("hi")); + let dir_text = dir.to_string_lossy().to_string(); + assert_eq!(app.ui.last_lisp_dir.as_deref(), Some(dir_text.as_str())); + + // The directory is kept in the preferences and given to the picker next time (its alias too). + let prefs = app.prefs_json(); + let (mut restarted, asked_again) = app_with_picker(None); + restarted.load_prefs(&prefs); + restarted.start("ap"); + assert_eq!(*asked_again.borrow(), vec![dir_text]); + assert!(!restarted.session.prompting(), "a cancelled picker does nothing"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_picked_file_that_asks_for_a_point_waits_for_the_user() { + let dir = temp_dir("interactive"); + // Loading asks for a point: a picked file runs as a typed APPLOAD does, so the routine + // waits for the answer instead of being handed nil. + let path = write(&dir, "ask.lsp", "(setq picked (getpoint \"\\nPick a point: \")) (princ picked)"); + let (mut app, asked) = app_with_picker(Some(path)); + + app.cmdline("appload"); + assert_eq!(asked.borrow().len(), 1); + assert!(app.session.lisp_running(), "the load waits for the point instead of getting nil"); + assert_eq!(app.session.prompt_text(), "Pick a point: "); + app.cmdline("3,4"); + assert!(!app.session.lisp_running()); + assert!(app.session.lisp_var("PICKED").as_point().is_some(), "the picked point reached the routine"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn where_there_is_no_file_system_appload_says_to_drop_the_file() { + // The web has no file system, so a name typed at the command line could never be read: + // APPLOAD says what works there (dropping the file on the window) instead of asking. + let mut app = CadApp::new(Session::new(), Services { no_file_system: true, ..Services::default() }); + app.cmdline("appload"); + assert!(!app.session.prompting(), "it never asks for a name it cannot read"); + assert!(app.session.log.iter().any(|l| l.contains("drop a .lsp file")), "{:?}", app.session.log); + assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("drop a .lsp file")), "{:?}", app.status); +} + +#[test] +fn a_cancelled_picker_is_a_no_op() { + let (mut app, asked) = app_with_picker(None); + let before = app.session.log.len(); + app.start("appload"); + assert_eq!(asked.borrow().len(), 1); + assert_eq!(app.session.log.len(), before, "a cancelled picker says nothing"); + assert!(!app.session.prompting()); + assert!(app.ui.last_lisp_dir.is_none()); +} + +#[test] +fn without_a_picker_appload_asks_for_the_name_on_the_command_line() { + let dir = temp_dir("prompt"); + let path = write(&dir, "tools.lsp", "(defun c:hi () (princ \"hi\") (princ))"); + // A desktop host with no file picker: the engine's prompt is the fallback (the web says to + // drop the file instead, since it cannot read a path). + let mut app = CadApp::new(Session::new(), Services::default()); + app.start("appload"); + assert!(app.session.prompting()); + assert!(app.session.prompt_text().contains("Enter name of LISP file to load"), "{}", app.session.prompt_text()); + app.cmdline(&path); + assert!(!app.session.prompting()); + assert!(app.session.log.iter().any(|l| l == "tools.lsp successfully loaded."), "{:?}", app.session.log); + app.session.cmdline("HI").unwrap(); + assert_eq!(app.session.log.last().map(String::as_str), Some("hi")); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/tests/it/dialog_dispatch.rs b/crates/ui-egui/tests/it/dialog_dispatch.rs index 05f725b4..df376a2d 100644 --- a/crates/ui-egui/tests/it/dialog_dispatch.rs +++ b/crates/ui-egui/tests/it/dialog_dispatch.rs @@ -20,6 +20,7 @@ fn app() -> (CadApp, Rc>) { b.set(b.get() + 1); None })), + ..Services::default() }; (CadApp::new(Session::new(), services), picks) } diff --git a/docs/autolisp.md b/docs/autolisp.md index 960fbf61..8dd79129 100644 --- a/docs/autolisp.md +++ b/docs/autolisp.md @@ -5,8 +5,13 @@ CADCraft runs AutoLISP routines (`.lsp` files). What is here and what is not yet ## Loading and running -- **APPLOAD** (Tools > Load Application…, alias `AP`) asks for a `.lsp` file and runs it. Dropping - a `.lsp` file on the window does the same. +- **APPLOAD** (Tools > Load Application…, alias `AP`) opens a file browser filtered to `.lsp` + files, starting in the folder the last one came from (kept in the preferences), and runs the + chosen file. Cancelling the browser does nothing. A picked file loads as a typed name does, so a + routine it starts that asks for a point waits for it. A desktop without a file browser asks for + the name on the command line; the web has no file system, so it says to drop the `.lsp` file on + the window and loads it from its contents. Only `.lsp` files are loaded; anything else is + refused. - **`(load "name")`** loads a file by path, with `.lsp` added when the name has none. A file APPLOAD loaded is found again by its name, also on the web where there is no file system. - **`(defun c:NAME …)`** defines a command: type `NAME` at the command line. Built-in commands @@ -16,8 +21,9 @@ CADCraft runs AutoLISP routines (`.lsp` files). What is here and what is not yet `CIRCLE 0,0 (* 2 r)`. - **Programmatically** (scripts, the control channel, MCP, the CLI): `lisp {code, inputs?}` and `appload {path} | {name, text | data}` run to the end and return `{value, output, error?}`. - `inputs` answer `get…` calls in order (numbers, strings, `[x, y]` points); a call with none left - gets nil. + `appload` also returns `commands`, the `c:` commands the file defined, and a JSON call never + opens a file dialog. `inputs` answer `get…` calls in order (numbers, strings, `[x, y]` points); + a call with none left gets nil. ### At startup diff --git a/docs/gaps.md b/docs/gaps.md index e0377307..44ed6f22 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -133,7 +133,7 @@ Detail: [localization-parity.md](localization-parity.md). | # | Gap | Est. | |---|---|---| -| 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 | +| E1 | AutoLISP beyond the core (#758 landed the language, `command`, entity lists, selection sets, `get…` input, APPLOAD, `(load)`, `c:` commands and `acad.lsp`/`acaddoc.lsp` at startup; #1820 gave APPLOAD its `.lsp` file picker): Visual LISP ActiveX (`vlax-`, `vla-`), DCL dialogs, reactors, an APPLOAD Startup Suite, UCS-aware `trans` | 26–46 | | E3 | Express Tools equivalents (SUPERHATCH; NCOPY, BREAKLINE, TXT2MTXT, BURST and MOCORO exist: BREAKLINE's Block option takes a block of the drawing, not a `.dwg` file, and TXT2MTXT's Settings are command-line prompts rather than a dialog, without the uniform line spacing option) | 24–40 | | E4 | Plug-in API (owner decision: Rust/WASM plug-ins rather than ObjectARX/.NET) | 30–50 | | E5 | CUI customisation (the alias editor, ALIASEDIT's Command Aliases dialog, landed, #1381; edits stay for the session) | 13–23 | @@ -150,6 +150,7 @@ Detail: [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-22 | minor | APPLOAD typed or from Tools ▸ Load Application opens a `.lsp` file browser starting in the last-used folder (kept in the preferences); a picked file loads interactively like a typed name (routines that ask for input wait), the web says to drop the file (no file system), and the JSON form `{path}` reads the path as given and returns the commands the file defined, never opening a dialog (#1820): E1 narrowed | | 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 |