mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
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:
co-authored by
Brandon Thomas
parent
9f2060d674
commit
5cfe1a4149
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -176,6 +176,7 @@ mod tests {
|
||||
b.borrow_mut().push(name.to_string());
|
||||
None
|
||||
})),
|
||||
..Services::default()
|
||||
};
|
||||
(CadApp::new(Session::new(), services), asked)
|
||||
}
|
||||
|
||||
@@ -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 Динамічні розміри
|
||||
|
||||
|
@@ -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>) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user