Compare commits

..
Author SHA1 Message Date
.CooCoo 74a8a1d2df Fonts: the MTEXT strip test expects a short \U+ escape to keep its digits (#2954)
strips_formatting expected `\U+12` to strip to nothing. plain_mtext
dropped every unknown code up to the next `;` when that assertion was
written. #1036 limits this to the codes in takes_arg, and `\U` is not
one of them, so plain_mtext returns `+12`. The layout and paragraph
paths give the same text, and
unicode_escapes_are_decoded_in_every_parser already expects it.

The test has failed on main since both changes merged, and the failure
stops the gate at its test step. The test module now also checks the
short escape in both layout paths.

(cherry picked from commit 6f0f1fefa8)
2026-11-10 17:26:01 +01:00
Dylan M. Taylor 227e3c385b Release 0.19.0 2026-11-09 06:29:44 -05:00
32 changed files with 211 additions and 857 deletions
Generated
+16 -17
View File
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadcraft"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -631,7 +631,6 @@ dependencies = [
"log",
"muda",
"objc2-app-kit 0.3.2",
"pollster 1.0.1",
"rfd",
"ron",
"serde_json",
@@ -645,7 +644,7 @@ dependencies = [
[[package]]
name = "cadcraft-cli"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -661,14 +660,14 @@ dependencies = [
[[package]]
name = "cadcraft-color"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-constraints"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-doc",
"cadcraft-geom",
@@ -678,7 +677,7 @@ dependencies = [
[[package]]
name = "cadcraft-doc"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-color",
"cadcraft-geom",
@@ -689,7 +688,7 @@ dependencies = [
[[package]]
name = "cadcraft-dwg"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"acadrust",
"log",
@@ -697,7 +696,7 @@ dependencies = [
[[package]]
name = "cadcraft-dxf"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"encoding_rs",
"log",
@@ -706,7 +705,7 @@ dependencies = [
[[package]]
name = "cadcraft-engine"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-color",
"cadcraft-constraints",
@@ -727,7 +726,7 @@ dependencies = [
[[package]]
name = "cadcraft-fonts"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-dxf",
"cadcraft-geom",
@@ -740,14 +739,14 @@ dependencies = [
[[package]]
name = "cadcraft-geom"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"serde",
]
[[package]]
name = "cadcraft-io"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"acadrust",
"cadcraft-color",
@@ -769,7 +768,7 @@ dependencies = [
[[package]]
name = "cadcraft-mcp"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-engine",
"cadcraft-io",
@@ -783,7 +782,7 @@ dependencies = [
[[package]]
name = "cadcraft-render"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -797,7 +796,7 @@ dependencies = [
[[package]]
name = "cadcraft-ui-egui"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-color",
"cadcraft-doc",
@@ -824,7 +823,7 @@ dependencies = [
[[package]]
name = "cadcraft-web"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"cadcraft-engine",
"cadcraft-fonts",
@@ -5992,7 +5991,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.19.0"
dependencies = [
"chrono",
"serde_json",
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
# default-members
[workspace.package]
version = "0.17.0"
version = "0.19.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+1 -5
View File
@@ -55,11 +55,7 @@ muda = { version = "0.21", default-features = false }
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSWorkspace", "NSAccessibility"] }
# The same winit eframe uses (the storytold fork, patched in the workspace): its opt-in that turns
# AppKit's terminate (Cmd+Q, Dock Quit) into a close request, so unsaved changes are asked about.
# `rwh_06`: the window handle a file dialog shown as a sheet attaches to (src/sheet.rs).
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
# Waits for the answer of a file dialog shown as a sheet (src/sheet.rs). MIT OR Apache-2.0, already
# in Cargo.lock through eframe.
pollster = "1"
winit = { version = "0.30", default-features = false }
# Rust HID backends: no proprietary SDK or C HID library.
[target.'cfg(target_os = "windows")'.dependencies]
+3 -22
View File
@@ -148,20 +148,7 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
let message = message.strip_suffix('\n').unwrap_or(message);
// A message can carry text from a file (a code page name, a path), so it must not be able to
// start what looks like another record: control characters other than tab and line feed are
// shown escaped, and every continuation line is indented, so only this function's own
// timestamp is ever at the start of a line.
let mut safe = String::with_capacity(message.len());
for c in message.chars() {
match c {
'\n' => safe.push_str("\n "),
'\t' => safe.push('\t'),
c if c.is_control() => safe.extend(c.escape_default()),
c => safe.push(c),
}
}
format!("{ts} {level:<5} [{thread}] {target}: {safe}\n")
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
@@ -454,14 +441,8 @@ mod tests {
format_line("2026-10-08T07:59:17.728Z", log::Level::Warn, "main", "cadcraft", "app icon: invalid PNG signature"),
"2026-10-08T07:59:17.728Z WARN [main] cadcraft: app icon: invalid PNG signature\n"
);
// A multi-line message keeps its lines, indented so they can't pass for records; the
// record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n b\n"));
// Control characters in a message (from a file) can't forge a record.
let forged = format_line("t", log::Level::Warn, "w", "x", "page\r\n2026-01-01T00:00:00Z ERROR [main] x: fake\u{1b}[0m");
assert_eq!(forged.lines().count(), 2);
assert!(forged.lines().skip(1).all(|l| l.starts_with(" ")));
assert!(!forged.contains('\r') && !forged.contains('\u{1b}'));
// A multi-line message keeps its lines; the record still ends in exactly one newline.
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
}
#[test]
+27 -40
View File
@@ -23,15 +23,13 @@ mod launch_args;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod sheet;
mod spacemouse;
mod system_animations;
mod workspace;
use cadcraft_engine::Session;
use cadcraft_ui_egui::switch::Switch;
use cadcraft_ui_egui::{CadApp, Pick, Services, i18n};
use cadcraft_ui_egui::{CadApp, Services, i18n};
struct App {
cad: CadApp,
@@ -161,44 +159,39 @@ pub fn install_io() {
fn services() -> Services {
Services {
pick_open: Some(Box::new(|| file_dialog(&Pick::Open).pick_file().map(|p| p.to_string_lossy().to_string()))),
pick_save: Some(Box::new(|name: &str| file_dialog(&Pick::Save(name.into())).save_file().map(|p| p.to_string_lossy().to_string()))),
// macOS shows the dialogs as sheets (`sheet::start_pick`), set once the window exists.
start_pick: None,
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter(i18n::t("Drawings (DWG, DXF)"), &["dwg", "dxf", "dwt"])
.add_filter(i18n::t("SVG drawings (SVG, SVGZ)"), &["svg", "svgz"])
.add_filter(i18n::t("All files"), &["*"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_save: Some(Box::new(|name: &str| {
filters_for_save(name)
.iter()
.fold(rfd::FileDialog::new().set_file_name(name), |d, (label, ext)| d.add_filter(i18n::t(label), &[*ext]))
.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| file_dialog(&Pick::Lisp(dir.into())).pick_file().map(|p| p.to_string_lossy().to_string()))),
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,
pick_pgp: Some(Box::new(|dir: Option<&str>| {
file_dialog(&Pick::Pgp(dir.unwrap_or_default().into())).pick_file().map(|p| p.to_string_lossy().to_string())
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())
})),
}
}
/// The file types `pick` offers: (label, extensions). Shared by the blocking dialogs and the
/// macOS sheets.
fn filters(pick: &Pick) -> Vec<(&'static str, Vec<&'static str>)> {
match pick {
Pick::Open => vec![
(i18n::t("Drawings (DWG, DXF)"), vec!["dwg", "dxf", "dwt"]),
(i18n::t("SVG drawings (SVG, SVGZ)"), vec!["svg", "svgz"]),
(i18n::t("All files"), vec!["*"]),
],
Pick::Save(name) => filters_for_save(name).iter().map(|(label, ext)| (i18n::t(label), vec![*ext])).collect(),
Pick::Lisp(_) => vec![(i18n::t("AutoLISP (LSP)"), vec!["lsp"]), (i18n::t("All files"), vec!["*"])],
Pick::Pgp(_) => vec![(i18n::t("Alias files (PGP)"), vec!["pgp"])],
}
}
/// The blocking dialog for `pick` (Windows, Linux, FreeBSD).
fn file_dialog(pick: &Pick) -> rfd::FileDialog {
let dialog = filters(pick).iter().fold(rfd::FileDialog::new(), |d, (label, exts)| d.add_filter(*label, exts));
match pick {
Pick::Save(name) => dialog.set_file_name(name),
Pick::Lisp(dir) | Pick::Pgp(dir) if !dir.is_empty() => dialog.set_directory(dir),
_ => dialog,
}
}
/// File types for the Save dialog, the suggested name's type first: Windows adds the first
/// filter's extension, and macOS accepts only listed extensions, replacing any other with the
/// first one. DWG must be listed for a `.dwg` name to survive, and a drawing opened from DWG
@@ -368,12 +361,6 @@ fn main() -> eframe::Result {
options,
Box::new(move |cc| {
let mut app = CadApp::new(Session::empty(), services());
// File dialogs are sheets on macOS: a modal loop inside the event handler crashes the
// app when the dialog is resized (#1527).
#[cfg(target_os = "macos")]
if let Some(window) = cc.winit_window().cloned() {
app.services.start_pick = Some(sheet::start_pick(window, cc.egui_ctx.clone()));
}
let (workspace, restored, workspace_error) = workspace::Workspace::load(vars.config_dir.as_deref());
if let Some(ui) = restored {
app.ui = ui;
-51
View File
@@ -1,51 +0,0 @@
//! macOS file dialogs as sheets. Shown in line, or from a helper thread that rfd hands to the main
//! thread's modal loop, a panel runs AppKit's modal event loop; resizing it sends window events
//! back into winit's event handler and winit aborts the app (#1527). A sheet returns at once and
//! the window keeps running; a thread of its own waits for the answer.
use std::sync::Arc;
use cadcraft_ui_egui::{Pick, StartPick};
type Answer = std::pin::Pin<Box<dyn std::future::Future<Output = Option<String>> + Send>>;
/// `Services::start_pick`: shows `pick` as a sheet on `window`; `ui` is woken when it answers.
pub fn start_pick(window: Arc<winit::window::Window>, ui: egui::Context) -> StartPick {
Box::new(move |pick, done| {
let (sheet_tx, sheet_rx) = std::sync::mpsc::channel::<Answer>();
let ui = ui.clone();
// The thread first, so the answer of a sheet once shown always has somewhere to go. If it
// cannot start, `done` is dropped unanswered and the app says the dialog could not be
// shown.
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
if let Ok(answer) = sheet_rx.recv() {
done(pollster::block_on(answer));
ui.request_repaint();
}
});
if let Err(e) = spawned {
log::warn!("file dialog: {e}");
return;
}
// rfd builds the panel and begins the sheet here, on the main thread (as it must).
let dialog =
crate::filters(&pick).iter().fold(rfd::AsyncFileDialog::new().set_parent(&*window), |d, (label, exts)| d.add_filter(*label, exts));
let path = |f: rfd::FileHandle| f.path().to_string_lossy().to_string();
let answer: Answer = match pick {
Pick::Save(name) => {
let picked = dialog.set_file_name(name).save_file();
Box::pin(async move { picked.await.map(path) })
}
Pick::Lisp(dir) | Pick::Pgp(dir) => {
let dialog = if dir.is_empty() { dialog } else { dialog.set_directory(dir) };
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(path) })
}
Pick::Open => {
let picked = dialog.pick_file();
Box::pin(async move { picked.await.map(path) })
}
};
let _ = sheet_tx.send(answer);
})
}
+6 -12
View File
@@ -232,10 +232,6 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
let mut extra = Vec::new();
let mut on = Vec::new();
for (rp, p) in g.points.iter().filter(|(r, _)| placed.contains(&r.handle)) {
// Nothing is added once the candidates are full, so the points left need no search.
if extra.len() + on.len() + out.len() >= MAX_CANDIDATES {
break;
}
if out.iter().any(|c| c.kind == ConstraintKind::Coincident && c.refs.contains(rp)) {
continue;
}
@@ -271,22 +267,20 @@ pub fn infer_placed(d: &Drawing, placed: &[Handle], others: &[Handle], o: &Infer
// Perpendicular lines meeting in a T (a perpendicular snap onto the middle of a line).
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 0.0 };
let allow_per = o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(ConstraintKind::Perpendicular.name()));
'per: for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| allow_per && placed.contains(&r.handle)) {
for (r1, a1, b1) in g.lines.iter().filter(|(r, _, _)| placed.contains(&r.handle)) {
for (r2, a2, b2) in g.lines.iter().filter(|(r, _, _)| r.handle != r1.handle) {
// Full: nothing more can be added, so the pairs left need no geometry or duplicate search.
if out.len() + extra.len() + on.len() >= MAX_CANDIDATES {
break 'per;
}
let (l1, l2) = (Line::new(*a1, *b1), Line::new(*a2, *b2));
let touches = |l: &Line, p: Vec2| l.dist(p) <= tol && (0.0..=1.0).contains(&l.param_of(p));
let tee = [*a1, *b1].iter().any(|p| touches(&l2, *p)) || [*a2, *b2].iter().any(|p| touches(&l1, *p));
let ang = ang_between(*b1 - *a1, *b2 - *a2);
// The duplicate search scans every candidate, so it comes after the cheap tests.
if tee
let dup = out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2));
if allow_per
&& tee
&& !dup
&& (std::f64::consts::FRAC_PI_2 - ang).abs() <= atol
&& l1.len() > tol
&& l2.len() > tol
&& !out.iter().any(|c| c.kind == ConstraintKind::Perpendicular && c.refs.contains(r1) && c.refs.contains(r2))
&& out.len() < MAX_CANDIDATES
{
out.push(Constraint { id: 0, kind: ConstraintKind::Perpendicular, refs: vec![*r1, *r2], name: String::new(), expr: String::new() });
}
+1 -3
View File
@@ -138,9 +138,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
let mut prep = Prep::new(crate::model::MAX_RIGID_WORK);
let (comps, _) = build_with(d, &cons, &SolveOptions::default(), &mut prep)?;
// The selection as a set, built once: a scan of the list per slot multiplied the two sizes.
let selected: HashSet<Handle> = handles.iter().copied().collect();
let want = |h: Handle| handles.is_empty() || selected.contains(&h);
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
let mut objects = Vec::new();
let mut seen = HashSet::new();
let mut total = Some(0usize);
+1 -1
View File
@@ -157,7 +157,7 @@ fn write_audit_log(_: &Session, _: &crate::audit::Report) {}
#[cfg(not(target_arch = "wasm32"))]
fn read_path(cmd: &str, path: &str) -> Result<Vec<u8>> {
crate::capped_read::read_capped(std::path::Path::new(path), file::MAX_DRAWING_BYTES).map_err(|e| bad(cmd, format!("{path}: {e}")))
std::fs::read(path).map_err(|e| bad(cmd, format!("{path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
+1 -2
View File
@@ -85,8 +85,7 @@ fn read_drawing(path: &str) -> Result<Drawing> {
fn read_file(_path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
.map_err(|e| bad("insert", format!("{_path}: {e}")))
std::fs::read(_path).map_err(|e| bad("insert", format!("{_path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
Err(bad("insert", "paths are not available on the web"))
+1 -2
View File
@@ -204,8 +204,7 @@ fn import_drawing(s: &mut Session, src: &Drawing, at: Vec2, scale: f64, file: &s
fn read_file(_path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
crate::capped_read::read_capped(std::path::Path::new(_path), crate::cmd::file::MAX_DRAWING_BYTES)
.map_err(|e| bad("import", format!("{_path}: {e}")))
std::fs::read(_path).map_err(|e| bad("import", format!("{_path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
Err(bad("import", "paths are not available on the web; pass `data`"))
+1 -1
View File
@@ -24,7 +24,7 @@ pub use template::{qnew_template, set_qnew_template};
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
/// through [`crate::capped_read`] rather than buffering to EOF.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) const MAX_DRAWING_BYTES: usize = 512 << 20;
const MAX_DRAWING_BYTES: usize = 512 << 20;
/// Largest `.scr` script accepted from a local path.
#[cfg(not(target_arch = "wasm32"))]
const MAX_SCRIPT_BYTES: usize = 64 << 20;
+1 -1
View File
@@ -110,7 +110,7 @@ fn other_drawing(s: &Session, p: &Value) -> Result<(Arc<Drawing>, String)> {
fn read_path(_path: &str) -> Result<Vec<u8>> {
#[cfg(not(target_arch = "wasm32"))]
{
crate::capped_read::read_capped(std::path::Path::new(_path), super::MAX_DRAWING_BYTES).map_err(|e| bad("compare", format!("{_path}: {e}")))
std::fs::read(_path).map_err(|e| bad("compare", format!("{_path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
Err(bad("compare", "paths are not available on the web: open the other drawing and compare with it by name (`document`)"))
+1 -1
View File
@@ -74,7 +74,7 @@ fn read_template(path: &str) -> Result<Drawing> {
#[cfg(not(target_arch = "wasm32"))]
fn read_bytes(path: &str) -> Result<Vec<u8>> {
crate::capped_read::read_capped(std::path::Path::new(path), super::MAX_DRAWING_BYTES).map_err(|e| bad("new", format!("template {path}: {e}")))
std::fs::read(path).map_err(|e| bad("new", format!("template {path}: {e}")))
}
#[cfg(target_arch = "wasm32")]
+5 -3
View File
@@ -303,9 +303,11 @@ fn run_stylesmanager(s: &mut Session, p: &Value) -> Result<Value> {
(None, Some(_path)) => {
#[cfg(not(target_arch = "wasm32"))]
{
let bytes = crate::capped_read::read_capped(std::path::Path::new(_path), cadcraft_doc::MAX_TABLE_FILE)
.map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
String::from_utf8(bytes).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
let meta = std::fs::metadata(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?;
if meta.len() > cadcraft_doc::MAX_TABLE_FILE as u64 {
return Err(bad(cmd, format!("{_path}: the plot style table file is too large")));
}
std::fs::read_to_string(_path).map_err(|e| bad(cmd, format!("{_path}: {e}")))?
}
#[cfg(target_arch = "wasm32")]
return Err(bad(cmd, "files can't be read here: pass `text`"));
+2 -31
View File
@@ -175,14 +175,8 @@ pub fn compare(prop: &Value, op: Op, value: &Value) -> bool {
if ptxt.eq_ignore_ascii_case(vtxt.trim()) {
return true;
}
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`. No colour spelling is long, so a
// long text (a layer name from a file) isn't parsed, copied and split per entity.
const MAX_COLOR_TEXT: usize = 64;
let (pc, vc) = (ptxt.trim(), vtxt.trim());
if pc.len() > MAX_COLOR_TEXT || vc.len() > MAX_COLOR_TEXT {
return false;
}
match (Color::parse(pc), Color::parse(vc)) {
// Colours: `red` = `1` = `Red`; `bylayer` = `ByLayer`.
match (Color::parse(&ptxt), Color::parse(vtxt.trim())) {
(Some(a), Some(b)) => a == b,
_ => false,
}
@@ -406,26 +400,3 @@ mod tests {
assert!(!wildcard(&"*a".repeat(500), &"b".repeat(4000)));
}
}
#[cfg(test)]
mod long_text_tests {
use super::*;
/// A long string property is compared as text only, and a colour name still equals its index.
#[test]
fn long_values_skip_colour_parsing() {
let long = Value::String("x".repeat(100_000));
assert!(compare(&long, Op::Eq, &long));
assert!(!compare(&long, Op::Eq, &Value::String("red".into())));
assert!(compare(&Value::String("red".into()), Op::Eq, &Value::String("1".into())));
}
/// Padding is trimmed before the length limit, so a padded colour still matches.
#[test]
fn padded_colours_still_match() {
let padded = |c: &str| Value::String(format!("{}{c}{}", " ".repeat(100), " ".repeat(100)));
assert!(compare(&padded("red"), Op::Eq, &Value::String("1".into())));
assert!(compare(&Value::String("red".into()), Op::Eq, &padded("1")));
assert!(!compare(&padded("red"), Op::Ne, &padded("1")));
}
}
+6 -14
View File
@@ -12,7 +12,7 @@ use crate::{Accept, Input, Interactive, Prompt, Result, Session, Step};
/// Largest linked file read (bytes).
#[cfg(not(target_arch = "wasm32"))]
const MAX_LINK_BYTES: usize = 8 << 20;
const MAX_LINK_BYTES: u64 = 8 << 20;
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -238,9 +238,11 @@ fn read_link(id: &str, file: &std::path::Path) -> Result<String> {
let path = file.display();
#[cfg(not(target_arch = "wasm32"))]
{
// The limit is enforced on the bytes read, not on reported metadata, so a device or FIFO
// that misreports its length can't be buffered to EOF.
let bytes = crate::capped_read::read_capped(file, MAX_LINK_BYTES).map_err(|e| bad(id, format!("{path}: {e}")))?;
let meta = std::fs::metadata(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
if meta.len() > MAX_LINK_BYTES {
return Err(bad(id, format!("{path}: larger than {} MB", MAX_LINK_BYTES >> 20)));
}
let bytes = std::fs::read(file).map_err(|e| bad(id, format!("{path}: {e}")))?;
let text = String::from_utf8_lossy(&bytes);
Ok(text.strip_prefix('\u{feff}').unwrap_or(&text).to_owned())
}
@@ -368,16 +370,6 @@ fn run_update(s: &mut Session, p: &Value) -> Result<Value> {
mod tests {
use super::*;
/// A linked path naming an endless device is refused instead of buffered to EOF.
#[cfg(all(unix, not(target_arch = "wasm32")))]
#[test]
fn link_to_endless_device_is_rejected() {
let dev = std::path::Path::new("/dev/zero");
if dev.exists() {
assert!(read_link("datalink", dev).is_err());
}
}
#[test]
fn csv_parsing() {
assert_eq!(parse_csv("a,b\r\n\"c,d\",\"e\"\"f\"\n", ','), vec![vec!["a", "b"], vec!["c,d", "e\"f"]]);
+3 -7
View File
@@ -415,12 +415,9 @@ impl Session {
/// Move printed text to the command history: complete lines, and with `all` the rest too.
fn lisp_flush(&mut self, env: &mut Env, all: bool) {
// One pass over the buffer, dropping the printed lines once at the end: draining each line
// from the front shifted the rest every time, quadratic in newline-dense output.
let mut done = 0;
while let Some(i) = env.out.get(done..).and_then(|rest| rest.find('\n')) {
let line = env.out.get(done..done + i).unwrap_or_default().trim_end_matches('\r').to_string();
done += i + 1;
while let Some(i) = env.out.find('\n') {
let line: String = env.out.drain(..=i).collect();
let line = line.trim_end_matches(['\n', '\r']).to_string();
// `(princ "\nText")` after a prompt or a command's echo ends that line instead of
// leaving an empty one; after LISP's own line it does print an empty line.
if line.is_empty() && env.printed_at != Some(self.log.len()) {
@@ -430,7 +427,6 @@ impl Session {
self.echo(line);
env.printed_at = Some(self.log.len());
}
env.out.drain(..done);
if all && !env.out.is_empty() {
let line = std::mem::take(&mut env.out);
self.echo(line);
-10
View File
@@ -307,16 +307,6 @@ fn printing() {
assert_eq!(ok(&mut s, "(rtos 12.5 2 2)"), "\"12.50\"");
}
/// Output with many lines moves to the history in one pass, not one buffer shift per line.
#[test]
fn printing_many_lines_is_linear() {
let mut s = session();
let started = std::time::Instant::now();
ok(&mut s, "(setq x \"a\\r\\n\") (repeat 17 (setq x (strcat x x))) (princ x) (princ)");
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
assert_eq!(s.log.last().map(String::as_str), Some("a"));
}
#[test]
fn command_draws_and_one_routine_is_one_undo_step() {
let mut s = session();
+1 -9
View File
@@ -103,11 +103,7 @@ impl Face {
let Some(f) = self.font() else { return Vec::new() };
[skrifa::string::StringId::FAMILY_NAME, skrifa::string::StringId::TYPOGRAPHIC_FAMILY_NAME, skrifa::string::StringId::FULL_NAME]
.into_iter()
// A name table can hold many records that point at one big string: at most
// MAX_NAME_RECORDS per name id, each decoded only up to MAX_NAME_CHARS characters.
.flat_map(|id| {
f.localized_strings(id).take(MAX_NAME_RECORDS).map(|s| norm(&s.chars().take(MAX_NAME_CHARS).collect::<String>())).collect::<Vec<_>>()
})
.flat_map(|id| f.localized_strings(id).map(|s| norm(&s.to_string())).collect::<Vec<_>>())
.collect()
}
}
@@ -122,10 +118,6 @@ pub fn faces(bytes: &Arc<Vec<u8>>) -> Vec<Face> {
}
const MAX_FACES_PER_FILE: u32 = 32;
/// Name records read per name id of a face, and characters read of each: real fonts have a few
/// dozen short ones.
const MAX_NAME_RECORDS: usize = 64;
const MAX_NAME_CHARS: usize = 256;
/// Bounds of the missing-glyph search: files loaded, bytes loaded, faces checked per character.
const MAX_FALLBACK_FILES: usize = 24;
const MAX_FALLBACK_BYTES: usize = 160 << 20;
+1 -18
View File
@@ -484,9 +484,6 @@ pub struct Polyline {
pub closed: bool,
}
/// Sampled points one [`Polyline::tessellate`] call produces before arcs become chords.
pub const MAX_TESSELLATED_POINTS: usize = 4_000_000;
impl Polyline {
pub fn from_points(pts: &[Vec2], closed: bool) -> Self {
Polyline { vertices: pts.iter().map(|p| PolyVertex::new(*p)).collect(), closed }
@@ -519,24 +516,10 @@ impl Polyline {
b
}
pub fn tessellate(&self, tol: f64) -> Vec<Vec2> {
let mut budget = MAX_TESSELLATED_POINTS;
self.tessellate_within(tol, &mut budget)
}
/// [`Polyline::tessellate`] spending about `budget` sampled points (shared by the caller
/// across many polylines). The budget is checked before each segment, so the last segment
/// started may overshoot it by that one segment's samples. Once it is spent, the remaining arcs are drawn as chords, so the
/// output stays proportional to the input vertices. Each arc can sample thousands of points,
/// so a compact polyline could otherwise expand into gigabytes (CWE-400).
pub fn tessellate_within(&self, tol: f64, budget: &mut usize) -> Vec<Vec2> {
let mut out: Vec<Vec2> = Vec::new();
for s in self.segments() {
let mut pts = Vec::new();
if *budget > 0 {
s.tessellate(tol, &mut pts);
*budget = budget.saturating_sub(pts.len());
} else {
pts.extend([s.start(), s.end()]);
}
s.tessellate(tol, &mut pts);
let skip = usize::from(out.last().is_some_and(|l| pts.first().is_some_and(|f| l.near(*f, 1e-9))));
out.extend(pts.into_iter().skip(skip));
}
+1 -2
View File
@@ -20,8 +20,7 @@ mod vec;
pub use bounds::Bounds2;
pub use curve::{
Arc, Circle, Ellipse, Line, MAX_TESSELLATED_POINTS, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon,
polygon_centroid, shoelace,
Arc, Circle, Ellipse, Line, PolyVertex, Polyline, Segment, arc_to_bulge, bulge_to_arc, point_in_polygon, polygon_centroid, shoelace,
};
pub use fillet::{CornerFit, TanCurve, tangent_circles};
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
+6 -23
View File
@@ -431,10 +431,10 @@ fn outside(core: &Graph, u: usize, steps: usize) -> Option<Vec<Vec2>> {
}
/// Outlines of the groups of curves inside the face `outer` (vertex indices into `g`).
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Vec<Island> {
let comp = components(g);
let outer_comp: std::collections::HashSet<usize> = outer.iter().filter_map(|&i| comp.get(i).copied()).collect();
let outer_pts = points(g, outer)?;
let Some(outer_pts) = points(g, outer) else { return Vec::new() };
let ob = Bounds2::from_points(outer_pts.iter().copied());
let core = prune(g);
// Leftmost (then lowest) vertex still on a cycle, per component.
@@ -464,32 +464,19 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
let mut starts: Vec<(usize, usize)> = start.into_iter().collect();
starts.sort_unstable();
let mut out = Vec::new();
// Each containment test scans a whole polygon, accepted or not: they share one allowance of
// polygon edges, and the search ends when it is spent.
let mut edges_left: usize = 100_000_000;
for (c, u) in starts {
if out.len() >= MAX_ISLANDS {
break;
}
let Some(&pu) = core.pts.get(u) else { continue };
if !ob.contains(pu) {
continue;
}
let left = edges_left.checked_sub(outer_pts.len())?;
edges_left = left;
if !point_in_polygon(&outer_pts, pu) {
if !ob.contains(pu) || !point_in_polygon(&outer_pts, pu) {
continue;
}
let steps = edges.get(&c).copied().unwrap_or(0).saturating_add(4);
let Some(lp) = outside(&core, u, steps) else { continue };
let area = shoelace(&lp);
let ib = Bounds2::from_points(lp.iter().copied());
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) {
continue;
}
let left = edges_left.checked_sub(lp.len())?;
edges_left = left;
if point_in_polygon(&lp, p) {
if lp.len() < 3 || !area.is_finite() || area <= 0.0 || !ob.contains_box(&ib) || point_in_polygon(&lp, p) {
continue;
}
let mut src = sources.remove(&c).unwrap_or_default();
@@ -497,7 +484,7 @@ fn islands(g: &Graph, outer: &[usize], p: Vec2) -> Option<Vec<Island>> {
src.dedup();
out.push(Island { outline: lp, sources: src });
}
Some(out)
out
}
/// The point of the nearest other curve within `snap` of the end `e` of curve `i`, as (curve,
@@ -510,10 +497,6 @@ fn nearest_curve(polys: &[Vec<Vec2>], i: usize, e: Vec2, snap: f64, tol: f64, bu
if j == i {
continue;
}
// A curve with no segment costs a visit too, so many of them can't multiply the searches.
if other.len() < 2 {
*budget = budget.checked_sub(1).ok_or(())?;
}
for (k, w) in other.windows(2).enumerate() {
if *budget == 0 {
return Err(());
@@ -615,7 +598,7 @@ pub fn boundary_at_gap(polys: &[Vec<Vec2>], p: Vec2, tol: f64, gap: f64) -> Opti
let g = build_gap(polys, tol, gap)?;
let ids = outer_face(&g, p)?;
let outer = points(&g, &ids)?;
let islands = islands(&g, &ids, p)?;
let islands = islands(&g, &ids, p);
let mut outer_sources: Vec<usize> =
ids.iter().zip(ids.iter().cycle().skip(1)).filter_map(|(&u, &v)| g.edge_src.get(&(u.min(v), u.max(v))).copied()).collect();
outer_sources.sort_unstable();
-12
View File
@@ -221,18 +221,6 @@ fn segment_offset() {
assert!(s.offset(6.0).is_none());
}
/// A polyline of many fine arcs samples at most the point budget, then draws chords.
#[test]
fn polyline_tessellation_is_bounded() {
let vertices: Vec<PolyVertex> = (0..3000).map(|i| PolyVertex { bulge: 1.0, ..PolyVertex::new(Vec2::new(f64::from(i) * 1e6, 0.0)) }).collect();
let pl = Polyline { vertices, closed: false };
let pts = pl.tessellate(1e-9);
assert!(pts.len() <= MAX_TESSELLATED_POINTS + 2 * 3000 + 4096, "{}", pts.len());
assert!(pts.len() > 3000);
let mut budget = 10;
assert!(pl.tessellate_within(1e-9, &mut budget).len() <= 4096 + 2 * 3000);
}
#[test]
fn arc_segments_bounded() {
assert_eq!(arc_segments(0.0, 1.0, 0.1), 1);
+1 -4
View File
@@ -128,10 +128,7 @@ const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
/// ignore keeps only the outermost loops. Nesting depth = how many other loops enclose a loop.
pub fn fill_loops(h: &Hatch, tol: f64) -> Vec<Vec<Vec2>> {
// One point budget for all the loops: many short arcs across loops must not add up unbounded.
let mut budget = cadcraft_geom::MAX_TESSELLATED_POINTS;
let mut loops: Vec<Vec<Vec2>> =
h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate_within(tol, &mut budget)).collect();
let mut loops: Vec<Vec<Vec2>> = h.loops.iter().map(|l| Polyline { vertices: l.vertices.clone(), closed: true }.tessellate(tol)).collect();
dedup_polygons(&mut loops);
let max_depth = match h.style {
1 => 1,
+5 -37
View File
@@ -217,14 +217,15 @@ pub fn import(app: &mut CadApp, path: &str) -> Result<(), String> {
/// 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().unwrap_or_default();
// Through `pick_for`, so macOS shows it as a sheet (#1527); aliases belong to no drawing.
match app.pick_for(crate::Pick::Pgp(dir), false, Box::new(|app, path| import(app, &path).map(|()| serde_json::Value::Null))) {
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(r) => r.map(|_| ()),
Some(None) => Ok(()),
Some(Some(path)) => import(app, &path),
}
}
@@ -449,39 +450,6 @@ mod tests {
let _ = app.run("aliasedit", json!({ "clear": true }));
}
/// Where the host shows dialogs without waiting (macOS sheets, #1527), Import… does not run
/// the blocking picker; the file loads once the sheet answers.
#[test]
fn import_waits_for_a_sheet_instead_of_blocking() {
type Done = Box<dyn FnOnce(Option<String>) + Send>;
let _guard = alone();
let dir = temp_dir("sheet");
let path = dir.join("aliases.pgp");
std::fs::write(&path, "XXS, *LINE\n").unwrap_or_default();
let asked = Rc::new(RefCell::new(Vec::new()));
let answer: Rc<RefCell<Option<Done>>> = Rc::default();
let (log, slot) = (Rc::clone(&asked), Rc::clone(&answer));
let services = Services {
pick_pgp: Some(Box::new(|_: Option<&str>| panic!("the blocking picker ran"))),
start_pick: Some(Box::new(move |pick, done| {
log.borrow_mut().push(pick);
*slot.borrow_mut() = Some(done);
})),
..Services::default()
};
let mut app = CadApp::new(Session::new(), services);
open(&mut app);
pick_and_import(&mut app).expect("the sheet starts");
assert!(matches!(asked.borrow().as_slice(), [crate::Pick::Pgp(_)]));
answer.borrow_mut().take().expect("a sheet was started")(Some(path.to_string_lossy().to_string()));
app.finish_pick(&egui::Context::default());
let st = app.alias_dlg.as_ref().expect("the dialog stays open");
assert_eq!(st.user.get("xxs").map(String::as_str), Some("line"));
clear(&mut app);
}
#[test]
fn import_loads_the_aliases_shows_the_path_and_warnings() {
let _guard = alone();
+18 -88
View File
@@ -88,11 +88,6 @@ impl CadApp {
/// Answer the open close prompt (no-op without one).
pub fn answer_close(&mut self, choice: Choice) {
let Some(uid) = self.closing.as_ref().and_then(|c| c.asking) else { return };
// Its Save dialog is open (macOS): Save again or Don't Save would race it.
if choice != Choice::Cancel && self.pending_pick.as_ref().is_some_and(|p| p.for_close) {
self.set_status("Answer the Save dialog first.");
return;
}
let i = match (choice, self.doc_index(uid)) {
(Choice::Cancel, _) => {
self.closing = None;
@@ -119,53 +114,26 @@ impl CadApp {
// The Block Editor's unsaved changes go into the block first (BSAVE), then the
// drawing is saved with them.
let r = if self.session.state().is_ok_and(|st| st.block_unsaved()) { self.run("bsave", json!({})) } else { Ok(Value::Null) };
let waiting = self.pending_pick.is_some();
let r = r.and_then(|_| self.run("qsave", Value::Null));
// The Save dialog opened without waiting (macOS): closing goes on once it has
// saved; cancelling it leaves the prompt up.
// The dialog is bound to this drawing: if it is closed meanwhile, nothing is
// saved and the prompt goes on with the next drawing.
if !waiting && let Some(mut pending) = self.pending_pick.take() {
let then = pending.then;
pending.for_close = true;
pending.then = Box::new(move |app: &mut CadApp, path| {
let r = then(app, path);
// Unless the prompt was answered otherwise meanwhile.
if app.closing.as_ref().and_then(|c| c.asking) == Some(uid) {
// It reports the outcome itself; an error returned here would
// replace its message in the status bar.
app.close_after_save(uid, r);
return Ok(Value::Null);
}
r
});
self.pending_pick = Some(pending);
return;
let saved = r.is_ok() && self.doc_index(uid).and_then(|i| self.session.docs.get(i)).is_some_and(|d| !d.is_dirty());
match (saved, self.doc_index(uid)) {
(true, Some(i)) => {
self.close_front(i);
self.advance_close();
}
_ => {
// Not saved (picker cancelled, save failed or unavailable): keep the
// drawing and its edits, and stop closing.
self.closing = None;
let title = self.doc_index(uid).and_then(|i| self.session.docs.get(i)).map(|d| d.title.clone()).unwrap_or_default();
let msg = match r {
Err(e) => format!("{title} was not saved ({e}); it stays open."),
Ok(_) => format!("{title} was not saved; it stays open."),
};
self.session.echo(msg.clone());
self.set_status(msg);
}
}
self.close_after_save(uid, r);
}
}
}
/// Go on closing after the drawing `uid` was asked to save with result `r`.
fn close_after_save(&mut self, uid: u64, r: Result<Value, String>) {
let saved = r.is_ok() && self.doc_index(uid).and_then(|i| self.session.docs.get(i)).is_some_and(|d| !d.is_dirty());
match (saved, self.doc_index(uid)) {
(true, Some(i)) => {
self.close_front(i);
self.advance_close();
}
_ => {
// Not saved (picker cancelled, save failed or unavailable): keep the
// drawing and its edits, and stop closing.
self.closing = None;
let title = self.doc_index(uid).and_then(|i| self.session.docs.get(i)).map(|d| d.title.clone()).unwrap_or_default();
let msg = match r {
Err(e) => format!("{title} was not saved ({e}); it stays open."),
Ok(_) => format!("{title} was not saved; it stays open."),
};
self.session.echo(msg.clone());
self.set_status(msg);
}
}
}
@@ -296,44 +264,6 @@ mod tests {
app.session.docs.iter().map(|d| d.title.clone()).collect()
}
type Done = Box<dyn FnOnce(Option<String>) + Send>;
/// The host shows its Save dialog without waiting (macOS): the callback that answers it.
fn deferred_save(app: &mut CadApp) -> std::rc::Rc<std::cell::RefCell<Option<Done>>> {
let slot: std::rc::Rc<std::cell::RefCell<Option<Done>>> = std::rc::Rc::default();
let s = slot.clone();
app.services = Services {
pick_save: Some(Box::new(|_: &str| None)),
start_pick: Some(Box::new(move |_, done| *s.borrow_mut() = Some(done))),
..Services::default()
};
slot
}
/// While the prompt's Save dialog is open, Don't Save (or Save again) does not race it; when
/// the save then fails, the prompt's own "was not saved" message is what the status bar shows.
#[test]
fn the_close_prompt_waits_for_its_save_dialog() {
let mut app = app_with(&["A", "B"]);
let answer = deferred_save(&mut app);
dirty(&mut app, 0);
app.request_close(Some(0));
app.answer_close(Choice::Save);
assert!(answer.borrow().is_some(), "the Save dialog is open");
app.answer_close(Choice::Discard);
app.answer_close(Choice::Save);
assert_eq!(titles(&app), ["A", "B"], "Don't Save waits for the Save dialog");
assert_eq!(app.close_prompt(), Some("A"));
let nowhere = std::env::temp_dir().join(format!("cadcraft-no-such-dir-{}", std::process::id())).join("A.dxf");
answer.borrow_mut().take().unwrap()(Some(nowhere.to_string_lossy().to_string()));
app.finish_pick(&egui::Context::default());
assert_eq!(titles(&app), ["A", "B"], "not saved, so not closed");
assert!(app.session.docs[0].is_dirty());
let status = app.status.as_ref().map(|(s, _)| s.as_str()).unwrap_or_default();
assert!(status.starts_with("A was not saved") && status.ends_with("it stays open."), "{status}");
}
#[test]
fn clean_drawing_closes_without_prompt() {
let mut app = app_with(&["A", "B"]);
+23 -176
View File
@@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
use egui::TextureHandle;
use serde_json::{Value, json};
use crate::{CadApp, Pick};
use crate::CadApp;
/// Bumped each time PREVIEW is started, so the window renders the drawing as it is now.
static PREVIEW_GEN: AtomicU32 = AtomicU32::new(0);
@@ -23,15 +23,27 @@ pub fn route(app: &mut CadApp, id: &str) -> Result<Value, String> {
app.ui.dialog = Some("preview".into());
Ok(Value::Null)
}
"import" | "imp" => app
.pick_then(Pick::Open, |app, path| app.run("import", json!({ "path": path })))
.unwrap_or_else(|| no_picker(app, "IMPORT", r#"import {"path": "parts.dxf"}"#)),
"publish" => app
.pick_then(Pick::Save(format!("{stem}.pdf")), |app, path| app.run("publish", json!({ "path": path })))
.unwrap_or_else(|| no_picker(app, "PUBLISH", "publish {}")),
_ => app
.pick_then(Pick::Save(format!("{stem}.svg")), |app, path| app.run("export", json!({ "path": path })))
.unwrap_or_else(|| no_picker(app, "EXPORT", r#"export {"format": "svg"}"#)),
"import" | "imp" => {
let Some(pick) = app.services.pick_open.as_ref() else { return no_picker(app, "IMPORT", r#"import {"path": "parts.dxf"}"#) };
match pick() {
Some(path) => app.run("import", json!({ "path": path })),
None => Ok(Value::Null),
}
}
"publish" => {
let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "PUBLISH", "publish {}") };
match pick(&format!("{stem}.pdf")) {
Some(path) => app.run("publish", json!({ "path": path })),
None => Ok(Value::Null),
}
}
_ => {
let Some(pick) = app.services.pick_save.as_ref() else { return no_picker(app, "EXPORT", r#"export {"format": "svg"}"#) };
match pick(&format!("{stem}.svg")) {
Some(path) => app.run("export", json!({ "path": path })),
None => Ok(Value::Null),
}
}
}
}
@@ -149,7 +161,7 @@ mod tests {
use cadcraft_engine::Session;
use serde_json::json;
use crate::{CadApp, Pick, Services};
use crate::{CadApp, Services};
/// An app whose file dialogs record what they were asked and cancel.
fn app() -> (CadApp, Rc<RefCell<Vec<String>>>) {
@@ -195,169 +207,4 @@ mod tests {
let e = app.run("share", json!({})).unwrap_err();
assert!(e.contains("not available yet"), "{e}");
}
type Done = Box<dyn FnOnce(Option<String>) + Send>;
/// An app whose host shows its dialogs without waiting (macOS, #1527): the dialogs asked
/// for, and the callback that answers the open one.
fn deferred_app() -> (CadApp, Rc<RefCell<Vec<Pick>>>, Rc<RefCell<Option<Done>>>) {
let asked = Rc::new(RefCell::new(Vec::new()));
let answer: Rc<RefCell<Option<Done>>> = Rc::default();
let (log, slot) = (asked.clone(), answer.clone());
let services = Services {
pick_open: Some(Box::new(|| None)),
pick_save: Some(Box::new(|_: &str| None)),
pick_lisp: Some(Box::new(|_: &str| None)),
start_pick: Some(Box::new(move |pick, done| {
log.borrow_mut().push(pick);
*slot.borrow_mut() = Some(done);
})),
..Services::default()
};
(CadApp::new(Session::new(), services), asked, answer)
}
fn status(app: &CadApp) -> &str {
app.status.as_ref().map(|(s, _)| s.as_str()).unwrap_or_default()
}
fn uid(app: &CadApp) -> u64 {
app.session.state().unwrap().uid
}
/// Where the host shows its dialogs without waiting (macOS, #1527), the command acts on the
/// path in a later frame, and no second dialog opens while one is up.
#[test]
fn a_deferred_dialog_acts_when_its_answer_arrives() {
let (mut app, asked, answer) = deferred_app();
let ctx = egui::Context::default();
// Cancelled: nothing happens, and the next command may ask again.
app.start("export");
assert_eq!(*asked.borrow(), [Pick::Save("Drawing1.svg".into())]);
answer.borrow_mut().take().unwrap()(None);
app.finish_pick(&ctx);
assert!(app.pending_pick.is_none());
app.start("imp");
app.finish_pick(&ctx);
assert!(app.pending_pick.is_some() && app.status.is_none(), "nothing runs before the answer");
// A second dialog is refused, and says so: in the status bar for a typed or menu
// command, and as an error for a programmatic call.
app.start("publish");
assert!(status(&app).contains("already open"), "{:?}", app.status);
let e = app.run("ui.open", json!(null)).unwrap_err();
assert!(e.contains("already open"), "{e}");
assert_eq!(asked.borrow().len(), 2, "no second dialog while one is open");
app.status = None;
let missing = std::env::temp_dir().join(format!("cadcraft-missing-{}.dxf", std::process::id()));
answer.borrow_mut().take().unwrap()(Some(missing.to_string_lossy().to_string()));
app.finish_pick(&ctx);
assert!(app.pending_pick.is_none());
assert!(app.status.is_some(), "IMPORT ran with the picked file and reported its error");
}
/// A dialog's answer acts on the drawing that asked for it, even when another drawing was
/// made active before it arrived (a control-channel `document.switch` runs before the answer
/// in the same frame): Save As must not write the other drawing to the chosen file.
#[test]
fn a_deferred_dialog_acts_on_the_drawing_that_asked() {
let (mut app, _, answer) = deferred_app();
let ctx = egui::Context::default();
app.run("new", json!({})).unwrap();
app.run("document.switch", json!({ "index": 0 })).unwrap();
let first = uid(&app);
let acted_on = Rc::new(RefCell::new(None));
let seen = acted_on.clone();
let r = app.pick_then(Pick::Save("Drawing1.dxf".into()), move |app, path| {
*seen.borrow_mut() = Some((uid(app), path));
Ok(json!(null))
});
assert_eq!(r, Some(Ok(json!(null))));
app.run("document.switch", json!({ "index": 1 })).unwrap();
assert_ne!(uid(&app), first);
answer.borrow_mut().take().unwrap()(Some("a.dxf".into()));
app.finish_pick(&ctx);
assert_eq!(*acted_on.borrow(), Some((first, "a.dxf".to_string())));
assert_eq!(app.session.active, 0, "the drawing that asked is shown again");
}
/// The drawing that asked was closed before the answer: nothing is done, and the status bar
/// says so. A dialog that opens a drawing (OPEN, NEW from a template) still acts.
#[test]
fn a_deferred_dialog_for_a_closed_drawing_does_nothing() {
let (mut app, _, answer) = deferred_app();
let ctx = egui::Context::default();
app.run("new", json!({})).unwrap();
let ran = Rc::new(RefCell::new(0));
let r1 = ran.clone();
let _ = app.pick_then(Pick::Save("Drawing2.svg".into()), move |_, _| {
*r1.borrow_mut() += 1;
Ok(json!(null))
});
app.run("close", json!({ "index": 1 })).unwrap();
answer.borrow_mut().take().unwrap()(Some("b.svg".into()));
app.finish_pick(&ctx);
assert_eq!(*ran.borrow(), 0, "the closed drawing's export did not run on another drawing");
assert!(app.pending_pick.is_none());
assert!(app.status.as_ref().is_some_and(|(s, _)| s.contains("closed") && s.contains("b.svg")), "{:?}", app.status);
app.run("new", json!({})).unwrap();
let r2 = ran.clone();
let _ = app.pick_then_open(Pick::Open, move |_, _| {
*r2.borrow_mut() += 1;
Ok(json!(null))
});
app.run("close", json!({ "index": 1 })).unwrap();
answer.borrow_mut().take().unwrap()(Some("c.dxf".into()));
app.finish_pick(&ctx);
assert_eq!(*ran.borrow(), 1, "OPEN goes on whichever drawing closed meanwhile");
}
/// The host dropped the answer (its dialog thread could not start): the status bar says
/// the dialog could not be shown, unlike a cancelled dialog, and the next one may open.
#[test]
fn a_dialog_that_never_answers_is_reported() {
let services =
Services { pick_save: Some(Box::new(|_: &str| None)), start_pick: Some(Box::new(|_, done| drop(done))), ..Services::default() };
let mut app = CadApp::new(Session::new(), services);
app.start("export");
app.finish_pick(&egui::Context::default());
assert!(app.pending_pick.is_none());
assert!(status(&app).contains("could not be shown"), "{:?}", app.status);
}
/// With no drawing open, a dialog bound to the active drawing is not shown: its answer could
/// otherwise save or export a drawing opened while it was up.
#[test]
fn a_bound_dialog_needs_an_open_drawing() {
let (mut app, asked, _) = deferred_app();
app.run("close", json!({ "index": 0 })).unwrap();
assert!(app.session.state().is_err());
app.start("ui.saveas");
let r = app.run("ui.saveas", json!(null));
assert!(asked.borrow().is_empty(), "no dialog");
assert!(app.pending_pick.is_none());
assert!(r.is_err() && app.status.is_some(), "{r:?} {:?}", app.status);
// OPEN needs no drawing.
app.start("ui.open");
assert_eq!(*asked.borrow(), [Pick::Open]);
}
/// A command started while APPLOAD's dialog was open (a control request, say) keeps
/// running; APPLOAD is refused and says to run it again.
#[test]
fn appload_does_not_cancel_a_command_started_meanwhile() {
let (mut app, asked, answer) = deferred_app();
let ctx = egui::Context::default();
app.start("appload");
assert!(matches!(asked.borrow().as_slice(), [Pick::Lisp(_)]));
app.start("line");
assert!(app.session.prompting());
answer.borrow_mut().take().unwrap()(Some("routine.lsp".into()));
app.finish_pick(&ctx);
assert_eq!(app.session.running.as_ref().map(|r| r.id.as_str()), Some("line"), "LINE was not cancelled");
assert!(status(&app).starts_with("APPLOAD: another command started"), "{:?}", app.status);
}
}
-169
View File
@@ -227,12 +227,6 @@ 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>>>,
/// Where set, the file dialogs are shown with this instead of calling `pick_open` /
/// `pick_save` / `pick_lisp` (which must still be set, to say which dialogs there are), and
/// the answer is acted on in a later frame ([`CadApp::pick_then`]). macOS needs it: a panel run modally
/// inside the window's event handler gets the window events of its own resizing sent back
/// into that handler, and winit aborts the app (#1527).
pub start_pick: Option<StartPick>,
/// 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`).
@@ -244,36 +238,6 @@ pub struct Services {
pub pick_pgp: Option<Box<dyn Fn(Option<&str>) -> Option<String>>>,
}
/// The file dialog to show: Open, Save with the suggested file name, APPLOAD's `.lsp` picker or
/// ALIASEDIT's `.pgp` picker, starting in the given folder (empty for the default one).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Pick {
Open,
Save(String),
Lisp(String),
Pgp(String),
}
/// Shows a file dialog without waiting for it and calls the callback (from any thread) with the
/// chosen path, or `None` when the dialog is cancelled ([`Services::start_pick`]).
pub type StartPick = Box<dyn Fn(Pick, Box<dyn FnOnce(Option<String>) + Send>)>;
/// What to do with the path a file dialog chose ([`CadApp::pick_then`]).
type PickDone = Box<dyn FnOnce(&mut CadApp, String) -> Result<Value, String>>;
/// A file dialog shown with [`Services::start_pick`] that has not answered yet.
struct PendingPick {
answer: Receiver<Option<String>>,
/// The drawing (its `uid`) the answer is for: Save As writes that drawing, export and plot
/// read it, and so on, whichever drawing is active when the answer arrives. `None` for the
/// dialogs that open a drawing (Open, New from Template, Recover).
doc: Option<u64>,
/// The close prompt's Save is waiting for this dialog: its other answers wait too
/// ([`closing`]).
for_close: bool,
then: PickDone,
}
pub struct CadApp {
pub system_languages: Vec<String>,
/// Whether the operating system shows animations, as the host reads it; `None` when the OS
@@ -365,8 +329,6 @@ pub struct CadApp {
pub hatch_dlg: Option<hatchdlg::HatchDlgState>,
/// The Edit Block Definition dialog ([`bedit`]).
pub bedit: bedit::State,
/// A file dialog shown with [`Services::start_pick`]: its answer, and what to do with it.
pending_pick: Option<PendingPick>,
/// The startup update check and its prompt (desktop; [`updates_ui`]).
#[cfg(not(target_arch = "wasm32"))]
pub updates_rt: updates_ui::Runtime,
@@ -427,139 +389,9 @@ impl CadApp {
bedit: bedit::State::default(),
#[cfg(not(target_arch = "wasm32"))]
updates_rt: updates_ui::Runtime::default(),
pending_pick: None,
}
}
/// Ask for a file with the host's dialog (`pick`) and run `then` with the chosen path, on the
/// drawing that is active now. `None` when the host has no dialog of that kind. Cancelling
/// gives `Ok(Null)`. With a synchronous picker the result is `then`'s; with
/// [`Services::start_pick`] the dialog opens and this returns `Ok(Null)` at once: `then` runs
/// in a later frame ([`Self::logic`]) with that drawing made active again (if something else
/// was switched to meanwhile, such as a control-channel `document.switch`), and not at all if
/// the drawing was closed. Its error, if any, shows in the status bar. With no drawing open
/// it refuses without showing a dialog. While one dialog is open, no other is shown: the
/// request is refused with an error, also shown in the status bar.
pub fn pick_then(
&mut self,
pick: Pick,
then: impl FnOnce(&mut CadApp, String) -> Result<Value, String> + 'static,
) -> Option<Result<Value, String>> {
self.pick_for(pick, true, Box::new(then))
}
/// [`Self::pick_then`] for a dialog that opens a drawing (Open, New from Template, Recover):
/// `then` does not act on the drawing that was active, so it runs whatever was switched to
/// or closed meanwhile.
pub fn pick_then_open(
&mut self,
pick: Pick,
then: impl FnOnce(&mut CadApp, String) -> Result<Value, String> + 'static,
) -> Option<Result<Value, String>> {
self.pick_for(pick, false, Box::new(then))
}
/// Show the dialog `pick` for `then`, bound to the active drawing when `bound`.
fn pick_for(&mut self, pick: Pick, bound: bool, then: PickDone) -> Option<Result<Value, String>> {
let available = match pick {
Pick::Open => self.services.pick_open.is_some(),
Pick::Save(_) => self.services.pick_save.is_some(),
Pick::Lisp(_) => self.services.pick_lisp.is_some(),
Pick::Pgp(_) => self.services.pick_pgp.is_some(),
};
if !available {
return None;
}
if self.pending_pick.is_some() {
return Some(Err(self.refuse_pick("A file dialog is already open; answer it first.".into())));
}
// A bound answer must not land on a drawing created or opened while the dialog is up.
let doc = match (bound, self.session.state()) {
(false, _) => None,
(true, Ok(st)) => Some(st.uid),
(true, Err(e)) => {
let why = e.to_string();
return Some(Err(self.refuse_pick(why)));
}
};
if let Some(start) = &self.services.start_pick {
let (tx, answer) = std::sync::mpsc::channel();
start(
pick,
Box::new(move |path| {
let _ = tx.send(path);
}),
);
self.pending_pick = Some(PendingPick { answer, doc, for_close: false, then });
return Some(Ok(Value::Null));
}
let path = match &pick {
Pick::Open => self.services.pick_open.as_ref().and_then(|f| f()),
Pick::Save(name) => self.services.pick_save.as_ref().and_then(|f| f(name)),
Pick::Lisp(dir) => self.services.pick_lisp.as_ref().and_then(|f| f(dir)),
Pick::Pgp(dir) => self.services.pick_pgp.as_ref().and_then(|f| f(Some(dir.as_str()).filter(|d| !d.is_empty()))),
};
Some(match path {
Some(path) => then(self, path),
None => Ok(Value::Null),
})
}
/// A file dialog that is not shown: the reason goes to the status bar too, since a typed or
/// menu command's result is not shown ([`Self::start`]).
fn refuse_pick(&mut self, why: String) -> String {
self.set_status(why.clone());
why
}
/// Start the interactive command `id` with a dialog's answer. `was_prompting`: whether a
/// command was running when the dialog was asked for. Starting cancels the running command,
/// so a command started while the dialog was open (a control request, say) is kept and this
/// one refused instead; one that was already running is cancelled as when the dialog answers
/// at once.
pub fn start_after_pick(&mut self, id: &str, was_prompting: bool) -> Result<(), String> {
if !was_prompting && self.session.prompting() {
return Err(format!("{}: another command started while the file dialog was open; run it again.", id.to_ascii_uppercase()));
}
self.session.start(id).map_err(|e| e.to_string())
}
/// Act on the answer of a dialog started with [`Services::start_pick`], once it arrives.
fn finish_pick(&mut self, ctx: &egui::Context) {
let Some(pending) = &self.pending_pick else { return };
match pending.answer.try_recv() {
Err(std::sync::mpsc::TryRecvError::Empty) => ctx.request_repaint_after(std::time::Duration::from_millis(100)),
Ok(None) => self.pending_pick = None,
// The host dropped the answer without giving one: the dialog never opened (its
// thread could not start) or failed.
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.pending_pick = None;
self.set_status("The file dialog could not be shown.");
}
Ok(Some(path)) => {
if let Some(PendingPick { doc, then, .. }) = self.pending_pick.take()
&& let Err(e) = self.make_active(doc, &path).and_then(|()| then(self, path))
{
self.set_status(e);
}
ctx.request_repaint();
}
}
}
/// Make the drawing `uid` active again for the answer `path` of a dialog it asked for, or
/// refuse when it was closed meanwhile.
fn make_active(&mut self, uid: Option<u64>, path: &str) -> Result<(), String> {
let Some(uid) = uid else { return Ok(()) };
let Some(i) = self.session.docs.iter().position(|d| d.uid == uid) else {
return Err(format!("The drawing was closed before the file dialog answered; nothing was done with {path}."));
};
if self.session.active != i {
self.run("document.switch", json!({ "index": i }))?;
}
Ok(())
}
/// The interface language shown: the chosen one, or English when this computer has no font
/// for it (Chinese or Korean without a suitable font would be boxes).
pub fn language(&self) -> &'static str {
@@ -870,7 +702,6 @@ impl CadApp {
// Consume-based queues: each item is handled once, whichever callback delivers it. A
// reply arriving during a later pass is never starved until the next frame.
self.drain_control(ctx);
self.finish_pick(ctx);
self.finish_opening();
// Finishing an open switches to the new drawing.
capture::note_identity(self, ctx);
+24 -40
View File
@@ -11,9 +11,9 @@ use std::sync::atomic::{AtomicU32, Ordering};
use egui::{RichText, Sense, Stroke, vec2};
use serde_json::{Value, json};
use crate::CadApp;
use crate::parametric::expr_field;
use crate::theme::Tokens;
use crate::{CadApp, Pick};
/// The Block Attribute Manager (BATTMAN).
mod battman;
@@ -121,7 +121,6 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
let library: Vec<String> = cadcraft_doc::library::standard_linetypes().into_iter().map(|l| l.name).filter(|n| d.linetype(n).is_none()).collect();
let mut sel = temp::<String>(ctx, "ltmgr_sel").filter(|s| rows.iter().any(|r| r.0.eq_ignore_ascii_case(s))).unwrap_or_else(|| current.clone());
let mut action = None;
let mut pick_lin = false;
egui::Window::new("Linetype Manager").open(open).default_size(vec2(520.0, 360.0)).resizable(true).show(ctx, |ui| {
ui.horizontal(|ui| {
craft_ui::ComboBox::from_id_salt("ltmgr_load").selected_text("Load...").width(110.0).show_ui(ui, |ui| {
@@ -136,10 +135,12 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
}
}
// The user's own .lin file (where the host has a file picker).
if app.services.pick_open.is_some() {
if let Some(pick) = app.services.pick_open.as_ref() {
ui.separator();
if ui.selectable_label(false, "From File...").clicked() {
pick_lin = true;
if ui.selectable_label(false, "From File...").clicked()
&& let Some(path) = pick()
{
action = Some(("linetype", json!({ "load": "*", "file": path })));
}
}
});
@@ -186,10 +187,6 @@ fn linetype(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
});
});
set_temp(ctx, "ltmgr_sel", sel);
// The user's .lin file, asked for once the window is drawn. Errors are echoed by `run`.
if pick_lin {
let _ = app.pick_then(Pick::Open, |app, path| app.run("linetype", json!({ "load": "*", "file": path })));
}
action
}
@@ -1041,46 +1038,33 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
return action;
}
if ok {
let params = plot_params(&st, None);
let target = st.target.clone();
if app.services.pick_save.is_some() {
let mut params = plot_params(&st, None);
if let Some(pick) = app.services.pick_save.as_ref() {
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
let suggested = format!("{}-{}.{ext}", stem.unwrap_or_else(|| "Drawing".into()), st.target);
// Cancelling the file dialog keeps the Plot dialog open with its settings.
let _ = app.pick_then(Pick::Save(suggested), move |app, path| {
let mut params = params;
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(path));
}
run_plot(app, params, &target);
Ok(Value::Null)
});
} else {
run_plot(app, params, &target);
let Some(path) = pick(&suggested) else { return action };
if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(path));
}
}
*open = false;
match app.run("plot", params) {
Ok(r) => {
let msg = match r.get("path").and_then(Value::as_str) {
Some(path) => format!("Plotted {} to {path}", st.target),
None => format!("Plotted {} ({} bytes of PDF; there is no file dialog here)", st.target, r["bytes"]),
};
app.session.echo(msg);
}
// `run` already echoed the error; keep the dialog open to fix the settings.
Err(_) => *open = true,
}
}
action
}
/// Plot with the Plot dialog's settings: it closes, and opens again when the plot fails.
fn run_plot(app: &mut CadApp, params: Value, target: &str) {
if app.ui.dialog.as_deref() == Some("plot") {
app.ui.dialog = None;
}
match app.run("plot", params) {
Ok(r) => {
let msg = match r.get("path").and_then(Value::as_str) {
Some(path) => format!("Plotted {target} to {path}"),
None => format!("Plotted {target} ({} bytes of PDF; there is no file dialog here)", r["bytes"]),
};
app.session.echo(msg);
}
// `run` already echoed the error; keep the dialog open to fix the settings.
Err(_) => app.ui.dialog = Some("plot".into()),
}
}
/// The sheet in the chosen orientation with its printable area, and its size.
fn sheet_sketch(ui: &mut egui::Ui, page: &cadcraft_doc::PageSetup, t: &Tokens) {
let (w, h) = if page.landscape { (page.height_mm, page.width_mm) } else { (page.width_mm, page.height_mm) };
+39 -35
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use crate::{CadApp, Pick};
use crate::CadApp;
/// The top-level menus, in order.
pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools", "Draw", "Dimension", "Modify", "Window", "Help"];
@@ -405,12 +405,13 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
return Some(Ok(Value::Null));
}
let r = match id {
"ui.open" | "open" if no_path => app
.pick_then_open(Pick::Open, |app, p| {
"ui.open" | "open" if no_path => {
let picked = app.services.pick_open.as_ref().and_then(|f| f());
if let Some(p) = picked {
app.open_path(std::path::Path::new(&p));
Ok(Value::Null)
})
.unwrap_or(Ok(Value::Null)),
}
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
@@ -422,54 +423,57 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option<Re
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 was_prompting = app.session.prompting();
return app.pick_then(Pick::Lisp(dir), move |app, path| {
if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::to_str).filter(|d| !d.is_empty()) {
app.ui.last_lisp_dir = Some(dir.to_string());
}
app.start_after_pick("appload", was_prompting)?;
app.cmdline(&path);
Ok(Value::Null)
});
let Some(path) = pick(&dir) else { return Some(Ok(Value::Null)) };
if let Some(dir) = std::path::Path::new(&path).parent().and_then(std::path::Path::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" => app
.pick_then_open(Pick::Open, |app, p| app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string()))
.unwrap_or(Ok(Value::Null)),
"ui.newtemplate" => {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
return Some(app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string()));
}
Ok(Value::Null)
}
// RECOVER from the menu or typed: pick the file. Without a picker the engine asks for the
// file name on the command line.
"recover" if no_path && app.services.pick_open.is_some() => app
.pick_then_open(Pick::Open, |app, p| {
"recover" if no_path && app.services.pick_open.is_some() => {
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
let r = app.run("recover", json!({ "path": p }));
app.opened(r);
Ok(Value::Null)
})
.unwrap_or(Ok(Value::Null)),
}
Ok(Value::Null)
}
"ui.saveas" | "saveas" if no_path => {
let title = app.session.state().map(|s| s.title.clone()).ok();
let name = app.ui.save_format.suggested_name(title.as_deref());
// Bound to this drawing: the answer saves it, not whichever drawing is active then.
app.pick_then(Pick::Save(name), |app, p| {
app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string())
})
.unwrap_or(Ok(Value::Null))
if let Some(p) = app.services.pick_save.as_ref().and_then(|f| f(&name)) {
return Some(app.session.execute("saveas", &saveas_params(&app.ui, &app.session, &p)).map_err(|e| e.to_string()));
}
Ok(Value::Null)
}
"qsave" if no_path => return user_qsave(app),
// EXPORTPDF from the menu or typed: ask where to save the PDF (PLOT and PRINT open the Plot
// dialog, `managers::route`). JSON calls (scripts, control channel, MCP) always carry
// params, never get here and never open a dialog.
"exportpdf" if params.is_null() && app.session.state().is_ok() => {
let pick = app.services.pick_save.as_ref()?;
let title = app.session.state().map(|s| s.title.clone()).unwrap_or_default();
let stem = std::path::Path::new(&title).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty());
return app.pick_then(Pick::Save(format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))), |app, path| {
let r = app.session.execute("exportpdf", &json!({ "path": path })).map_err(|e| e.to_string());
app.session.echo(match &r {
Ok(_) => format!("Plotted to {path}"),
Err(e) => e.clone(),
});
r
let Some(path) = pick(&format!("{}.pdf", stem.unwrap_or_else(|| "Drawing".into()))) else { return Some(Ok(Value::Null)) };
let r = app.session.execute("exportpdf", &json!({ "path": path })).map_err(|e| e.to_string());
app.session.echo(match &r {
Ok(_) => format!("Plotted to {path}"),
Err(e) => e.clone(),
});
r
}
"ui.sample" => {
let d = cadcraft_engine::sample::default_sample();
+15 -20
View File
@@ -6,8 +6,8 @@
use egui::RichText;
use serde_json::{Value, json};
use crate::CadApp;
use crate::docking::Panel;
use crate::{CadApp, Pick};
/// XREF typed or chosen from a menu opens this palette; XATTACH there asks for the file first
/// (with the host's file picker, where there is one). JSON calls (with parameters) run the
@@ -19,18 +19,19 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
match cadcraft_engine::cmd::resolve_alias(id).as_str() {
"xref" => Some(open_palette(app)),
"xattach" => {
app.services.pick_open.as_ref()?;
let was_prompting = app.session.prompting();
app.pick_then(Pick::Open, move |app, picked| {
let path = relative_to_host(app, &picked);
app.start_after_pick("xattach", was_prompting)?;
// Submit the picked filename as literal text, not through the command line: a
// path is attacker-controlled (the file picker returns whatever the user
// selected), and `cmdline` interprets command syntax, so a name like `'closeall`
// would run an engine command instead of naming the file (CWE-74).
app.session.input(cadcraft_engine::Input::Text(path)).map_err(|e| e.to_string())?;
Ok(Value::Null)
})
let picked = app.services.pick_open.as_ref().and_then(|f| f())?;
let path = relative_to_host(app, &picked);
if let Err(e) = app.session.start("xattach") {
return Some(Err(e.to_string()));
}
// Submit the picked filename as literal text, not through the command line: a path is
// attacker-controlled (the file picker returns whatever the user selected), and
// `cmdline` interprets command syntax, so a name like `'closeall` would run an engine
// command instead of naming the file (CWE-74).
if let Err(e) = app.session.input(cadcraft_engine::Input::Text(path)) {
return Some(Err(e.to_string()));
}
Some(Ok(Value::Null))
}
_ => None,
}
@@ -77,16 +78,10 @@ fn rows(app: &CadApp, ui: &egui::Ui) -> Vec<Row> {
let xrefs: Vec<_> = d.blocks.values().filter(|b| b.xref_path.is_some() && !b.name.contains('|')).collect();
let mut counts = vec![0usize; xrefs.len()];
if !xrefs.is_empty() {
// Xref name (ASCII-upper-cased) → its row, the first of names that differ only in case, so
// each INSERT is one lookup instead of a scan of the xref list.
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for (k, b) in xrefs.iter().enumerate() {
index.entry(b.name.to_ascii_uppercase()).or_insert(k);
}
let mut count = |store: &cadcraft_engine::doc::EntityStore| {
for e in store.iter() {
if let cadcraft_engine::doc::EntityKind::Insert(i) = &e.kind
&& let Some(&k) = index.get(&i.block.to_ascii_uppercase())
&& let Some(k) = xrefs.iter().position(|b| b.name.eq_ignore_ascii_case(&i.block))
&& let Some(c) = counts.get_mut(k)
{
*c += 1;