mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 20:40:30 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
637efc238c | ||
|
|
2f8f186188 | ||
|
|
6f0f1fefa8 | ||
|
|
38bf0300fe | ||
|
|
24cd02feb3 | ||
|
|
27f3a18cc9 |
Generated
+17
-16
@@ -614,7 +614,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -631,6 +631,7 @@ dependencies = [
|
||||
"log",
|
||||
"muda",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"pollster 1.0.1",
|
||||
"rfd",
|
||||
"ron",
|
||||
"serde_json",
|
||||
@@ -644,7 +645,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -660,14 +661,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -677,7 +678,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -688,7 +689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"log",
|
||||
@@ -696,7 +697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -705,7 +706,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -726,7 +727,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-dxf",
|
||||
"cadcraft-geom",
|
||||
@@ -739,14 +740,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -768,7 +769,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -782,7 +783,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -796,7 +797,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -823,7 +824,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-fonts",
|
||||
@@ -5991,7 +5992,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -55,7 +55,11 @@ 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.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
# `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"
|
||||
|
||||
# Rust HID backends: no proprietary SDK or C HID library.
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
|
||||
+40
-27
@@ -23,13 +23,15 @@ 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, Services, i18n};
|
||||
use cadcraft_ui_egui::{CadApp, Pick, Services, i18n};
|
||||
|
||||
struct App {
|
||||
cad: CadApp,
|
||||
@@ -159,39 +161,44 @@ pub fn install_io() {
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
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())
|
||||
})),
|
||||
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,
|
||||
// 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())
|
||||
})),
|
||||
pick_lisp: Some(Box::new(|dir: &str| file_dialog(&Pick::Lisp(dir.into())).pick_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
no_file_system: false,
|
||||
pick_pgp: Some(Box::new(|dir: Option<&str>| {
|
||||
let dialog = rfd::FileDialog::new().add_filter(i18n::t("Alias files (PGP)"), &["pgp"]);
|
||||
let dialog = match dir {
|
||||
Some(dir) => dialog.set_directory(dir),
|
||||
None => dialog,
|
||||
};
|
||||
dialog.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
file_dialog(&Pick::Pgp(dir.unwrap_or_default().into())).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
|
||||
@@ -361,6 +368,12 @@ 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;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! 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);
|
||||
})
|
||||
}
|
||||
@@ -367,6 +367,7 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
|
||||
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
|
||||
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
|
||||
/// the part of the hatch inside it is generated; `None` = all of it.
|
||||
#[cfg(test)]
|
||||
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines_until(d, h, loops, px, view, None)
|
||||
}
|
||||
@@ -380,6 +381,21 @@ pub(crate) fn pattern_lines_until(
|
||||
px: f64,
|
||||
view: Option<&Bounds2>,
|
||||
cancel: Option<&AtomicBool>,
|
||||
) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines_within(d, h, loops, px, view, cancel, MAX_RUNS, MAX_HATCH_WORK)
|
||||
}
|
||||
|
||||
/// [`pattern_lines_until`] with the most runs and the dash-work allowance given.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn pattern_lines_within(
|
||||
d: &Drawing,
|
||||
h: &Hatch,
|
||||
loops: &[Vec<Vec2>],
|
||||
px: f64,
|
||||
view: Option<&Bounds2>,
|
||||
cancel: Option<&AtomicBool>,
|
||||
max_runs: usize,
|
||||
work_limit: u64,
|
||||
) -> Vec<Vec<Vec2>> {
|
||||
let lines = lines_of(d, h);
|
||||
let scale = scale_of(h);
|
||||
@@ -388,7 +404,7 @@ pub(crate) fn pattern_lines_until(
|
||||
// Shared work budget across all families and spans, counting dash-processing steps even when
|
||||
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
|
||||
// their cost (CWE-400).
|
||||
let mut work: u64 = MAX_HATCH_WORK;
|
||||
let mut work: u64 = work_limit;
|
||||
for fam in &lines {
|
||||
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
|
||||
// are not swept at all.
|
||||
@@ -403,8 +419,8 @@ pub(crate) fn pattern_lines_until(
|
||||
continue;
|
||||
}
|
||||
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
|
||||
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
|
||||
Family { max_runs, origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||
if out.len() >= max_runs || work == 0 || is_set(cancel) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -415,6 +431,8 @@ pub(crate) fn pattern_lines_until(
|
||||
const SWATCH_REPEATS: f64 = 6.0;
|
||||
/// Most runs a swatch holds.
|
||||
const MAX_SWATCH_RUNS: usize = 4000;
|
||||
/// The dash-work allowance of a swatch (a hatch's is [`MAX_HATCH_WORK`]).
|
||||
const MAX_SWATCH_WORK: u64 = 2_000_000;
|
||||
|
||||
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
|
||||
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
|
||||
@@ -441,14 +459,19 @@ pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
|
||||
pattern_lines: lines.to_vec(),
|
||||
};
|
||||
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
|
||||
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
|
||||
// The generator stops at the swatch's own limits, so a compact pattern is never made in full
|
||||
// (about a million runs) to keep a few thousand.
|
||||
let mut runs = pattern_lines_within(&Drawing::default(), &h, &[square], 0.0, None, None, MAX_SWATCH_RUNS, MAX_SWATCH_WORK);
|
||||
runs.truncate(MAX_SWATCH_RUNS);
|
||||
runs.shrink_to_fit();
|
||||
runs
|
||||
}
|
||||
|
||||
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
|
||||
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
|
||||
struct Family<'a> {
|
||||
/// Most runs the hatch may hold in all ([`MAX_RUNS`], less for a swatch).
|
||||
max_runs: usize,
|
||||
origin: Vec2,
|
||||
ang: f64,
|
||||
delta: Vec2,
|
||||
@@ -487,6 +510,13 @@ impl Family<'_> {
|
||||
|
||||
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
|
||||
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
|
||||
// Transforming the boundary is charged before it is done, at the scanline rate (one unit per 8 vertices).
|
||||
let verts: u64 = loops.iter().map(|l| l.len() as u64).sum();
|
||||
let transform_charge = verts / 8 + 1;
|
||||
if transform_charge > *work {
|
||||
return;
|
||||
}
|
||||
*work -= transform_charge;
|
||||
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
|
||||
let bb = Bounds2::from_points(local.iter().flatten().copied());
|
||||
if bb.is_empty() {
|
||||
@@ -505,10 +535,17 @@ impl Family<'_> {
|
||||
// family draws anything at all).
|
||||
let draws = self.dashes.iter().any(|d| *d >= 0.0);
|
||||
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
|
||||
// Each scanline visits every boundary edge and sorts the intersections. Charge both the
|
||||
// traversal and an n*ceil(log2(n)) upper bound for sorting, scaled to one unit per eight
|
||||
// operations, whether or not any dash is made.
|
||||
let edges: u64 = local.iter().map(|l| l.len() as u64).sum();
|
||||
let sort_levels = if edges > 1 { (u64::BITS - (edges - 1).leading_zeros()) as u64 } else { 0 };
|
||||
let crossings_charge = edges.saturating_add(edges.saturating_mul(sort_levels)) / 8 + 1;
|
||||
for k in k0..=k1 {
|
||||
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
|
||||
if out.len() >= self.max_runs || crossings_charge > *work || is_set(cancel) {
|
||||
return;
|
||||
}
|
||||
*work -= crossings_charge;
|
||||
let y = k as f64 * self.spacing;
|
||||
let xs = crossings(&local, y);
|
||||
let offset = shift_per_line * k as f64;
|
||||
@@ -538,10 +575,10 @@ impl Family<'_> {
|
||||
'outer: while x < x1 {
|
||||
for d in self.dashes {
|
||||
guard += 1;
|
||||
*work = work.saturating_sub(1);
|
||||
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
|
||||
if guard > 100_000 || out.len() >= self.max_runs || *work == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
*work -= 1;
|
||||
let len = d.abs();
|
||||
if *d > 0.0 {
|
||||
let a = x.max(x0);
|
||||
|
||||
@@ -29,11 +29,17 @@ const MAX_TEXTS: usize = 10_000;
|
||||
/// repeated many times).
|
||||
const MAX_POINTS: usize = 2_000_000;
|
||||
|
||||
/// Upper bound on the points of all the texts of one run: each placement keeps its own copy of the
|
||||
/// label's outline, so a long label times [`MAX_TEXTS`] would otherwise be too much to hold.
|
||||
const MAX_TEXT_POINTS: usize = 4_000_000;
|
||||
|
||||
/// One text or shape element of the pattern, shaped once at the origin.
|
||||
struct Element {
|
||||
/// Distance from the start of a pattern repeat to the anchor (where the element ends).
|
||||
at: f64,
|
||||
local: Shaped,
|
||||
/// Points in `local`, each placement's cost against [`MAX_TEXT_POINTS`].
|
||||
points: usize,
|
||||
rotation: f64,
|
||||
absolute: bool,
|
||||
offset: Vec2,
|
||||
@@ -58,6 +64,7 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
|
||||
return out;
|
||||
}
|
||||
let mut walk = Walk { pts, i: 0, start: 0.0 };
|
||||
let mut points_left = MAX_TEXT_POINTS;
|
||||
let mut base = 0.0;
|
||||
let mut points = 0usize;
|
||||
'repeats: while base <= len {
|
||||
@@ -67,6 +74,8 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
|
||||
if s > len + 1e-9 || out.len() >= MAX_TEXTS || points > MAX_POINTS {
|
||||
break 'repeats;
|
||||
}
|
||||
let Some(left) = points_left.checked_sub(el.points) else { break 'repeats };
|
||||
points_left = left;
|
||||
let Some((p, dir)) = walk.at(s) else { break 'repeats };
|
||||
let origin = p + dir * el.offset.x + dir.perp() * el.offset.y;
|
||||
let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation };
|
||||
@@ -111,6 +120,7 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
|
||||
{
|
||||
out.push(Element {
|
||||
at,
|
||||
points: point_count(&local),
|
||||
local,
|
||||
rotation: finite(el.rotation),
|
||||
absolute: el.absolute,
|
||||
@@ -135,8 +145,16 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
|
||||
..TextParams::new(Vec2::ZERO, height)
|
||||
};
|
||||
let (local, _) = cadcraft_fonts::place(&font, text, ¶ms);
|
||||
// Points plus one per stroke and glyph slot, and one for the label itself, so a label of empty
|
||||
// glyphs still has a cost proportional to its size.
|
||||
let points = local.strokes.iter().map(Vec::len).sum::<usize>()
|
||||
+ local.glyphs.iter().flatten().map(Vec::len).sum::<usize>()
|
||||
+ local.strokes.len()
|
||||
+ local.glyphs.len()
|
||||
+ 1;
|
||||
out.push(Element {
|
||||
at,
|
||||
points,
|
||||
local,
|
||||
rotation: finite(el.rotation),
|
||||
absolute: el.absolute,
|
||||
@@ -217,6 +235,20 @@ mod tests {
|
||||
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty());
|
||||
}
|
||||
|
||||
/// A long label repeated along a long run stops at the point allowance, well before MAX_TEXTS.
|
||||
#[test]
|
||||
fn placed_labels_stop_at_the_point_allowance() {
|
||||
let d = Drawing::new_imperial();
|
||||
let mut lt = gas();
|
||||
lt.pattern[1].text = Some("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM".into());
|
||||
// 0.95 per repeat over 9,000: about 9,500 repeats, under the 50,000 the run is drawn for.
|
||||
let t = texts(&d, &[Vec2::ZERO, Vec2::new(9_000.0, 0.0)], <, 1.0, 0.0);
|
||||
let points: usize =
|
||||
t.iter().map(|s| s.strokes.iter().map(Vec::len).sum::<usize>() + s.glyphs.iter().flatten().map(Vec::len).sum::<usize>()).sum();
|
||||
assert!(!t.is_empty() && t.len() < MAX_TEXTS, "{}", t.len());
|
||||
assert!(points <= MAX_TEXT_POINTS, "{points}");
|
||||
}
|
||||
|
||||
/// A shape file written for this test: shape 130 a 2 x 2 box with its pen ending at x = 2.
|
||||
fn register_shapes() {
|
||||
let src = "*130,7,BOX\n1,020,024,028,02C,2,020,0\n";
|
||||
|
||||
@@ -211,3 +211,14 @@ fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
|
||||
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// A pattern with a huge number of tiny dashes makes a swatch of a few thousand runs, stopping
|
||||
/// there; and many scanlines over a many-edged boundary are charged to the hatch's work allowance.
|
||||
#[test]
|
||||
fn compact_patterns_stop_at_the_swatch_limits() {
|
||||
let lines = vec![PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![1e-6, -1e-6] }];
|
||||
let started = std::time::Instant::now();
|
||||
let runs = cadcraft_render::pattern_swatch(&lines, 1.0);
|
||||
assert!(!runs.is_empty() && runs.len() <= 4000, "{}", runs.len());
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(20));
|
||||
}
|
||||
|
||||
@@ -217,15 +217,14 @@ 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();
|
||||
let picked = app.services.pick_pgp.as_ref().map(|pick| pick(dir.as_deref()));
|
||||
match picked {
|
||||
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))) {
|
||||
None => Err(refused(
|
||||
app,
|
||||
crate::tl!("Import: there is no file dialog here; run ALIASEDIT with the file's contents using its text parameter.").to_string(),
|
||||
)),
|
||||
Some(None) => Ok(()),
|
||||
Some(Some(path)) => import(app, &path),
|
||||
Some(r) => r.map(|_| ()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +449,39 @@ 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();
|
||||
|
||||
@@ -88,6 +88,11 @@ 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;
|
||||
@@ -114,26 +119,53 @@ 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));
|
||||
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);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -264,6 +296,44 @@ 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"]);
|
||||
|
||||
+176
-23
@@ -7,7 +7,7 @@ use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use egui::TextureHandle;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// Bumped each time PREVIEW is started, so the window renders the drawing as it is now.
|
||||
static PREVIEW_GEN: AtomicU32 = AtomicU32::new(0);
|
||||
@@ -23,27 +23,15 @@ pub fn route(app: &mut CadApp, id: &str) -> Result<Value, String> {
|
||||
app.ui.dialog = Some("preview".into());
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"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),
|
||||
}
|
||||
}
|
||||
"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"}"#)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +149,7 @@ mod tests {
|
||||
use cadcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{CadApp, Services};
|
||||
use crate::{CadApp, Pick, Services};
|
||||
|
||||
/// An app whose file dialogs record what they were asked and cancel.
|
||||
fn app() -> (CadApp, Rc<RefCell<Vec<String>>>) {
|
||||
@@ -207,4 +195,169 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,12 @@ 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`).
|
||||
@@ -238,6 +244,36 @@ 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
|
||||
@@ -329,6 +365,8 @@ 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,
|
||||
@@ -389,9 +427,139 @@ 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 {
|
||||
@@ -702,6 +870,7 @@ 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);
|
||||
|
||||
@@ -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,6 +121,7 @@ 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| {
|
||||
@@ -135,12 +136,10 @@ 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 let Some(pick) = app.services.pick_open.as_ref() {
|
||||
if app.services.pick_open.is_some() {
|
||||
ui.separator();
|
||||
if ui.selectable_label(false, "From File...").clicked()
|
||||
&& let Some(path) = pick()
|
||||
{
|
||||
action = Some(("linetype", json!({ "load": "*", "file": path })));
|
||||
if ui.selectable_label(false, "From File...").clicked() {
|
||||
pick_lin = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -187,6 +186,10 @@ 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
|
||||
}
|
||||
|
||||
@@ -1038,33 +1041,46 @@ fn plot(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) -> Action {
|
||||
return action;
|
||||
}
|
||||
if ok {
|
||||
let mut params = plot_params(&st, None);
|
||||
if let Some(pick) = app.services.pick_save.as_ref() {
|
||||
let params = plot_params(&st, None);
|
||||
let target = st.target.clone();
|
||||
if app.services.pick_save.is_some() {
|
||||
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 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,
|
||||
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);
|
||||
}
|
||||
}
|
||||
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) };
|
||||
|
||||
+35
-39
@@ -3,7 +3,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::CadApp;
|
||||
use crate::{CadApp, Pick};
|
||||
|
||||
/// The top-level menus, in order.
|
||||
pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools", "Draw", "Dimension", "Modify", "Window", "Help"];
|
||||
@@ -405,13 +405,12 @@ 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 => {
|
||||
let picked = app.services.pick_open.as_ref().and_then(|f| f());
|
||||
if let Some(p) = picked {
|
||||
"ui.open" | "open" if no_path => app
|
||||
.pick_then_open(Pick::Open, |app, p| {
|
||||
app.open_path(std::path::Path::new(&p));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap_or(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
|
||||
@@ -423,57 +422,54 @@ 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 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)
|
||||
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)
|
||||
});
|
||||
}
|
||||
// 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()) {
|
||||
return Some(app.session.execute("new", &json!({ "template": p })).map_err(|e| e.to_string()));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"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)),
|
||||
// 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() => {
|
||||
if let Some(p) = app.services.pick_open.as_ref().and_then(|f| f()) {
|
||||
"recover" if no_path && app.services.pick_open.is_some() => app
|
||||
.pick_then_open(Pick::Open, |app, p| {
|
||||
let r = app.run("recover", json!({ "path": p }));
|
||||
app.opened(r);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap_or(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());
|
||||
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)
|
||||
// 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))
|
||||
}
|
||||
"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());
|
||||
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(),
|
||||
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
|
||||
});
|
||||
r
|
||||
}
|
||||
"ui.sample" => {
|
||||
let d = cadcraft_engine::sample::default_sample();
|
||||
|
||||
+13
-14
@@ -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,19 +19,18 @@ 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" => {
|
||||
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))
|
||||
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)
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user