diff --git a/src/app/mod.rs b/src/app/mod.rs index 4dfbf6a42..9557c2aae 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -521,6 +521,9 @@ pub(super) struct OpenCADStudio { plot_orientation: crate::io::paper_sizes::Orientation, /// Backing state for the full Plot / Print dialog. plot_dialog: crate::ui::window::plot::PlotDialogState, + /// Snapshot of the dialog's settings taken when it opened, restored by the + /// `` list entry. + plot_prev: Option, // ── Plot Style Table ────────────────────────────────────────────────── /// Currently loaded CTB/STB table (None = no override). @@ -2140,6 +2143,7 @@ impl OpenCADStudio { plot_format: crate::io::paper_sizes::PaperSize::A4, plot_orientation: crate::io::paper_sizes::Orientation::Landscape, plot_dialog: crate::ui::window::plot::PlotDialogState::load(), + plot_prev: None, opening: None, pending_close: None, save_dialog_format: "DWG 2018".to_string(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 94077f61c..abf7ab08b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1253,6 +1253,19 @@ pub(super) fn on_open_file(&mut self) -> Task { let deg = ps.rotation.to_degrees() as i32; d.rotation = format!("{deg}°"); } + self.plot_dialog.name_input = None; + self.plot_dialog.name_rename = false; + // Refresh the list ( / / layouts / named setups) and + // snapshot the just-loaded settings as the `` restore point. + self.refresh_page_setups(); + self.plot_prev = Some(self.plot_dialog.clone()); + // Default selection: model space → ; a layout → its own setup. + let cur = self.tabs[self.active_tab].scene.current_layout.clone(); + if cur == "Model" { + self.select_page_setup(crate::ui::window::plot::SETUP_NONE); + } else { + self.select_page_setup(&format!("*{cur}*")); + } self.active_modal = Some(crate::app::ModalKind::Plot); Task::none() } @@ -1350,17 +1363,255 @@ pub(super) fn on_open_file(&mut self) -> Task { self.close_active_modal(); Task::done(Message::Command("PLOTWINDOW".into())) } + M::SelectSetup(name) => { + self.select_page_setup(&name); + Task::none() + } + M::SetCurrent => { + self.apply_dialog_to_layout(); + self.command_line.push_info("Page setup applied to the layout."); + Task::none() + } + M::NewSetup => { + // Create a setup from the current editor values, then start an + // inline rename so the user can name it. + let name = self.next_page_setup_name("Setup"); + let ps = self.dialog_to_plotsettings(); + self.tabs[self.active_tab].scene.page_setup_save(&name, ps); + self.tabs[self.active_tab].dirty = true; + self.plot_dialog.selected_setup = name.clone(); + self.refresh_page_setups(); + self.plot_dialog.name_input = Some(name); + self.plot_dialog.name_rename = true; + Task::none() + } + M::CopySetup => { + // Duplicate the selected entry — a layout OR a named setup — + // into a new standalone named page setup. + let sel = self.plot_dialog.selected_setup.clone(); + let scene = &self.tabs[self.active_tab].scene; + let (src_ps, base) = if is_layout_entry(&sel) { + let ln = layout_entry_name(&sel); + (scene.plot_settings_for(ln), format!("{ln} copy")) + } else { + (scene.page_setup_get(&sel), format!("{sel} copy")) + }; + if let Some(ps) = src_ps { + let name = self.next_page_setup_name(&base); + self.tabs[self.active_tab].scene.page_setup_save(&name, ps); + self.tabs[self.active_tab].dirty = true; + self.plot_dialog.selected_setup = name.clone(); + self.refresh_page_setups(); + self.plot_dialog.name_input = Some(name); + self.plot_dialog.name_rename = true; + } + Task::none() + } + M::RenameStart(name) => { + // Only standalone named setups can be renamed. + if is_layout_entry(&name) || is_special_entry(&name) { + return Task::none(); + } + self.plot_dialog.selected_setup = name.clone(); + if let Some(ps) = self.tabs[self.active_tab].scene.page_setup_get(&name) { + self.load_plotsettings_into_dialog(&ps); + } + self.plot_dialog.name_input = Some(name); + self.plot_dialog.name_rename = true; + Task::none() + } + M::DeleteSetup => { + let sel = self.plot_dialog.selected_setup.clone(); + if !sel.is_empty() && !is_layout_entry(&sel) { + self.tabs[self.active_tab].scene.page_setup_delete(&sel); + self.tabs[self.active_tab].dirty = true; + self.plot_dialog.selected_setup.clear(); + self.refresh_page_setups(); + } + Task::none() + } + M::NameInput(s) => { + self.plot_dialog.name_input = Some(s); + Task::none() + } + M::NameCommit => { + if let Some(name) = self.plot_dialog.name_input.take() { + let name = name.trim().to_string(); + if !name.is_empty() { + if self.plot_dialog.name_rename { + let old = self.plot_dialog.selected_setup.clone(); + self.tabs[self.active_tab].scene.page_setup_rename(&old, &name); + } else { + let ps = self.dialog_to_plotsettings(); + self.tabs[self.active_tab].scene.page_setup_save(&name, ps); + } + self.tabs[self.active_tab].dirty = true; + self.plot_dialog.selected_setup = name; + self.refresh_page_setups(); + } + } + self.plot_dialog.name_rename = false; + Task::none() + } + M::NameCancel => { + self.plot_dialog.name_input = None; + self.plot_dialog.name_rename = false; + Task::none() + } M::Preview => self.on_plot_dlg_commit(true), M::Commit => self.on_plot_dlg_commit(false), } } - /// Apply the dialog's page settings to the layout, then either open a - /// preview PDF, export a PDF, or send the job to the chosen printer. - fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { + fn refresh_page_setups(&mut self) { + use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV}; + let scene = &self.tabs[self.active_tab].scene; + // / , then layouts (`*name*`), then named setups. + let mut list = vec![SETUP_NONE.to_string(), SETUP_PREV.to_string()]; + list.extend(scene.layout_names().into_iter().map(|n| format!("*{n}*"))); + list.extend(scene.page_setup_names()); + self.plot_dialog.page_setups = list; + } + + /// Apply a page-setup list selection to the editor. Handles the pseudo + /// entries (`` / ``), layout rows (`*name*`) and named + /// setups. + 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 { + // No page setup: default geometry + PDF output. + let is_model = self.tabs[self.active_tab].scene.current_layout == "Model"; + let d = &mut self.plot_dialog; + d.to_file = true; + d.paper = "A4".into(); + d.orientation = "Landscape".into(); + d.area = if is_model { "Window".into() } else { "Layout".into() }; + d.center = true; + d.offset_x = "0.0".into(); + d.offset_y = "0.0".into(); + d.rotation = "0°".into(); + d.scale = "Fit".into(); + } else if name == SETUP_PREV { + if let Some(prev) = self.plot_prev.clone() { + self.plot_dialog.copy_settings_from(&prev); + } + } else if is_layout_entry(name) { + if let Some(ps) = self + .tabs[self.active_tab] + .scene + .plot_settings_for(layout_entry_name(name)) + { + self.load_plotsettings_into_dialog(&ps); + } + } else if let Some(ps) = self.tabs[self.active_tab].scene.page_setup_get(name) { + self.load_plotsettings_into_dialog(&ps); + } + } + + /// A page-setup name based on `base` that isn't already taken (`base`, + /// `base 2`, `base 3`, …). + fn next_page_setup_name(&self, base: &str) -> String { + let existing = self.tabs[self.active_tab].scene.page_setup_names(); + if !existing.iter().any(|n| n == base) { + return base.to_string(); + } + (2..) + .map(|i| format!("{base} {i}")) + .find(|c| !existing.iter().any(|n| n == c)) + .unwrap_or_else(|| base.to_string()) + } + + /// Build a `PlotSettings` from the current dialog fields (for saving a named + /// page setup). + fn dialog_to_plotsettings(&self) -> acadrust::objects::PlotSettings { + use acadrust::objects::{ + PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, + }; + use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; + let d = &self.plot_dialog; + let paper = match d.paper.as_str() { + "A3" => PaperSize::A3, + "A2" => PaperSize::A2, + "A1" => PaperSize::A1, + "A0" => PaperSize::A0, + _ => PaperSize::A4, + }; + let orient = if d.orientation == "Portrait" { + Orientation::Portrait + } else { + Orientation::Landscape + }; + let (w, h) = sheet_mm(paper, orient); + let mut ps = PlotSettings::new(""); + ps.paper_width = w; + ps.paper_height = h; + ps.paper_units = PlotPaperUnits::Millimeters; + ps.plot_type = match d.area.as_str() { + "Window" => PlotType::Window, + "Layout" => PlotType::Layout, + _ => PlotType::Extents, + }; + ps.flags.plot_centered = d.center; + ps.origin_x = d.offset_x.parse::().unwrap_or(0.0); + ps.origin_y = d.offset_y.parse::().unwrap_or(0.0); + let rot: i16 = d.rotation.trim_end_matches('°').parse().unwrap_or(0); + ps.rotation = match rot { + 90 => PlotRotation::Degrees90, + 180 => PlotRotation::Degrees180, + 270 => PlotRotation::Degrees270, + _ => PlotRotation::None, + }; + if d.scale == "Fit" { + ps.set_scale_to_fit(); + } else { + let (num, den) = parse_plot_scale(&d.scale); + ps.scale_type = ScaledType::CustomScale; + ps.scale_numerator = num; + ps.scale_denominator = den; + } + ps.printer_name = d.printer.clone().unwrap_or_default(); + ps.current_style_sheet = d.style_name.clone(); + ps + } + + /// Load a `PlotSettings` into the dialog editor fields. + fn load_plotsettings_into_dialog(&mut self, ps: &acadrust::objects::PlotSettings) { + use acadrust::objects::PlotType; + let (paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); + let d = &mut self.plot_dialog; + d.paper = paper; + d.orientation = orient; + d.area = match ps.plot_type { + PlotType::Window => "Window", + PlotType::Layout => "Layout", + _ => "Extents", + } + .to_string(); + d.center = ps.flags.plot_centered; + d.offset_x = format!("{:.2}", ps.origin_x); + d.offset_y = format!("{:.2}", ps.origin_y); + let deg = ps.rotation.to_degrees() as i32; + d.rotation = format!("{deg}°"); + d.scale = if ps.is_scale_to_fit() { + "Fit".into() + } else { + let n = ps.scale_numerator; + let m = ps.scale_denominator; + if (n - 1.0).abs() < 1e-9 { + format!("1:{}", m as i64) + } else if (m - 1.0).abs() < 1e-9 { + format!("{}:1", n as i64) + } else { + "Fit".into() + } + }; + } + + /// Write the current dialog fields into the active layout's plot settings. + /// Shared by the "Set current" action and the print/export commit. + fn apply_dialog_to_layout(&mut self) { use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; - // Remember the user's print preferences across sessions. - self.plot_dialog.save(); let d = self.plot_dialog.clone(); let paper = match d.paper.as_str() { "A3" => PaperSize::A3, @@ -1375,9 +1626,6 @@ pub(super) fn on_open_file(&mut self) -> Task { Orientation::Landscape }; let (sheet_w, sheet_h) = sheet_mm(paper, orient); - // Persist the dialog's page settings into the layout's PlotSettings / - // paper_limits, then reuse the tested layout-plot derivation. `plot_scale` - // feeds the model-space window plot path. self.plot_format = paper; self.plot_orientation = orient; self.plot_scale = d.scale.clone(); @@ -1388,6 +1636,17 @@ pub(super) fn on_open_file(&mut self) -> Task { self.apply_plot_page_settings( sheet_w, sheet_h, extents, d.center, off_x, off_y, rotation, &d.scale, ); + } + + /// Apply the dialog's page settings to the layout, then either open a + /// preview PDF, export a PDF, or send the job to the chosen printer. + fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { + // Remember the user's print preferences across sessions. + self.plot_dialog.save(); + let d = self.plot_dialog.clone(); + // Persist the dialog's page settings into the layout, then reuse the + // tested layout-plot derivation. + self.apply_dialog_to_layout(); self.active_modal = None; let plot_style = if d.with_styles { diff --git a/src/app/update/util.rs b/src/app/update/util.rs index 933b35738..c09a82ed9 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -39,6 +39,39 @@ fn format_annotation_scale_name(anno: f32) -> String { } } +/// `` / `` are pseudo-entries, not real page setups. +pub(super) fn is_special_entry(s: &str) -> bool { + s == crate::ui::window::plot::SETUP_NONE || s == crate::ui::window::plot::SETUP_PREV +} + +/// A page-setup list entry wrapped in `*…*` is a layout (its embedded page +/// setup), not a standalone named page setup. +pub(super) fn is_layout_entry(s: &str) -> bool { + s.len() >= 2 && s.starts_with('*') && s.ends_with('*') +} + +/// The layout name inside a `*name*` list entry. +pub(super) fn layout_entry_name(s: &str) -> &str { + s.trim_start_matches('*').trim_end_matches('*') +} + +/// Infer the closest A-series paper label and orientation from sheet +/// dimensions (mm). Falls back to A4 when nothing is close. +pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) { + use crate::io::paper_sizes::PaperSize; + let orient = if w >= h { "Landscape" } else { "Portrait" }; + let (short, long) = if w <= h { (w, h) } else { (h, w) }; + let mut best = ("A4".to_string(), f64::INFINITY); + for p in PaperSize::ALL { + let (pw, ph) = p.dimensions_mm(); // portrait: pw < ph + let err = (pw - short).abs() + (ph - long).abs(); + if err < best.1 { + best = (p.label().to_string(), err); + } + } + (best.0, orient.to_string()) +} + pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) { if s == "Fit" { return (1.0, 1.0); diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 1e010a9a2..b4154f0e9 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1466,7 +1466,7 @@ impl OpenCADStudio { PluginManager => (520, 460), UpdateNotice => (560, 460), Layers => (900, 360), - Plot => (660, 540), + Plot => (760, 540), LayoutManager => (640, 320), Plotstyle => (780, 540), TextStyle => (860, 480), diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 1b131f00c..69c71e768 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -48,7 +48,7 @@ impl OpenCADStudio { sized(tab.layers.view_window(), 900, 360) } super::super::ModalKind::Plot => { - sized(crate::ui::window::plot::view_window(&self.plot_dialog), 660, 540) + sized(crate::ui::window::plot::view_window(&self.plot_dialog), 760, 540) } super::super::ModalKind::LayoutManager => { let i = self.active_tab; diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 367c9295d..df0ba47af 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -21,6 +21,7 @@ mod camera_ops; mod layout; mod modify; mod mspace; +mod page_setup; mod paper; mod preview; mod project; @@ -1677,14 +1678,20 @@ impl Scene { /// AutoCAD files keep their settings embedded, so without this fallback the /// plot/PDF path would ignore the file's rotation, origin and scale. pub fn effective_plot_settings(&self) -> Option { + self.plot_settings_for(&self.current_layout) + } + + /// Plot settings for a specific layout by name: its standalone + /// `PlotSettings` object if one exists, else synthesized from the `Layout` + /// object's embedded fields. + pub fn plot_settings_for(&self, name: &str) -> Option { use acadrust::objects::{ ObjectType, PaperMargin, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, PlotWindow, ScaledType, }; - let name = &self.current_layout; if let Some(ps) = self.document.objects.values().find_map(|o| { if let ObjectType::PlotSettings(ps) = o { - if &ps.page_name == name { + if ps.page_name.as_str() == name { return Some(ps.clone()); } } @@ -1694,7 +1701,7 @@ impl Scene { } self.document.objects.values().find_map(|o| { let ObjectType::Layout(l) = o else { return None }; - if &l.name != name { + if l.name.as_str() != name { return None; } let mut ps = PlotSettings::new(l.name.clone()); diff --git a/src/scene/page_setup.rs b/src/scene/page_setup.rs new file mode 100644 index 000000000..ef2f5e5e7 --- /dev/null +++ b/src/scene/page_setup.rs @@ -0,0 +1,126 @@ +//! Named page setups — the reusable plot configurations a drawing stores in +//! its `ACAD_PLOTSETTINGS` dictionary (each a standalone `PlotSettings` object +//! keyed by name). These are distinct from a layout's own embedded plot +//! settings: a named setup can be applied to any layout. CRUD here keeps the +//! dictionary + objects consistent so the result round-trips through DWG/DXF. + +use super::Scene; +use acadrust::objects::{Dictionary, ObjectType, PlotSettings}; +use acadrust::Handle; + +impl Scene { + /// Names of the document's named page setups, in dictionary order. + pub fn page_setup_names(&self) -> Vec { + let dh = self.document.header.acad_plotsettings_dict_handle; + match self.document.objects.get(&dh) { + Some(ObjectType::Dictionary(d)) => d.entries.iter().map(|(k, _)| k.clone()).collect(), + _ => Vec::new(), + } + } + + /// Clone the named page setup's `PlotSettings`, or `None` if absent. + pub fn page_setup_get(&self, name: &str) -> Option { + let dh = self.document.header.acad_plotsettings_dict_handle; + let ObjectType::Dictionary(d) = self.document.objects.get(&dh)? else { + return None; + }; + let h = d.entries.iter().find(|(k, _)| k == name).map(|(_, h)| *h)?; + match self.document.objects.get(&h)? { + ObjectType::PlotSettings(ps) => Some(ps.clone()), + _ => None, + } + } + + /// Handle of the `ACAD_PLOTSETTINGS` dictionary, creating it (and its entry + /// in the root named-objects dictionary) if the drawing has none yet. + fn ensure_plotsettings_dict(&mut self) -> Handle { + let dh = self.document.header.acad_plotsettings_dict_handle; + if matches!(self.document.objects.get(&dh), Some(ObjectType::Dictionary(_))) { + return dh; + } + let root = self.document.header.named_objects_dict_handle; + let mut dict = Dictionary::new(); + dict.handle = self.document.allocate_handle(); + dict.owner = root; + let new_handle = dict.handle; + self.document + .objects + .insert(new_handle, ObjectType::Dictionary(dict)); + self.document.header.acad_plotsettings_dict_handle = new_handle; + if let Some(ObjectType::Dictionary(rd)) = self.document.objects.get_mut(&root) { + rd.entries.retain(|(k, _)| k != "ACAD_PLOTSETTINGS"); + rd.entries.push(("ACAD_PLOTSETTINGS".to_string(), new_handle)); + } + new_handle + } + + /// Handle of the named page setup, if it exists. + fn page_setup_handle(&self, name: &str) -> Option { + let dh = self.document.header.acad_plotsettings_dict_handle; + let ObjectType::Dictionary(d) = self.document.objects.get(&dh)? else { + return None; + }; + d.entries.iter().find(|(k, _)| k == name).map(|(_, h)| *h) + } + + /// Create or update the named page setup from `ps` (its `page_name` and + /// `owner` are set here). Existing entries are updated in place; new ones + /// are inserted and registered in the dictionary. + pub fn page_setup_save(&mut self, name: &str, mut ps: PlotSettings) { + let dict_handle = self.ensure_plotsettings_dict(); + ps.page_name = name.to_string(); + ps.owner = dict_handle; + if let Some(h) = self.page_setup_handle(name) { + ps.handle = h; + self.document.objects.insert(h, ObjectType::PlotSettings(ps)); + } else { + ps.handle = self.document.allocate_handle(); + let h = ps.handle; + self.document.objects.insert(h, ObjectType::PlotSettings(ps)); + if let Some(ObjectType::Dictionary(d)) = self.document.objects.get_mut(&dict_handle) { + d.entries.push((name.to_string(), h)); + } + } + } + + /// Remove the named page setup (object + dictionary entry). No-op if absent. + pub fn page_setup_delete(&mut self, name: &str) { + let dict_handle = self.document.header.acad_plotsettings_dict_handle; + let handle = if let Some(ObjectType::Dictionary(d)) = + self.document.objects.get_mut(&dict_handle) + { + let h = d.entries.iter().find(|(k, _)| k == name).map(|(_, h)| *h); + d.entries.retain(|(k, _)| k != name); + h + } else { + None + }; + if let Some(h) = handle { + self.document.objects.remove(&h); + } + } + + /// Rename a named page setup (dictionary key + the object's `page_name`). + /// No-op if `old` is absent or `new` already exists. + pub fn page_setup_rename(&mut self, old: &str, new: &str) { + if old == new || new.trim().is_empty() { + return; + } + if self.page_setup_handle(new).is_some() { + return; // name collision + } + let dict_handle = self.document.header.acad_plotsettings_dict_handle; + let mut renamed = None; + if let Some(ObjectType::Dictionary(d)) = self.document.objects.get_mut(&dict_handle) { + if let Some(e) = d.entries.iter_mut().find(|(k, _)| k == old) { + e.0 = new.to_string(); + renamed = Some(e.1); + } + } + if let Some(h) = renamed { + if let Some(ObjectType::PlotSettings(ps)) = self.document.objects.get_mut(&h) { + ps.page_name = new.to_string(); + } + } + } +} diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index f434f71e0..77440f343 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -7,7 +7,8 @@ use crate::app::Message; use crate::io::paper_sizes::PaperSize; use iced::widget::{ - button, checkbox, column, container, pick_list, row, scrollable, text, text_input, Space, + button, checkbox, column, container, mouse_area, pick_list, row, scrollable, text, text_input, + Space, }; use iced::{Background, Border, Color, Element, Fill, Length, Theme}; @@ -18,11 +19,18 @@ const TEXT: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; const DIM: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; const ACCENT: Color = Color { r: 0.25, g: 0.50, b: 0.85, a: 1.0 }; const FIELD: Color = Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }; +const ACTIVE: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 }; +const LIST: Color = Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 }; /// Sentinel entries in the printer dropdown (not real printer names). pub const OUT_DEFAULT: &str = "System default printer"; pub const OUT_PDF: &str = "Save to PDF file…"; +/// Top-of-list entries: no page setup (defaults + PDF), and the last-used +/// settings captured when the dialog opened. +pub const SETUP_NONE: &str = ""; +pub const SETUP_PREV: &str = ""; + /// One of the many boolean plot options (folded into a single message so the /// dialog needn't carry a variant per checkbox). #[derive(Debug, Clone, Copy)] @@ -62,6 +70,25 @@ pub enum PlotDlgMsg { LoadStyle, ClearStyle, PickWindow, + // ── Named page-setup manager ───────────────────────────────────────── + /// Pick a named page setup (loads its values into the editor). + SelectSetup(String), + /// Write the current editor values into the active layout. + SetCurrent, + /// Create a new named page setup from the current editor values. + NewSetup, + /// Duplicate the selected page setup. + CopySetup, + /// Begin an inline rename of the given page setup row. + RenameStart(String), + /// Delete the selected named page setup. + DeleteSetup, + /// Live edit of the new/rename name field. + NameInput(String), + /// Confirm the new/rename name. + NameCommit, + /// Cancel the new/rename name row. + NameCancel, } /// Transient state backing the Plot dialog. Seeded from the layout's plot @@ -98,6 +125,14 @@ pub struct PlotDialogState { pub save_layout: bool, /// Display name of the active plot style table ("" = none). pub style_name: String, + /// Named page setups in the document (refreshed when the dialog opens). + pub page_setups: Vec, + /// Currently selected named page setup ("" = none / current layout). + pub selected_setup: String, + /// When `Some`, a name-entry row is showing (for New / Rename). + pub name_input: Option, + /// `true` when `name_input` is renaming the selected setup, else creating. + pub name_rename: bool, } impl Default for PlotDialogState { @@ -128,11 +163,45 @@ impl Default for PlotDialogState { stamp: false, save_layout: false, style_name: String::new(), + page_setups: Vec::new(), + selected_setup: String::new(), + name_input: None, + name_rename: false, } } } impl PlotDialogState { + /// Copy the plot-setting fields (paper, scale, output options, …) from + /// `o`, leaving list / rename / runtime UI state untouched. Used to restore + /// the `` snapshot. + pub fn copy_settings_from(&mut self, o: &PlotDialogState) { + self.printer = o.printer.clone(); + self.to_file = o.to_file; + self.paper = o.paper.clone(); + self.orientation = o.orientation.clone(); + self.rotation = o.rotation.clone(); + self.copies = o.copies.clone(); + self.area = o.area.clone(); + self.center = o.center; + self.offset_x = o.offset_x.clone(); + self.offset_y = o.offset_y.clone(); + self.scale = o.scale.clone(); + self.scale_lw = o.scale_lw; + self.quality = o.quality.clone(); + self.dpi = o.dpi.clone(); + self.shade = o.shade.clone(); + self.mono = o.mono; + self.lineweights = o.lineweights; + self.with_styles = o.with_styles; + self.transparency = o.transparency; + self.paperspace_last = o.paperspace_last; + self.hide_paperspace = o.hide_paperspace; + self.stamp = o.stamp; + self.save_layout = o.save_layout; + self.style_name = o.style_name.clone(); + } + /// Load the persisted print preferences (printer, copies, quality, output /// options) from `/OpenCADStudio/plot.txt`. Drawing-specific fields /// (paper, scale, offset, rotation…) are NOT persisted here — they are @@ -271,6 +340,50 @@ fn section_label<'a>(s: &'static str) -> Element<'a, Message> { text(s).size(11).color(DIM).into() } +fn vsep<'a>() -> Element<'a, Message> { + container(Space::new().width(1).height(Fill)) + .width(1) + .height(Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BORDER)), + ..Default::default() + }) + .into() +} + +/// One row in the page-setup list: a selectable name, or an inline rename field +/// when this row is being renamed. +fn setup_row<'a>( + name: &'a str, + selected: &str, + renaming: Option<&str>, + rename_buf: &'a str, +) -> Element<'a, Message> { + if renaming == Some(name) { + return text_input("", rename_buf) + .on_input(|v| Message::PlotDlg(PlotDlgMsg::NameInput(v))) + .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) + .style(field_style) + .size(11) + .padding([4, 8]) + .width(Fill) + .into(); + } + let is_sel = name == selected; + let cell = container(text(name.to_string()).size(11).color(TEXT)) + .padding([4, 8]) + .width(Fill) + .style(move |_: &Theme| container::Style { + background: is_sel.then_some(Background::Color(ACTIVE)), + text_color: Some(TEXT), + ..Default::default() + }); + mouse_area(cell) + .on_press(Message::PlotDlg(PlotDlgMsg::SelectSetup(name.to_string()))) + .on_double_click(Message::PlotDlg(PlotDlgMsg::RenameStart(name.to_string()))) + .into() +} + /// A `label : dropdown` row. `ctor` turns the picked string into a dialog /// message. fn drop_row<'a>( @@ -326,22 +439,52 @@ fn strs(items: &[&str]) -> Vec { pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { // ── Toolbar: Cancel … Preview Print/Export ────────────────────────── let action = if s.to_file { "Export PDF" } else { "Print" }; + // `` / `` are pseudo-entries; layout rows are `*name*`. + let is_special = s.selected_setup == SETUP_NONE || s.selected_setup == SETUP_PREV; + let sel_is_layout = s.selected_setup.len() >= 2 + && s.selected_setup.starts_with('*') + && s.selected_setup.ends_with('*'); + let can_copy = !s.selected_setup.is_empty() && !is_special; + let is_named = can_copy && !sel_is_layout; + let mut left_bar = row![button(text("New").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::NewSetup)) + .style(btn(false)) + .padding([4, 12])] + .spacing(4); + if can_copy { + left_bar = left_bar.push( + button(text("Copy").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::CopySetup)) + .style(btn(false)) + .padding([4, 12]), + ); + } + if is_named { + left_bar = left_bar.push( + button(text("Delete").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::DeleteSetup)) + .style(btn(false)) + .padding([4, 12]), + ); + } let toolbar = container( row![ - button(text("Cancel").size(12)) - .on_press(Message::PlotDlg(PlotDlgMsg::Close)) - .style(btn(false)) - .padding([4, 14]), + left_bar, Space::new().width(Fill), - button(text("Preview").size(12)) + button(text("Set current").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::SetCurrent)) + .style(btn(false)) + .padding([4, 12]), + Space::new().width(6), + button(text("Preview").size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::Preview)) .style(btn(false)) .padding([4, 12]), - Space::new().width(8), - button(text(action).size(12)) + Space::new().width(6), + button(text(action).size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::Commit)) .style(btn(true)) - .padding([4, 20]), + .padding([4, 18]), ] .align_y(iced::Center), ) @@ -364,6 +507,54 @@ pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { let paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); + // ── Left panel: named page-setup list (click selects, double-click renames) + let renaming = if s.name_rename && !s.selected_setup.is_empty() { + Some(s.selected_setup.as_str()) + } else { + None + }; + let rename_buf = s.name_input.as_deref().unwrap_or(""); + let rows: Vec> = s + .page_setups + .iter() + .map(|name| setup_row(name, &s.selected_setup, renaming, rename_buf)) + .collect(); + let list_body: Element<'_, Message> = if rows.is_empty() { + container(text("(no page setups)").size(11).color(DIM)) + .padding([6, 8]) + .into() + } else { + scrollable(column(rows).spacing(1)).height(Fill).into() + }; + let list_panel = container( + column![ + text("Page setups").size(10).color(DIM), + container(list_body) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(LIST)), + border: Border { + color: BORDER, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .width(Fill) + .height(Fill) + .padding(2), + ] + .spacing(4) + .height(Fill), + ) + .width(160) + .height(Fill) + .padding(iced::Padding { + top: 12.0, + right: 8.0, + bottom: 12.0, + left: 12.0, + }); + // ── Left column ────────────────────────────────────────────────────── let style_label: String = if s.style_name.is_empty() { "(none)".into() @@ -507,10 +698,14 @@ pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { .spacing(9) .width(Fill); - let body = row![left, right].spacing(18).padding(14).width(Fill); - let content = scrollable(body).width(Fill).height(Fill); + let detail = scrollable( + container(row![left, right].spacing(18).width(Fill)).padding(14), + ) + .width(Fill) + .height(Fill); + let body = row![list_panel, vsep(), detail].height(Fill); - container(column![toolbar, hdivider(), content].spacing(0)) + container(column![toolbar, hdivider(), body].spacing(0)) .style(|_: &Theme| container::Style { background: Some(Background::Color(BG)), ..Default::default()