appload: load a .lsp file from a path or the file picker (#1903)

* 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 <echelon@gmail.com>
This commit is contained in:
Nicholas Gower
2026-10-24 01:57:06 -04:00
committed by GitHub
co-authored by Brandon Thomas
parent 9f2060d674
commit 5cfe1a4149
14 changed files with 320 additions and 11 deletions
+4 -2
View File
@@ -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() {
+7
View File
@@ -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,
}
}
+20 -3
View File
@@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
]
}
/// 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<std::result::Result<Lv, String
fn run_appload(s: &mut Session, p: &Value) -> Result<Value> {
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::<Vec<_>>());
Ok(out)
}
/// A JSON input as a LISP value: numbers, strings, `[x, y(, z)]` points, null for Enter.
+14
View File
@@ -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<String> {
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<String> = 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<Value> {
if self.lisp.active {
+57
View File
@@ -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();
+1
View File
@@ -176,6 +176,7 @@ mod tests {
b.borrow_mut().push(name.to_string());
None
})),
..Services::default()
};
(CadApp::new(Session::new(), services), asked)
}
+1
View File
@@ -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 Динамічні розміри
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
255
256
257
258
259
260
261
+16
View File
@@ -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<String>,
/// The folder the last APPLOAD file came from: the next pick starts there.
pub last_lisp_dir: Option<String>,
}
/// 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<Box<dyn Fn() -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// 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<Box<dyn Fn(&str) -> Option<String>>>,
/// 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<String>) {
+24
View File
@@ -156,6 +156,29 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
}
Ok(Value::Null)
}
// APPLOAD typed or chosen from a menu: the `.lsp` file picker, starting where the last
// one came from. JSON calls carry `path` and never open one. A picked file is loaded as a
// typed name is, through the interactive command, so a routine it starts that asks for a
// point waits for it; on the web (no file system) it says to drop the file instead.
"appload" | "ap" if params.is_null() => {
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)
}
+1 -1
View File
@@ -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();
+162
View File
@@ -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<String>) -> (CadApp, Rc<RefCell<Vec<String>>>) {
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);
}
@@ -20,6 +20,7 @@ fn app() -> (CadApp, Rc<Cell<u32>>) {
b.set(b.get() + 1);
None
})),
..Services::default()
};
(CadApp::new(Session::new(), services), picks)
}
+10 -4
View File
@@ -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
+2 -1
View File
@@ -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 |