forked from 1b-agent/opencadstudio-upstream
feat(plot): PSETUPIN and page-setup import, page setup for new layouts
- PSETUPIN / -PSETUPIN: with no arguments a file picker offers the chosen drawing's named page setups in the Plot dialog; `PSETUPIN <file>` opens the same chooser on that file; `PSETUPIN <file> *` or `PSETUPIN <file> name,name` import straight away. Same-named setups are replaced, the import is one undo step, and the report counts both. - The Plot dialog's page-setup list gains "Import…": the chooser shows the source drawing's setups with a tick each, All, Import and Cancel, and selects the first imported setup afterwards. The source is read off the UI thread with the same reader the editor uses for its own setups (`scene::document_page_setups`), and handles that only meant something in the source drawing (plot view, visual style) are dropped on import. - Options → General → Plotting: "Show the page setup for new layouts" opens the Plot / Page Setup dialog whenever a layout is created (persisted with the plot preferences). Tests: the document reader, the command (all / named / unknown / missing file / undo) against a drawing saved by the app, the chooser flow, and the new-layout option. Nine new strings in all 21 locales. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
1 parent
7efb04a666
commit
74936063f9
35 files changed
+786
-48
No files matched your search
@@ -567,6 +567,8 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"BACKGROUNDPLOT",
|
||||
"CTAB",
|
||||
"TILEMODE",
|
||||
"PSETUPIN",
|
||||
"-PSETUPIN",
|
||||
"DISPSILH",
|
||||
"WORLDVIEW",
|
||||
"LIMCHECK",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! Plot system variables. `PLOTOFFSET`, `PAPERUPDATE`, `PLOTROTMODE`,
|
||||
//! `PLOTTRANSPARENCYOVERRIDE` and `BACKGROUNDPLOT` are plot preferences of
|
||||
//! the application, kept with the Plot dialog's persisted settings; `CTAB`
|
||||
//! and `TILEMODE` say which layout of the drawing is current. Each is
|
||||
//! typeable on its own (`PLOTOFFSET 1`, `CTAB Layout1`) and through
|
||||
//! `SETVAR`, and a bare name prompts for the value like the other variables.
|
||||
//! Plot system variables and the page-setup import command. `PLOTOFFSET`,
|
||||
//! `PAPERUPDATE`, `PLOTROTMODE`, `PLOTTRANSPARENCYOVERRIDE` and
|
||||
//! `BACKGROUNDPLOT` are plot preferences of the application, kept with the
|
||||
//! Plot dialog's persisted settings; `CTAB` and `TILEMODE` say which layout
|
||||
//! of the drawing is current. Each is typeable on its own (`PLOTOFFSET 1`,
|
||||
//! `CTAB Layout1`) and through `SETVAR`, and a bare name prompts for the
|
||||
//! value like the other variables. `PSETUPIN` brings named page setups in
|
||||
//! from another drawing (see `app::update::page_setup_import`).
|
||||
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use iced::Task;
|
||||
@@ -39,6 +41,16 @@ enum Outcome {
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn dispatch_plotvars(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
// PSETUPIN [file [* | name,name…]] — the dash form is the same
|
||||
// command; both read the file and report on the command line.
|
||||
for name in ["PSETUPIN", "-PSETUPIN"] {
|
||||
if cmd == name {
|
||||
return Some(self.on_psetupin(""));
|
||||
}
|
||||
if let Some(args) = cmd.strip_prefix(name).filter(|rest| rest.starts_with(' ')) {
|
||||
return Some(self.on_psetupin(args));
|
||||
}
|
||||
}
|
||||
let rest = cmd.strip_prefix("SETVAR ").map(str::trim).unwrap_or(cmd);
|
||||
let mut parts = rest.splitn(2, char::is_whitespace);
|
||||
let name = parts.next().unwrap_or("").to_ascii_uppercase();
|
||||
|
||||
@@ -2168,6 +2168,10 @@ pub enum Message {
|
||||
SaveTimeChanged(i32),
|
||||
/// Toggle keeping a `.bak` copy when overwriting a drawing (ISAVEBAK).
|
||||
BackupOnSaveChanged(bool),
|
||||
/// A drawing was picked to import page setups from (`PSETUPIN`).
|
||||
PageSetupImportFile(std::path::PathBuf),
|
||||
/// Options: open the Plot / Page Setup dialog for every new layout.
|
||||
PageSetupOnNewLayoutChanged(bool),
|
||||
/// Toggle filled TrueType glyphs (TEXTFILL).
|
||||
TextFillChanged(bool),
|
||||
/// Change how many prompt lines sit above the command window (CLIPROMPTLINES).
|
||||
|
||||
@@ -4421,6 +4421,7 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
M::PrinterProperties => self.on_printer_properties(),
|
||||
M::Import(msg) => self.on_page_setup_import(msg),
|
||||
M::PrinterOptionsLoaded(printer, result) => {
|
||||
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
|
||||
if draft.printer == printer {
|
||||
@@ -4814,7 +4815,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_page_setups(&mut self) {
|
||||
pub(super) fn refresh_page_setups(&mut self) {
|
||||
use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV};
|
||||
let scene = &self.tabs[self.active_tab].scene;
|
||||
// <none> / <previous>, then layouts (`*name*`), then named setups.
|
||||
@@ -4827,7 +4828,7 @@ impl OpenCADStudio {
|
||||
/// Apply a page-setup list selection to the editor. Handles the pseudo
|
||||
/// entries (`<none>` / `<previous>`), layout rows (`*name*`) and named
|
||||
/// setups.
|
||||
fn select_page_setup(&mut self, name: &str) {
|
||||
pub(super) fn select_page_setup(&mut self, name: &str) {
|
||||
use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV};
|
||||
self.plot_dialog.selected_setup = name.to_string();
|
||||
if name == SETUP_NONE {
|
||||
|
||||
@@ -94,6 +94,7 @@ mod context_menu;
|
||||
mod dialog;
|
||||
mod dynamic;
|
||||
mod file;
|
||||
mod page_setup_import;
|
||||
mod style;
|
||||
mod util;
|
||||
mod viewport;
|
||||
@@ -7237,6 +7238,12 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::PageSetupImportFile(path) => self.on_page_setup_import_file(path),
|
||||
Message::PageSetupOnNewLayoutChanged(enabled) => {
|
||||
self.plot_dialog.page_setup_on_new_layout = enabled;
|
||||
self.persist_settings_if_changed();
|
||||
Task::none()
|
||||
}
|
||||
Message::BackupOnSaveChanged(enabled) => {
|
||||
self.backup_on_save = enabled;
|
||||
self.persist_settings_if_changed();
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
//! Importing named page setups from another drawing (`PSETUPIN`): the
|
||||
//! "Import…" chooser of the Plot dialog and the command that drives the same
|
||||
//! path from the command line.
|
||||
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use crate::ui::window::plot::{PageSetupImportDraft, PageSetupImportMsg, PlotDlgMsg};
|
||||
use iced::Task;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Read the named page setups of the drawing at `path` off the UI thread.
|
||||
/// The result names the file the way the chooser shows it.
|
||||
fn read_page_setups_task(path: PathBuf) -> Task<Message> {
|
||||
super::file::background_task(
|
||||
move || {
|
||||
let file = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
crate::io::load_file(&path)
|
||||
.map(|document| (file, crate::scene::document_page_setups(&document)))
|
||||
},
|
||||
|result| Message::PlotDlg(PlotDlgMsg::Import(PageSetupImportMsg::Loaded(result))),
|
||||
)
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(super) fn on_page_setup_import(&mut self, msg: PageSetupImportMsg) -> Task<Message> {
|
||||
match msg {
|
||||
PageSetupImportMsg::Pick => Task::perform(
|
||||
async {
|
||||
let handle = rfd::AsyncFileDialog::new()
|
||||
.set_title(crate::t!("Select Drawing to Import Page Setups From").as_ref())
|
||||
.add_filter(
|
||||
crate::t!("Drawings and templates").as_ref(),
|
||||
&["dwg", "dxf", "dwt", "DWG", "DXF", "DWT"],
|
||||
)
|
||||
.pick_file()
|
||||
.await;
|
||||
handle.map(|h| crate::sys::handle_path(&h))
|
||||
},
|
||||
|picked| match picked {
|
||||
Some(path) => Message::PageSetupImportFile(path),
|
||||
None => Message::Noop,
|
||||
},
|
||||
),
|
||||
PageSetupImportMsg::Loaded(result) => {
|
||||
self.plot_dialog.import_draft = Some(match result {
|
||||
Ok((file, setups)) => PageSetupImportDraft {
|
||||
file,
|
||||
setups: setups.into_iter().map(|(name, ps)| (name, ps, true)).collect(),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => PageSetupImportDraft {
|
||||
file: String::new(),
|
||||
setups: Vec::new(),
|
||||
error: Some(error),
|
||||
},
|
||||
});
|
||||
Task::none()
|
||||
}
|
||||
PageSetupImportMsg::Toggle(name) => {
|
||||
if let Some(draft) = self.plot_dialog.import_draft.as_mut() {
|
||||
if let Some(entry) = draft.setups.iter_mut().find(|(n, _, _)| *n == name) {
|
||||
entry.2 = !entry.2;
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
PageSetupImportMsg::All(on) => {
|
||||
if let Some(draft) = self.plot_dialog.import_draft.as_mut() {
|
||||
for entry in &mut draft.setups {
|
||||
entry.2 = on;
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
PageSetupImportMsg::Apply => {
|
||||
let Some(draft) = self.plot_dialog.import_draft.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
let chosen: Vec<(String, acadrust::objects::PlotSettings)> = draft
|
||||
.selected()
|
||||
.map(|(name, ps)| (name.to_string(), ps.clone()))
|
||||
.collect();
|
||||
let imported = self.import_page_setups(chosen);
|
||||
if let Some(first) = imported.first() {
|
||||
let first = first.clone();
|
||||
self.select_page_setup(&first);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
PageSetupImportMsg::Cancel => {
|
||||
self.plot_dialog.import_draft = None;
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing was picked for import: read it in the background; the
|
||||
/// chooser appears in the Plot dialog once it is read.
|
||||
pub(super) fn on_page_setup_import_file(&mut self, path: PathBuf) -> Task<Message> {
|
||||
// The chooser lives in the Plot dialog; open it if the import was
|
||||
// started from the command line.
|
||||
let open = if self.active_modal == Some(crate::app::ModalKind::Plot) {
|
||||
Task::none()
|
||||
} else {
|
||||
self.on_plot_dialog_open()
|
||||
};
|
||||
Task::batch([open, read_page_setups_task(path)])
|
||||
}
|
||||
|
||||
/// Bring the given setups into the drawing, replacing same-named ones,
|
||||
/// as one undoable step. Returns the names imported.
|
||||
pub(super) fn import_page_setups(
|
||||
&mut self,
|
||||
setups: Vec<(String, acadrust::objects::PlotSettings)>,
|
||||
) -> Vec<String> {
|
||||
if setups.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let i = self.active_tab;
|
||||
self.push_undo_snapshot(i, "PSETUPIN");
|
||||
let mut replaced = 0usize;
|
||||
let existing = self.tabs[i].scene.page_setup_names();
|
||||
let mut names = Vec::with_capacity(setups.len());
|
||||
for (name, ps) in setups {
|
||||
if existing.iter().any(|n| n.eq_ignore_ascii_case(&name)) {
|
||||
replaced += 1;
|
||||
}
|
||||
self.tabs[i].scene.import_page_setup(&name, ps);
|
||||
names.push(name);
|
||||
}
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_page_setups();
|
||||
let count = names.len();
|
||||
self.command_line.push_output(
|
||||
crate::tf!("PSETUPIN: {count} page setup(s) imported, {replaced} replaced.").as_ref(),
|
||||
);
|
||||
names
|
||||
}
|
||||
|
||||
/// `PSETUPIN` / `-PSETUPIN`: with no file, ask for one and offer its
|
||||
/// setups in the Plot dialog; with `<file> *` or `<file> name,name`
|
||||
/// import those straight away; with just `<file>` open the chooser on it.
|
||||
pub(in crate::app) fn on_psetupin(&mut self, args: &str) -> Task<Message> {
|
||||
let args = args.trim();
|
||||
if args.is_empty() {
|
||||
return self.on_page_setup_import(PageSetupImportMsg::Pick);
|
||||
}
|
||||
// The file may carry spaces; the setup list, when given, is the last
|
||||
// word (`*` or a comma-separated list without spaces), so the
|
||||
// arguments are a file followed by names only when what precedes the
|
||||
// last word is itself a file.
|
||||
let (file, names) = match args.rsplit_once(char::is_whitespace) {
|
||||
Some((file, names)) if Path::new(file.trim()).is_file() => {
|
||||
(file.trim(), Some(names.trim()))
|
||||
}
|
||||
_ => (args, None),
|
||||
};
|
||||
let path = PathBuf::from(file);
|
||||
let Some(names) = names else {
|
||||
return self.on_page_setup_import_file(path);
|
||||
};
|
||||
let available = match crate::io::load_file(&path) {
|
||||
Ok(document) => crate::scene::document_page_setups(&document),
|
||||
Err(error) => {
|
||||
self.command_line
|
||||
.push_error(crate::tf!("PSETUPIN: {error}").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let chosen: Vec<(String, acadrust::objects::PlotSettings)> = if names == "*" {
|
||||
available
|
||||
} else {
|
||||
let wanted: Vec<&str> = names.split(',').map(str::trim).collect();
|
||||
let mut chosen = Vec::new();
|
||||
for want in wanted {
|
||||
match available.iter().find(|(n, _)| n.eq_ignore_ascii_case(want)) {
|
||||
Some(found) => chosen.push(found.clone()),
|
||||
None => self.command_line.push_error(
|
||||
crate::tf!("PSETUPIN: no page setup named \"{want}\" in {file}.")
|
||||
.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
chosen
|
||||
};
|
||||
if chosen.is_empty() {
|
||||
self.command_line
|
||||
.push_info(crate::tf!("PSETUPIN: nothing to import from {file}.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
self.import_page_setups(chosen);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use crate::ui::window::plot::{PageSetupImportMsg as I, PlotDlgMsg};
|
||||
|
||||
const FIXTURE: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/plot/page-setups-metric.dxf"
|
||||
);
|
||||
|
||||
fn app() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let _ = app.update(Message::LayoutSwitch("Layout1".into()));
|
||||
app
|
||||
}
|
||||
|
||||
fn last_line(app: &OpenCADStudio) -> String {
|
||||
app.command_line.history.last().map(|line| line.text.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The fixture's page setups as the chooser would hold them, read
|
||||
/// through the same document reader the command uses.
|
||||
fn fixture_setups() -> Vec<(String, acadrust::objects::PlotSettings)> {
|
||||
let document = crate::io::load_file(std::path::Path::new(FIXTURE)).unwrap();
|
||||
crate::scene::document_page_setups(&document)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixture_reader_lists_its_named_page_setups() {
|
||||
// The fixture carries its page setups on the layouts, not as named
|
||||
// setups; a drawing with named ones lists them in dictionary order.
|
||||
assert!(fixture_setups().is_empty());
|
||||
let mut app = app();
|
||||
let i = app.active_tab;
|
||||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
app.tabs[i].scene.page_setup_save("Site", ps.clone());
|
||||
app.tabs[i].scene.page_setup_save("Detail", ps);
|
||||
let names: Vec<String> = crate::scene::document_page_setups(&app.tabs[i].scene.document)
|
||||
.into_iter()
|
||||
.map(|(name, _)| name)
|
||||
.collect();
|
||||
assert_eq!(names, ["Site", "Detail"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psetupin_imports_named_setups_from_a_saved_drawing() {
|
||||
use acadrust::objects::PlotRotation;
|
||||
// Author a source drawing with two named setups and save it.
|
||||
let dir = std::env::temp_dir().join(format!("ocs-psetupin-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let source = dir.join("source.dxf");
|
||||
{
|
||||
let mut app = app();
|
||||
let i = app.active_tab;
|
||||
let mut ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
ps.printer_name = "DWG To PDF.pc3".into();
|
||||
ps.paper_size = "ISO_A3_(297.00_x_420.00_MM)".into();
|
||||
ps.paper_width = 297.0;
|
||||
ps.paper_height = 420.0;
|
||||
ps.rotation = PlotRotation::Degrees90;
|
||||
ps.plot_view_handle = acadrust::Handle::new(0xBEEF);
|
||||
app.tabs[i].scene.page_setup_save("Site", ps.clone());
|
||||
ps.paper_size = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||||
app.tabs[i].scene.page_setup_save("Overview", ps);
|
||||
let save = format!(r#"{{"op":"save","path":"{}"}}"#, source.display());
|
||||
let reply = app.automation_op(&save);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
}
|
||||
let mut app = app();
|
||||
let i = app.active_tab;
|
||||
// One named setup already exists under a name the file also has.
|
||||
let own = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
app.tabs[i].scene.page_setup_save("Site", own);
|
||||
// `*` imports everything, replacing the same-named one.
|
||||
let _ = app.run_command_line(&format!("PSETUPIN {} *", source.display()));
|
||||
// The report is translated on localized systems; the counts are not.
|
||||
let report = last_line(&app);
|
||||
assert!(report.starts_with("PSETUPIN"), "{report}");
|
||||
assert!(report.contains('2') && report.contains('1'), "{report}");
|
||||
let names = app.tabs[i].scene.page_setup_names();
|
||||
assert_eq!(names, ["Site", "Overview"]);
|
||||
let site = app.tabs[i].scene.page_setup_get("Site").unwrap();
|
||||
assert_eq!(site.paper_size, "ISO_A3_(297.00_x_420.00_MM)");
|
||||
assert_eq!(site.rotation, PlotRotation::Degrees90);
|
||||
assert!(site.plot_view_handle.is_null(), "foreign handles are dropped");
|
||||
assert!(app.tabs[i].dirty);
|
||||
// Undo takes the whole import back.
|
||||
let _ = app.update(Message::Undo);
|
||||
let names = app.tabs[i].scene.page_setup_names();
|
||||
assert_eq!(names, ["Site"]);
|
||||
assert_ne!(
|
||||
app.tabs[i].scene.page_setup_get("Site").unwrap().paper_size,
|
||||
"ISO_A3_(297.00_x_420.00_MM)"
|
||||
);
|
||||
// A name list imports just those and reports the unknown ones.
|
||||
let _ = app.run_command_line(&format!("PSETUPIN {} Overview,Nothing", source.display()));
|
||||
assert_eq!(app.tabs[i].scene.page_setup_names(), ["Site", "Overview"]);
|
||||
// A missing file is an error, not a crash.
|
||||
let _ = app.run_command_line("PSETUPIN /nowhere/at/all.dwg *");
|
||||
assert!(last_line(&app).starts_with("PSETUPIN:"));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chooser_imports_the_ticked_setups_and_selects_the_first() {
|
||||
let mut app = app();
|
||||
let i = app.active_tab;
|
||||
let ps = app.tabs[i].scene.plot_settings_for("Layout1").unwrap();
|
||||
let _ = app.on_plot_dialog_open();
|
||||
let setups = ["A", "B", "C"]
|
||||
.into_iter()
|
||||
.map(|name| (name.to_string(), ps.clone()))
|
||||
.collect();
|
||||
let loaded = Ok(("other.dwg".to_string(), setups));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Loaded(loaded)));
|
||||
let draft = app.plot_dialog.import_draft.as_ref().expect("chooser open");
|
||||
assert_eq!(draft.file, "other.dwg");
|
||||
assert_eq!(draft.selected().count(), 3, "everything ticked to start with");
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::All(false)));
|
||||
assert_eq!(app.plot_dialog.import_draft.as_ref().unwrap().selected().count(), 0);
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Toggle("B".into())));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Toggle("C".into())));
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Apply));
|
||||
assert!(app.plot_dialog.import_draft.is_none(), "Apply closes the chooser");
|
||||
assert_eq!(app.tabs[i].scene.page_setup_names(), ["B", "C"]);
|
||||
assert_eq!(app.plot_dialog.selected_setup, "B");
|
||||
assert!(app.plot_dialog.page_setups.iter().any(|name| name == "C"));
|
||||
// A failed read shows its reason in the chooser; Cancel closes it.
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Loaded(Err("unreadable".into()))));
|
||||
assert_eq!(
|
||||
app.plot_dialog.import_draft.as_ref().unwrap().error.as_deref(),
|
||||
Some("unreadable")
|
||||
);
|
||||
let _ = app.on_plot_dlg(PlotDlgMsg::Import(I::Cancel));
|
||||
assert!(app.plot_dialog.import_draft.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_layout_opens_its_page_setup_when_the_option_is_on() {
|
||||
let mut app = app();
|
||||
let _ = app.update(Message::LayoutCreate);
|
||||
assert_ne!(app.active_modal, Some(crate::app::ModalKind::Plot));
|
||||
let _ = app.update(Message::PageSetupOnNewLayoutChanged(true));
|
||||
assert!(app.current_config().plot.page_setup_on_new_layout, "persisted");
|
||||
let _ = app.update(Message::LayoutCreate);
|
||||
assert_eq!(app.active_modal, Some(crate::app::ModalKind::Plot));
|
||||
assert_eq!(app.plot_dialog.selected_setup, "*Layout3*");
|
||||
}
|
||||
}
|
||||
@@ -6047,6 +6047,12 @@ properties={:.1}ms picked={}",
|
||||
crate::tf!("Layout \"{new_name}\" created — use MVIEW to add a viewport")
|
||||
.as_ref(),
|
||||
);
|
||||
// The option that greets every new layout with its page setup.
|
||||
let switch_task = if self.plot_dialog.page_setup_on_new_layout {
|
||||
Task::batch([switch_task, self.on_plot_dialog_open()])
|
||||
} else {
|
||||
switch_task
|
||||
};
|
||||
self.tabs[i].dirty = true;
|
||||
return Task::batch([cancel_task, switch_task]);
|
||||
}
|
||||
|
||||
@@ -288,6 +288,7 @@ impl OpenCADStudio {
|
||||
crate::ui::window::options::AppPrefs {
|
||||
savetime_min: self.savetime_min,
|
||||
backup_on_save: self.backup_on_save,
|
||||
page_setup_on_new_layout: self.plot_dialog.page_setup_on_new_layout,
|
||||
textfill: crate::scene::text::sdf_atlas::textfill(),
|
||||
cliprompt_lines: self.cliprompt_lines,
|
||||
commandline_fade_ms: self.commandline_fade_ms,
|
||||
|
||||
@@ -3144,6 +3144,15 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
|
||||
"World" => Some(("common", "world")),
|
||||
"Write Block" => Some(("insert", "write-block")),
|
||||
"X (mm)" => Some(("plot", "x-mm")),
|
||||
"Select Drawing to Import Page Setups From" => Some(("plot", "select-drawing-to-import-page-setups-from")),
|
||||
"Drawings and templates" => Some(("plot", "drawings-and-templates")),
|
||||
"PSETUPIN: {count} page setup(s) imported, {replaced} replaced." => Some(("plot", "psetupin-count-page-setup-s-imported-replaced-replaced")),
|
||||
"PSETUPIN: {error}" => Some(("plot", "psetupin-error")),
|
||||
"PSETUPIN: no page setup named \"{want}\" in {file}." => Some(("plot", "psetupin-no-page-setup-named-want-in-file")),
|
||||
"PSETUPIN: nothing to import from {file}." => Some(("plot", "psetupin-nothing-to-import-from-file")),
|
||||
"No named page setups in this drawing." => Some(("plot", "no-named-page-setups-in-this-drawing")),
|
||||
"Import…" => Some(("plot", "import-ellipsis")),
|
||||
"Show the page setup for new layouts" => Some(("plot", "show-the-page-setup-for-new-layouts")),
|
||||
"X (in)" => Some(("plot", "x-in")),
|
||||
"Y (in)" => Some(("plot", "y-in")),
|
||||
"Hide paperspace objects" => Some(("plot", "hide-paperspace-objects")),
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ pub(crate) mod centermark;
|
||||
pub(crate) mod dimension_assoc;
|
||||
pub(crate) mod dimension_assoc_chain;
|
||||
pub use dimension_assoc::{ReferenceStatus, ResolvedReference};
|
||||
pub use page_setup::{apply_default_page_setup, rotated_margins};
|
||||
pub use page_setup::{apply_default_page_setup, document_page_setups, rotated_margins};
|
||||
mod dwg_native_constraints;
|
||||
mod entity;
|
||||
#[cfg(test)]
|
||||
|
||||
+60
-24
@@ -6,7 +6,7 @@
|
||||
|
||||
use super::Scene;
|
||||
use acadrust::objects::{Dictionary, Layout, ObjectType, PlotSettings};
|
||||
use acadrust::Handle;
|
||||
use acadrust::{CadDocument, Handle};
|
||||
|
||||
/// Give a freshly created paper layout the page setup a new drawing starts
|
||||
/// with — ISO A4 landscape on no plotter, plotted 1:1 as a layout — stored the
|
||||
@@ -59,30 +59,59 @@ pub fn rotated_margins(
|
||||
}
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
/// Handle of the `ACAD_PLOTSETTINGS` dictionary, located robustly.
|
||||
///
|
||||
/// The canonical path is the header's `acad_plotsettings_dict_handle`, but
|
||||
/// DWGs written by other programs don't always leave that pointer resolvable
|
||||
/// (the header handle points at no loaded dictionary — see
|
||||
/// [`crate::scene::annotative::root_named_dict_handle`]). In that case fall
|
||||
/// back to the dictionary that owns the drawing's `PlotSettings` objects,
|
||||
/// mirroring [`Scene::scalelist_dict_handle`]. Returns `None` when the
|
||||
/// drawing genuinely has no named page setups.
|
||||
fn plotsettings_dict_handle(&self) -> Option<Handle> {
|
||||
let dh = self.document.header.acad_plotsettings_dict_handle;
|
||||
if matches!(self.document.objects.get(&dh), Some(ObjectType::Dictionary(_))) {
|
||||
return Some(dh);
|
||||
}
|
||||
let owner = self.document.objects.values().find_map(|o| match o {
|
||||
ObjectType::PlotSettings(ps) => Some(ps.owner),
|
||||
/// Handle of a document's `ACAD_PLOTSETTINGS` dictionary, located robustly.
|
||||
///
|
||||
/// The canonical path is the header's `acad_plotsettings_dict_handle`, but
|
||||
/// DWGs written by other programs don't always leave that pointer resolvable
|
||||
/// (the header handle points at no loaded dictionary — see
|
||||
/// [`crate::scene::annotative::root_named_dict_handle`]). In that case fall
|
||||
/// back to the dictionary that owns the drawing's `PlotSettings` objects,
|
||||
/// mirroring [`Scene::scalelist_dict_handle`]. Returns `None` when the
|
||||
/// drawing genuinely has no named page setups.
|
||||
fn plotsettings_dict_handle_in(document: &CadDocument) -> Option<Handle> {
|
||||
let dh = document.header.acad_plotsettings_dict_handle;
|
||||
if matches!(document.objects.get(&dh), Some(ObjectType::Dictionary(_))) {
|
||||
return Some(dh);
|
||||
}
|
||||
let owner = document.objects.values().find_map(|o| match o {
|
||||
ObjectType::PlotSettings(ps) => Some(ps.owner),
|
||||
_ => None,
|
||||
})?;
|
||||
matches!(document.objects.get(&owner), Some(ObjectType::Dictionary(_))).then_some(owner)
|
||||
}
|
||||
|
||||
/// The named page setups of any document — `(name, settings)` in dictionary
|
||||
/// order. Reads a drawing that is not open in the editor (the source of a
|
||||
/// `PSETUPIN` import) the same way the editor reads its own.
|
||||
pub fn document_page_setups(document: &CadDocument) -> Vec<(String, PlotSettings)> {
|
||||
let Some(dh) = plotsettings_dict_handle_in(document) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(ObjectType::Dictionary(dict)) = document.objects.get(&dh) else {
|
||||
return Vec::new();
|
||||
};
|
||||
dict.entries
|
||||
.iter()
|
||||
.filter_map(|(name, handle)| match document.objects.get(handle) {
|
||||
Some(ObjectType::PlotSettings(ps)) => Some((name.clone(), ps.clone())),
|
||||
_ => None,
|
||||
})?;
|
||||
matches!(
|
||||
self.document.objects.get(&owner),
|
||||
Some(ObjectType::Dictionary(_))
|
||||
)
|
||||
.then_some(owner)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A page setup copied out of another drawing, made safe for this one: the
|
||||
/// handles it pointed at (a plot view, a visual style) meant something only
|
||||
/// in its source, so they are dropped while the names, which travel, stay.
|
||||
pub fn detach_page_setup(ps: &mut PlotSettings) {
|
||||
ps.plot_view_handle = Handle::NULL;
|
||||
ps.visual_style_handle = Handle::NULL;
|
||||
ps.reactors.clear();
|
||||
ps.xdictionary_handle = None;
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
fn plotsettings_dict_handle(&self) -> Option<Handle> {
|
||||
plotsettings_dict_handle_in(&self.document)
|
||||
}
|
||||
|
||||
/// Names of the document's named page setups, in dictionary order.
|
||||
@@ -93,6 +122,13 @@ impl Scene {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bring a page setup from another drawing in under `name`, replacing
|
||||
/// one of that name the way `PSETUPIN` redefines it.
|
||||
pub fn import_page_setup(&mut self, name: &str, mut ps: PlotSettings) {
|
||||
detach_page_setup(&mut ps);
|
||||
self.page_setup_save(name, ps);
|
||||
}
|
||||
|
||||
/// Clone the named page setup's `PlotSettings`, or `None` if absent.
|
||||
pub fn page_setup_get(&self, name: &str) -> Option<PlotSettings> {
|
||||
let dh = self.plotsettings_dict_handle()?;
|
||||
|
||||
+25
-14
@@ -77,6 +77,8 @@ pub struct AppPrefs {
|
||||
pub right_click_mode: RightClickMode,
|
||||
/// SHORTCUTMENUDURATION: time-sensitive hold threshold, ms.
|
||||
pub right_click_hold_ms: i32,
|
||||
/// Open the Plot / Page Setup dialog for every new layout.
|
||||
pub page_setup_on_new_layout: bool,
|
||||
}
|
||||
|
||||
/// The fixed locations the Files page lists.
|
||||
@@ -323,6 +325,29 @@ pub fn view_window<'a>(
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(22),
|
||||
text(crate::t!("Plotting")).size(15),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
text(crate::t!("Plot device, paper, scale and plot styles"))
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
button(text(crate::t!("Plot and Page Setup…")).size(11))
|
||||
.on_press(Message::PlotDialogOpen)
|
||||
.padding([4, 10])
|
||||
.style(button::secondary),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
iced::widget::checkbox(prefs.page_setup_on_new_layout)
|
||||
.on_toggle(Message::PageSetupOnNewLayoutChanged)
|
||||
.size(15),
|
||||
text(crate::t!("Show the page setup for new layouts")).size(12),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(0)
|
||||
.width(sizing.width);
|
||||
@@ -418,20 +443,6 @@ pub fn view_window<'a>(
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(22),
|
||||
text(crate::t!("Plotting")).size(15),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
text(crate::t!("Plot device, paper, scale and plot styles"))
|
||||
.size(12)
|
||||
.width(Fill),
|
||||
button(text(crate::t!("Plot and Page Setup…")).size(11))
|
||||
.on_press(Message::PlotDialogOpen)
|
||||
.padding([4, 10])
|
||||
.style(button::secondary),
|
||||
]
|
||||
.spacing(10)
|
||||
.align_y(iced::Center),
|
||||
]
|
||||
.spacing(0)
|
||||
.width(sizing.width);
|
||||
|
||||
@@ -150,6 +150,43 @@ pub enum PlotFlag {
|
||||
SaveToLayout,
|
||||
}
|
||||
|
||||
/// The page setups of another drawing, offered for import (`PSETUPIN`).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageSetupImportDraft {
|
||||
/// The drawing they come from, as shown to the user.
|
||||
pub file: String,
|
||||
/// Each setup with whether it is ticked for import. The settings are
|
||||
/// carried along so the source file is read once.
|
||||
pub setups: Vec<(String, acadrust::objects::PlotSettings, bool)>,
|
||||
/// Why the file gave nothing, when it did not.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl PageSetupImportDraft {
|
||||
pub fn selected(&self) -> impl Iterator<Item = (&str, &acadrust::objects::PlotSettings)> {
|
||||
self.setups
|
||||
.iter()
|
||||
.filter(|(_, _, on)| *on)
|
||||
.map(|(name, ps, _)| (name.as_str(), ps))
|
||||
}
|
||||
}
|
||||
|
||||
/// Edits of the page-setup import chooser.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PageSetupImportMsg {
|
||||
/// Ask for the drawing to import from.
|
||||
Pick,
|
||||
/// The drawing was read (or could not be): its name and page setups.
|
||||
Loaded(Result<(String, Vec<(String, acadrust::objects::PlotSettings)>), String>),
|
||||
/// Tick or untick one setup.
|
||||
Toggle(String),
|
||||
/// Tick or untick every setup.
|
||||
All(bool),
|
||||
/// Import the ticked setups and close the chooser.
|
||||
Apply,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
/// Every edit the Plot dialog can emit. Wrapped in `Message::PlotDlg` so the
|
||||
/// top-level match stays a single arm.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -208,6 +245,8 @@ pub enum PlotDlgMsg {
|
||||
CopySetup,
|
||||
/// Begin an inline rename of the given page setup row.
|
||||
RenameStart(String),
|
||||
/// Import named page setups from another drawing (`PSETUPIN`).
|
||||
Import(PageSetupImportMsg),
|
||||
/// Delete the selected named page setup.
|
||||
DeleteSetup,
|
||||
/// Live edit of the new/rename name field.
|
||||
@@ -396,6 +435,12 @@ pub struct PlotDialogState {
|
||||
/// The in-line printer-properties editor while it is open.
|
||||
#[serde(skip)]
|
||||
pub printer_editor: Option<PrinterOptionsDraft>,
|
||||
/// The page-setup import chooser while it is open.
|
||||
#[serde(skip)]
|
||||
pub import_draft: Option<PageSetupImportDraft>,
|
||||
/// Open the Plot / Page Setup dialog whenever a new layout is created,
|
||||
/// the way a page-setup manager is expected to greet a new layout.
|
||||
pub page_setup_on_new_layout: bool,
|
||||
/// Chosen printer name, or `None` for the system default.
|
||||
pub printer: Option<String>,
|
||||
/// Output goes to a PDF file instead of a printer.
|
||||
@@ -501,6 +546,8 @@ impl Default for PlotDialogState {
|
||||
custom_editor: None,
|
||||
driver_options: std::collections::BTreeMap::new(),
|
||||
printer_editor: None,
|
||||
import_draft: None,
|
||||
page_setup_on_new_layout: false,
|
||||
printer: None,
|
||||
to_file: false,
|
||||
paper: paper_catalog::default_paper().canonical.to_string(),
|
||||
@@ -727,6 +774,58 @@ fn check_enabled<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The import chooser: the source drawing's name, one tick per page setup,
|
||||
/// All / None, and Import / Cancel.
|
||||
fn page_setup_import_chooser<'a>(
|
||||
draft: &'a PageSetupImportDraft,
|
||||
height: Length,
|
||||
) -> Element<'a, Message> {
|
||||
let msg = |m: PageSetupImportMsg| Message::PlotDlg(PlotDlgMsg::Import(m));
|
||||
let mut rows = column![].spacing(3);
|
||||
if let Some(error) = &draft.error {
|
||||
rows = rows.push(text(error.clone()).size(10).style(muted_style));
|
||||
} else if draft.setups.is_empty() {
|
||||
let none = text(t!("No named page setups in this drawing.")).size(10);
|
||||
rows = rows.push(none.style(muted_style));
|
||||
}
|
||||
for (name, _, on) in &draft.setups {
|
||||
let name = name.clone();
|
||||
rows = rows.push(
|
||||
checkbox(*on)
|
||||
.label(name.clone())
|
||||
.on_toggle(move |_| msg(PageSetupImportMsg::Toggle(name.clone())))
|
||||
.size(13)
|
||||
.text_size(11),
|
||||
);
|
||||
}
|
||||
let all = !draft.setups.is_empty() && draft.setups.iter().all(|(_, _, on)| *on);
|
||||
let any = draft.setups.iter().any(|(_, _, on)| *on);
|
||||
let mut import = button(text(t!("Import")).size(11)).style(btn(true)).padding([4, 8]);
|
||||
if any {
|
||||
import = import.on_press(msg(PageSetupImportMsg::Apply));
|
||||
}
|
||||
column![
|
||||
text(draft.file.clone()).size(10).style(muted_style),
|
||||
scrollable(rows).height(height),
|
||||
checkbox(all)
|
||||
.label(t!("All"))
|
||||
.on_toggle(move |on| msg(PageSetupImportMsg::All(on)))
|
||||
.size(13)
|
||||
.text_size(11),
|
||||
row![
|
||||
import,
|
||||
button(text(t!("Cancel")).size(11))
|
||||
.on_press(msg(PageSetupImportMsg::Cancel))
|
||||
.style(btn(false))
|
||||
.padding([4, 8]),
|
||||
]
|
||||
.spacing(4),
|
||||
]
|
||||
.spacing(6)
|
||||
.padding(4)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The custom-scale row: `Custom: [paper] mm = [drawing] units`. Editing
|
||||
/// either side replaces the picked scale with the typed ratio.
|
||||
fn custom_scale_row<'a>(s: &'a PlotDialogState, enabled: bool) -> Element<'a, Message> {
|
||||
@@ -1018,6 +1117,12 @@ pub fn view_window(
|
||||
} else {
|
||||
Space::new().height(0).into()
|
||||
};
|
||||
// While an import is being chosen, the list shows the other drawing's
|
||||
// setups with a tick each instead of this drawing's.
|
||||
let list_body: Element<'_, Message> = match &s.import_draft {
|
||||
Some(draft) => page_setup_import_chooser(draft, height),
|
||||
None => list_body,
|
||||
};
|
||||
let list_panel = container(
|
||||
column![
|
||||
text(t!("Page setups")).size(10).style(muted_style),
|
||||
@@ -1069,10 +1174,18 @@ pub fn view_window(
|
||||
if is_named && !print_all_options {
|
||||
delete_button = delete_button.on_press(Message::PlotDlg(PlotDlgMsg::DeleteSetup));
|
||||
}
|
||||
let mut import_button = button(text(t!("Import…")).size(11))
|
||||
.style(btn(false))
|
||||
.padding([4, 12]);
|
||||
if !print_all_options && s.import_draft.is_none() {
|
||||
import_button = import_button
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::Import(PageSetupImportMsg::Pick)));
|
||||
}
|
||||
let left_bar = row![
|
||||
new_button,
|
||||
copy_button,
|
||||
delete_button,
|
||||
import_button,
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
|
||||
Reference in new issue
Block a user