diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index b78867769..7917b4356 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1187,10 +1187,11 @@ impl OpenCADStudio { self.command_line.push_output(&format!( "Plot window: {x0:.2},{y0:.2} to {x1:.2},{y1:.2}" )); - // Pick window closed Page Setup so the viewport could - // receive the two clicks — bring it back for scale - // review and the "Plot window → PDF" button. - self.active_modal = Some(super::ModalKind::PageSetup); + // Pick window closed the plot dialog so the viewport could + // receive the two clicks — bring the dialog back with the + // window now active. + self.plot_dialog.area = "Window".to_string(); + self.active_modal = Some(super::ModalKind::Plot); } else { let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let doc = &mut self.tabs[i].scene.document; diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 7a0dd3e51..8e21cf6f2 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -358,12 +358,13 @@ impl OpenCADStudio { } // ── Plot / Page Setup ────────────────────────────────────────── - "PLOT" | "EXPORT" | "EXPORTPDF" => { - return Some(Task::done(Message::PlotExport)); + // PLOT / PRINT open the full plot dialog (printer, paper, scale, + // options); EXPORT / EXPORTPDF stay a direct PDF export. + "PLOT" | "PRINT" => { + return Some(Task::done(Message::PlotDialogOpen)); } - // PRINT — send current layout to the system default printer. - "PRINT" => { - return Some(Task::done(Message::PrintToPrinter)); + "EXPORT" | "EXPORTPDF" => { + return Some(Task::done(Message::PlotExport)); } // PLOTSTYLE — load or clear CTB/STB plot style table cmd if cmd == "PLOTSTYLE" || cmd.starts_with("PLOTSTYLE ") => { @@ -509,11 +510,9 @@ impl OpenCADStudio { } } - // Model space now has its own dialog section (window-plot format / - // orientation / pick), so PAGESETUP opens there too, not just on - // paper-space layouts. + // PAGESETUP is folded into the unified plot dialog. "PAGESETUP" => { - return Some(Task::done(Message::PageSetupOpen)); + return Some(Task::done(Message::PlotDialogOpen)); } // ── Recognized commands whose full implementation is pending ───────── diff --git a/src/app/mod.rs b/src/app/mod.rs index afc432fde..4dfbf6a42 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -513,26 +513,14 @@ pub(super) struct OpenCADStudio { last_vp_click_time: Option, /// Screen position of the previous viewport left-click release. last_vp_click_pos: Option, - /// Editable paper width buffer for the Page Setup panel (string while typing). - page_setup_w: String, - /// Editable paper height buffer for the Page Setup panel (string while typing). - page_setup_h: String, - /// Plot area type: "Layout" | "Extents". - page_setup_plot_area: String, - /// Center the drawing on the page when exporting. - page_setup_center: bool, - /// Plot offset X in mm (applied after optional centering). - page_setup_offset_x: String, - /// Plot offset Y in mm. - page_setup_offset_y: String, - /// Plot rotation in degrees: "0" | "90" | "180" | "270". - page_setup_rotation: String, - /// Plot scale: "Fit" | "1:1" | "1:2" | "1:4" | "1:5" | "1:10" | "1:20" | "1:50" | "1:100" | "2:1". - page_setup_scale: String, + /// Plot scale for model-space window plots: "Fit" | "1:1" | … | "2:1". + plot_scale: String, /// Pending model-space plot window (x0, y0, x1, y1) in world XY, or None. plot_window: Option<(f64, f64, f64, f64)>, plot_format: crate::io::paper_sizes::PaperSize, plot_orientation: crate::io::paper_sizes::Orientation, + /// Backing state for the full Plot / Print dialog. + plot_dialog: crate::ui::window::plot::PlotDialogState, // ── Plot Style Table ────────────────────────────────────────────────── /// Currently loaded CTB/STB table (None = no override). @@ -1058,7 +1046,7 @@ pub enum ModalKind { PluginManager, UpdateNotice, Layers, - PageSetup, + Plot, LayoutManager, Plotstyle, TextStyle, @@ -1791,30 +1779,6 @@ pub enum Message { /// User clicked the "Open release page" button — opens the GitHub /// release URL in the OS default browser and closes the notice. UpdateNoticeOpenRelease, - // ── Page Setup ──────────────────────────────────────────────────────── - /// Open the Page Setup panel for the current layout. - PageSetupOpen, - /// Close (cancel) the Page Setup panel without applying changes. - PageSetupClose, - /// Live-edit of the paper width field. - PageSetupWidthEdit(String), - /// Live-edit of the paper height field. - PageSetupHeightEdit(String), - /// User selected a paper size preset (e.g. "A4 Portrait"). - PageSetupPreset(String), - /// User changed the plot area type ("Layout" or "Extents"). - PageSetupPlotArea(String), - /// Toggle center-on-page. - PageSetupCenterToggle, - /// Live-edit of plot offset X. - PageSetupOffsetXEdit(String), - /// Live-edit of plot offset Y. - PageSetupOffsetYEdit(String), - /// User changed plot rotation. - PageSetupRotation(String), - PageSetupScale(String), - /// Apply the changes entered in Page Setup. - PageSetupCommit, // ── Plot / Export ───────────────────────────────────────────────────── /// Show the SVG save-file dialog and trigger export. PlotExport, @@ -1832,6 +1796,10 @@ pub enum Message { PrintToPrinter, /// Callback from the async printer job. PrintResult(Result), + /// Open the full Plot / Print dialog (seeds state from the layout). + PlotDialogOpen, + /// An edit inside the Plot / Print dialog. + PlotDlg(crate::ui::window::plot::PlotDlgMsg), // ── Plot Style Table ───────────────────────────────────────────────── /// Open file dialog to load a CTB/STB plot style table. PlotStyleLoad, @@ -2167,17 +2135,11 @@ impl OpenCADStudio { layout_rename_state: None, last_vp_click_time: None, last_vp_click_pos: None, - page_setup_w: String::new(), - page_setup_h: String::new(), - page_setup_plot_area: "Layout".to_string(), - page_setup_center: true, - page_setup_offset_x: "0.0".to_string(), - page_setup_offset_y: "0.0".to_string(), - page_setup_rotation: "0".to_string(), - page_setup_scale: "Fit".to_string(), + plot_scale: "Fit".to_string(), plot_window: None, plot_format: crate::io::paper_sizes::PaperSize::A4, plot_orientation: crate::io::paper_sizes::Orientation::Landscape, + plot_dialog: crate::ui::window::plot::PlotDialogState::load(), 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 ca24cba21..94077f61c 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -808,18 +808,28 @@ pub(super) fn on_open_file(&mut self) -> Task { iced::exit() } - pub(super) fn on_page_setup_commit(&mut self) -> Task { + /// Write the given plot page settings into the active layout's Layout + + /// PlotSettings objects (paper size, plot area, offset, rotation, scale). + /// No-op on the Model tab (which has no paper layout). Marks the tab dirty + /// and re-tessellates the sheet. Called by the Plot dialog on commit. + #[allow(clippy::too_many_arguments)] + pub(super) fn apply_plot_page_settings( + &mut self, + w: f64, + h: f64, + extents: bool, + center: bool, + offset_x: f64, + offset_y: f64, + rotation: i16, + scale_str: &str, + ) { let i = self.active_tab; let layout_name = self.tabs[i].scene.current_layout.clone(); if layout_name != "Model" { - let w: f64 = self.page_setup_w.parse::().unwrap_or(297.0).max(1.0); - let h: f64 = self.page_setup_h.parse::().unwrap_or(210.0).max(1.0); - let plot_area = self.page_setup_plot_area.clone(); - let center = self.page_setup_center; - let offset_x = self.page_setup_offset_x.parse::().unwrap_or(0.0); - let offset_y = self.page_setup_offset_y.parse::().unwrap_or(0.0); - let rotation: i16 = self.page_setup_rotation.parse().unwrap_or(0); - let scale_str = self.page_setup_scale.clone(); + let w: f64 = w.max(1.0); + let h: f64 = h.max(1.0); + let plot_area = if extents { "Extents" } else { "Layout" }; // Update the Layout object's limits AND its embedded // PlotSettings fields. `paper_limits()` (sheet rendering) and @@ -905,7 +915,7 @@ pub(super) fn on_open_file(&mut self) -> Task { }; // Apply plot scale. use acadrust::objects::ScaledType; - let (num, den) = parse_plot_scale(&scale_str); + let (num, den) = parse_plot_scale(scale_str); if scale_str == "Fit" { ps.set_scale_to_fit(); } else { @@ -924,8 +934,6 @@ pub(super) fn on_open_file(&mut self) -> Task { center={center} rot={rotation}°" )); } - self.active_modal = None; - Task::none() } pub(super) fn on_plot_export_path_some(&mut self, path: std::path::PathBuf) -> Task { @@ -1062,10 +1070,10 @@ pub(super) fn on_open_file(&mut self) -> Task { let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); let win_w = (x1 - x0).max(1e-9); let win_h = (y1 - y0).max(1e-9); - let scale_sel = if self.page_setup_scale.trim().eq_ignore_ascii_case("fit") { + let scale_sel = if self.plot_scale.trim().eq_ignore_ascii_case("fit") { PlotScale::Fit } else { - let (num, den) = parse_plot_scale(&self.page_setup_scale); + let (num, den) = parse_plot_scale(&self.plot_scale); if num > 0.0 && den > 0.0 { PlotScale::Ratio(num / den) } else { @@ -1126,70 +1134,442 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::none() } - pub(super) fn on_print_to_printer(&mut self) -> Task { - let i = self.active_tab; - let scene = &self.tabs[i].scene; - let wires = scene.entity_wires(); - let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone(); - let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone(); - use acadrust::objects::PlotType; - let ps_snap = scene.effective_plot_settings(); - let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) = - if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { - let (pw, ph) = (x1 - x0, y1 - y0); - let use_extents = ps_snap - .as_ref() - .map(|ps| matches!(ps.plot_type, PlotType::Extents)) - .unwrap_or(false); - let (ox, oy) = if use_extents { - if let Some((mn, _mx)) = scene.model_space_extents() { - (-mn.x as f64, -mn.y as f64) - } else { - (-x0, -y0) - } - } else { - (-x0, -y0) - }; - let rot = ps_snap - .as_ref() - .map(|ps| ps.rotation.to_degrees() as i32) - .unwrap_or(0); - (pw, ph, ox, oy, rot) + /// Build the render inputs and page geometry for a full-layout plot: wires + /// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size, + /// the draw-origin offset, and the rotation. Shared by print + preview so + /// the Plot dialog reuses the same tested derivation. + #[allow(clippy::type_complexity)] + pub(super) fn layout_plot_params( + &self, + ) -> ( + Vec, + Vec, + Vec, + f64, + f64, + f32, + f32, + i32, + ) { + let i = self.active_tab; + let scene = &self.tabs[i].scene; + let wires = scene.entity_wires(); + let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone(); + let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone(); + use acadrust::objects::PlotType; + let ps_snap = scene.effective_plot_settings(); + let (paper_w, paper_h, draw_ox, draw_oy, rotation_deg) = + if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { + let (pw, ph) = (x1 - x0, y1 - y0); + let use_extents = ps_snap + .as_ref() + .map(|ps| matches!(ps.plot_type, PlotType::Extents)) + .unwrap_or(false); + let (ox, oy) = if use_extents { + if let Some((mn, _mx)) = scene.model_space_extents() { + (-mn.x as f64, -mn.y as f64) } else { - if let Some((mn, mx)) = scene.model_space_extents() { - let margin = 1.05_f64; - let w = ((mx.x - mn.x) as f64 * margin).max(1.0); - let h = ((mx.y - mn.y) as f64 * margin).max(1.0); - let pad_x = (w - (mx.x - mn.x) as f64) * 0.5; - let pad_y = (h - (mx.y - mn.y) as f64) * 0.5; - (w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0) - } else { - (297.0, 210.0, 0.0, 0.0, 0) - } - }; - let (eff_w, eff_h) = match rotation_deg { - 90 | 270 => (paper_h, paper_w), - _ => (paper_w, paper_h), + (-x0, -y0) + } + } else { + (-x0, -y0) }; - let plot_style = self.active_plot_style.clone(); - self.command_line.push_info("Sending to system printer…"); - Task::perform( - async move { - crate::io::print_to_printer::print_wires( - wires, - hatches, - wipeouts, - eff_w, - eff_h, - draw_ox as f32, - draw_oy as f32, - rotation_deg, - plot_style, - ) - .await - }, - Message::PrintResult, + let rot = ps_snap + .as_ref() + .map(|ps| ps.rotation.to_degrees() as i32) + .unwrap_or(0); + (pw, ph, ox, oy, rot) + } else if let Some((mn, mx)) = scene.model_space_extents() { + let margin = 1.05_f64; + let w = ((mx.x - mn.x) as f64 * margin).max(1.0); + let h = ((mx.y - mn.y) as f64 * margin).max(1.0); + let pad_x = (w - (mx.x - mn.x) as f64) * 0.5; + let pad_y = (h - (mx.y - mn.y) as f64) * 0.5; + (w, h, -(mn.x as f64) + pad_x, -(mn.y as f64) + pad_y, 0) + } else { + (297.0, 210.0, 0.0, 0.0, 0) + }; + let (eff_w, eff_h) = match rotation_deg { + 90 | 270 => (paper_h, paper_w), + _ => (paper_w, paper_h), + }; + ( + wires, + hatches, + wipeouts, + eff_w, + eff_h, + draw_ox as f32, + draw_oy as f32, + rotation_deg, + ) + } + + pub(super) fn on_print_to_printer(&mut self) -> Task { + let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg) = + self.layout_plot_params(); + let plot_style = self.active_plot_style.clone(); + self.command_line.push_info("Sending to system printer…"); + Task::perform( + async move { + crate::io::print_to_printer::print_wires( + wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rotation_deg, plot_style, ) + .await + }, + Message::PrintResult, + ) + } + + /// Open the full Plot / Print dialog, seeding its state from the active + /// layout's plot settings and the printers found on the system. + pub(super) fn on_plot_dialog_open(&mut self) -> Task { + use crate::io::paper_sizes::Orientation; + // Keep the user's last print preferences (printer, copies, quality, + // output options — persisted in `self.plot_dialog`); only refresh the + // runtime printer list and reseed the drawing-specific fields from the + // active layout. + let d = &mut self.plot_dialog; + d.printers = crate::io::print_to_printer::list_printers(); + d.paper = self.plot_format.label().to_string(); + d.orientation = match self.plot_orientation { + Orientation::Portrait => "Portrait", + Orientation::Landscape => "Landscape", + } + .to_string(); + d.style_name = self + .active_plot_style + .as_ref() + .map(|t| t.name.clone()) + .unwrap_or_default(); + // `area` and `scale` are remembered user choices (default Window / Fit), + // not reseeded from the layout. Offset / center / rotation ARE layout + // properties, so reflect them. + if let Some(ps) = self.tabs[self.active_tab].scene.effective_plot_settings() { + let d = &mut self.plot_dialog; + 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}°"); + } + self.active_modal = Some(crate::app::ModalKind::Plot); + Task::none() + } + + /// Handle one edit / action from the Plot dialog. + pub(super) fn on_plot_dlg( + &mut self, + msg: crate::ui::window::plot::PlotDlgMsg, + ) -> Task { + use crate::ui::window::plot::{PlotDlgMsg as M, PlotFlag, OUT_DEFAULT, OUT_PDF}; + match msg { + M::Close => { + self.close_active_modal(); + Task::none() + } + M::Printer(s) => { + if s == OUT_PDF { + self.plot_dialog.to_file = true; + } else if s == OUT_DEFAULT { + self.plot_dialog.to_file = false; + self.plot_dialog.printer = None; + } else { + self.plot_dialog.to_file = false; + self.plot_dialog.printer = Some(s); + } + Task::none() + } + M::Paper(s) => { + self.plot_dialog.paper = s; + Task::none() + } + M::Orientation(s) => { + self.plot_dialog.orientation = s; + Task::none() + } + M::Rotation(s) => { + self.plot_dialog.rotation = s; + Task::none() + } + M::Area(s) => { + self.plot_dialog.area = s; + Task::none() + } + M::Scale(s) => { + self.plot_dialog.scale = s; + Task::none() + } + M::Quality(s) => { + self.plot_dialog.quality = s; + Task::none() + } + M::Shade(s) => { + self.plot_dialog.shade = s; + Task::none() + } + M::Copies(s) => { + self.plot_dialog.copies = s; + Task::none() + } + M::OffsetX(s) => { + self.plot_dialog.offset_x = s; + Task::none() + } + M::OffsetY(s) => { + self.plot_dialog.offset_y = s; + Task::none() + } + M::Dpi(s) => { + self.plot_dialog.dpi = s; + Task::none() + } + M::Flag(f) => { + let d = &mut self.plot_dialog; + match f { + PlotFlag::Center => d.center = !d.center, + PlotFlag::ScaleLw => d.scale_lw = !d.scale_lw, + PlotFlag::Mono => d.mono = !d.mono, + PlotFlag::Lineweights => d.lineweights = !d.lineweights, + PlotFlag::WithStyles => d.with_styles = !d.with_styles, + PlotFlag::Transparency => d.transparency = !d.transparency, + PlotFlag::PaperspaceLast => d.paperspace_last = !d.paperspace_last, + PlotFlag::HidePaperspace => d.hide_paperspace = !d.hide_paperspace, + PlotFlag::Stamp => d.stamp = !d.stamp, + PlotFlag::SaveLayout => d.save_layout = !d.save_layout, + } + Task::none() + } + M::LoadStyle => Task::done(Message::PlotStyleLoad), + M::ClearStyle => { + self.active_plot_style = None; + self.plot_dialog.style_name.clear(); + Task::none() + } + M::PickWindow => { + self.close_active_modal(); + Task::done(Message::Command("PLOTWINDOW".into())) + } + 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 { + 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, + "A2" => PaperSize::A2, + "A1" => PaperSize::A1, + "A0" => PaperSize::A0, + _ => PaperSize::A4, + }; + let orient = if d.orientation == "Portrait" { + Orientation::Portrait + } else { + 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(); + let rotation: i16 = d.rotation.trim_end_matches('°').parse().unwrap_or(0); + let off_x = d.offset_x.parse::().unwrap_or(0.0); + let off_y = d.offset_y.parse::().unwrap_or(0.0); + let extents = d.area != "Layout"; + self.apply_plot_page_settings( + sheet_w, sheet_h, extents, d.center, off_x, off_y, rotation, &d.scale, + ); + self.active_modal = None; + + let plot_style = if d.with_styles { + self.active_plot_style.clone() + } else { + None + }; + + // ── Window area: clipped model-space plot ──────────────────────────── + if d.area == "Window" { + // Preview renders to a temp PDF and opens it — never a save dialog, + // whatever the output target is. + if preview { + let Some((w_wires, w_hatches, w_wipeouts, sw, sh, wox, woy, wscale, wclip)) = + self.window_plot_job() + else { + self.command_line + .push_error("Pick a plot window first (model space)."); + self.active_modal = Some(crate::app::ModalKind::Plot); + return Task::none(); + }; + let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); + let exp = crate::io::pdf_export::export_pdf( + &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp, + plot_style.as_ref(), + ); + match exp.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) { + Ok(()) => self.command_line.push_info("Opened plot preview."), + Err(e) => self.command_line.push_error(&format!("Preview failed: {e}")), + } + self.active_modal = Some(crate::app::ModalKind::Plot); + return Task::none(); + } + if d.to_file { + // Tested clipped export (opens a save dialog). + return Task::done(Message::PlotWindowExport); + } + let Some((w_wires, w_hatches, w_wipeouts, sw, sh, wox, woy, wscale, wclip)) = + self.window_plot_job() + else { + self.command_line + .push_error("Pick a plot window first (model space)."); + self.active_modal = Some(crate::app::ModalKind::Plot); + return Task::none(); + }; + let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf"); + let exp = crate::io::pdf_export::export_pdf( + &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, 0, wscale, wclip, &tmp, + plot_style.as_ref(), + ); + let opts = self.plot_print_options(&d); + match exp.and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) { + Ok(printer) => self + .command_line + .push_info(&format!("Sent to printer: {printer}")), + Err(e) => self.command_line.push_error(&format!("Print failed: {e}")), + } + return Task::none(); + } + + let (wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot) = self.layout_plot_params(); + + if preview { + let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); + let res = crate::io::pdf_export::export_pdf( + &wires, + &hatches, + &wipeouts, + eff_w, + eff_h, + ox, + oy, + rot, + 1.0, + None, + &tmp, + plot_style.as_ref(), + ); + match res.and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) { + Ok(()) => self.command_line.push_info("Opened plot preview."), + Err(e) => self.command_line.push_error(&format!("Preview failed: {e}")), + } + // Preview leaves the dialog open for further tweaks. + self.active_modal = Some(crate::app::ModalKind::Plot); + return Task::none(); + } + + if d.to_file { + // Reuse the tested PDF export flow (it opens a save dialog). + return Task::done(Message::PlotExport); + } + + let opts = self.plot_print_options(&d); + self.command_line.push_info("Sending to system printer…"); + Task::perform( + async move { + crate::io::print_to_printer::print_wires_with( + wires, hatches, wipeouts, eff_w, eff_h, ox, oy, rot, plot_style, opts, + ) + .await + }, + Message::PrintResult, + ) + } + + /// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from + /// the dialog state. + fn plot_print_options( + &self, + d: &crate::ui::window::plot::PlotDialogState, + ) -> crate::io::print_to_printer::PrintOptions { + crate::io::print_to_printer::PrintOptions { + printer: d.printer.clone(), + copies: d.copies.trim().parse::().unwrap_or(1).max(1), + mono: d.mono, + quality: Some(d.quality.clone()), + dpi: d.dpi.trim().parse::().ok().filter(|v| *v > 0), + } + } + + /// Render inputs + page geometry for a clipped model-space window plot + /// (the pending `plot_window`, fitted onto the dialog's sheet). Returns + /// `None` when not in model space or no window has been picked. Mirrors + /// [`Self::on_plot_window_export_path_some`] but hands back the params so + /// the plot can go to a printer or preview, not only a save-dialog PDF. + #[allow(clippy::type_complexity)] + fn window_plot_job( + &self, + ) -> Option<( + Vec, + Vec, + Vec, + f64, + f64, + f32, + f32, + f32, + Option<(f32, f32, f32, f32)>, + )> { + use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale}; + let i = self.active_tab; + if self.tabs[i].scene.current_layout != "Model" { + return None; + } + let (x0, y0, x1, y1) = self.plot_window?; + if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { + return None; + } + let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); + let win_w = (x1 - x0).max(1e-9); + let win_h = (y1 - y0).max(1e-9); + let scale_sel = if self.plot_scale.trim().eq_ignore_ascii_case("fit") { + PlotScale::Fit + } else { + let (num, den) = parse_plot_scale(&self.plot_scale); + if num > 0.0 && den > 0.0 { + PlotScale::Ratio(num / den) + } else { + PlotScale::Fit + } + }; + let (scale, ox, oy) = window_to_sheet((win_w, win_h), (sheet_w, sheet_h), scale_sel); + let scene = &self.tabs[i].scene; + let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); + let wires: Vec<_> = scene + .entity_wires() + .into_iter() + .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) + .collect(); + let hatches: Vec<_> = scene.paper_canvas_hatches().as_ref().clone(); + let wipeouts: Vec<_> = scene.paper_canvas_wipeouts().as_ref().clone(); + let offset_x = ((ox / scale) - x0) as f32; + let offset_y = ((oy / scale) - y0) as f32; + let clip = Some(( + (ox / scale) as f32, + (oy / scale) as f32, + win_w as f32, + win_h as f32, + )); + Some(( + wires, hatches, wipeouts, sheet_w, sheet_h, offset_x, offset_y, scale as f32, clip, + )) } pub(super) fn on_plot_style_panel_apply(&mut self) -> Task { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 61828ff9e..8410669bd 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -79,7 +79,11 @@ impl OpenCADStudio { } match self.active_modal { Some(Layers) => self.ribbon.deactivate_tool_if("LAYERS"), - Some(PageSetup) => self.ribbon.deactivate_tool_if("PAGESETUP"), + Some(Plot) => { + self.ribbon.deactivate_tool_if("PLOT"); + self.ribbon.deactivate_tool_if("PRINT"); + self.ribbon.deactivate_tool_if("PAGESETUP"); + } Some(TextStyle) => { self.ribbon.deactivate_tool_if("STYLE"); self.ribbon.deactivate_tool_if("TEXTSTYLE"); @@ -3020,23 +3024,6 @@ impl OpenCADStudio { } // ── Page Setup ──────────────────────────────────────────────── - Message::PageSetupOpen => { - let i = self.active_tab; - // Populate edit buffers from current paper limits. - let (w, h) = if let Some(((x0, y0), (x1, y1))) = self.tabs[i].scene.paper_limits() { - (x1 - x0, y1 - y0) - } else { - (297.0, 210.0) // A4 default - }; - self.page_setup_w = format!("{w:.1}"); - self.page_setup_h = format!("{h:.1}"); - self.active_modal = Some(super::ModalKind::PageSetup); - Task::none() - } - Message::PageSetupClose => { - self.close_active_modal(); - Task::none() - } Message::UpdateCheckResult(latest) => { let Some(info) = latest else { return Task::none(); @@ -3081,61 +3068,8 @@ impl OpenCADStudio { } Task::none() } - Message::PageSetupWidthEdit(s) => { - self.page_setup_w = s; - Task::none() - } - Message::PageSetupHeightEdit(s) => { - self.page_setup_h = s; - Task::none() - } - Message::PageSetupPreset(name) => { - // Paper size presets defined in view.rs — mirror them here. - let sizes: &[(&str, f64, f64)] = &[ - ("A4 Portrait", 210.0, 297.0), - ("A4 Landscape", 297.0, 210.0), - ("A3 Portrait", 297.0, 420.0), - ("A3 Landscape", 420.0, 297.0), - ("A2 Portrait", 420.0, 594.0), - ("A2 Landscape", 594.0, 420.0), - ("A1 Portrait", 594.0, 841.0), - ("A1 Landscape", 841.0, 594.0), - ("A0 Portrait", 841.0, 1189.0), - ("A0 Landscape", 1189.0, 841.0), - ("Letter Portrait", 215.9, 279.4), - ("Letter Landscape", 279.4, 215.9), - ]; - if let Some(&(_, w, h)) = sizes.iter().find(|(n, _, _)| *n == name) { - self.page_setup_w = format!("{w:.1}"); - self.page_setup_h = format!("{h:.1}"); - } - Task::none() - } - Message::PageSetupPlotArea(s) => { - self.page_setup_plot_area = s; - Task::none() - } - Message::PageSetupCenterToggle => { - self.page_setup_center = !self.page_setup_center; - Task::none() - } - Message::PageSetupOffsetXEdit(s) => { - self.page_setup_offset_x = s; - Task::none() - } - Message::PageSetupOffsetYEdit(s) => { - self.page_setup_offset_y = s; - Task::none() - } - Message::PageSetupRotation(s) => { - self.page_setup_rotation = s; - Task::none() - } - Message::PageSetupScale(s) => { - self.page_setup_scale = s; - Task::none() - } - Message::PageSetupCommit => self.on_page_setup_commit(), + Message::PlotDialogOpen => self.on_plot_dialog_open(), + Message::PlotDlg(m) => self.on_plot_dlg(m), // ── Plot / Export ───────────────────────────────────────────── Message::PlotExport => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index f0c3a6b05..1e010a9a2 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), - PageSetup => (520, 460), + Plot => (660, 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 cbbc5d65f..1b131f00c 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -47,22 +47,9 @@ impl OpenCADStudio { let tab = &self.tabs[self.active_tab]; sized(tab.layers.view_window(), 900, 360) } - super::super::ModalKind::PageSetup => sized( - crate::ui::window::page_setup::view_window( - &self.page_setup_w, - &self.page_setup_h, - &self.page_setup_plot_area, - self.page_setup_center, - &self.page_setup_offset_x, - &self.page_setup_offset_y, - &self.page_setup_rotation, - &self.page_setup_scale, - self.plot_format, - self.plot_orientation, - ), - 520, - 460, - ), + super::super::ModalKind::Plot => { + sized(crate::ui::window::plot::view_window(&self.plot_dialog), 660, 540) + } super::super::ModalKind::LayoutManager => { let i = self.active_tab; let layouts = self.tabs[i].scene.layout_names(); diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index c175edd05..c597b6152 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -13,6 +13,24 @@ use crate::io::plot_style::PlotStyleTable; use crate::scene::model::hatch_model::HatchModel; use crate::scene::WireModel; +/// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp` +/// flags / `-o` options. On Windows only `printer` is honoured (via the +/// "printto" shell verb); copies / quality / colour need the driver DEVMODE +/// and are ignored there. +#[derive(Debug, Clone, Default)] +pub struct PrintOptions { + /// Target printer name, or `None` for the system default. + pub printer: Option, + /// Number of copies (treated as at least 1). + pub copies: u32, + /// Force grayscale output. + pub mono: bool, + /// Print quality: "Draft" | "Normal" | "High" | "Maximum". + pub quality: Option, + /// Rasterisation resolution in DPI. + pub dpi: Option, +} + // Printing routes through the native PDF pipeline + the OS print command, so it // is native-only; the web build gets a stub so the call site still compiles. #[cfg(target_arch = "wasm32")] @@ -30,6 +48,38 @@ pub async fn print_wires( Err("Printing is not available in the web version.".into()) } +#[cfg(target_arch = "wasm32")] +pub fn list_printers() -> Vec { + Vec::new() +} + +#[cfg(target_arch = "wasm32")] +#[allow(clippy::too_many_arguments)] +pub async fn print_wires_with( + _wires: Vec, + _hatches: Vec, + _wipeouts: Vec, + _paper_w: f64, + _paper_h: f64, + _offset_x: f32, + _offset_y: f32, + _rotation_deg: i32, + _plot_style: Option, + _opts: PrintOptions, +) -> Result { + Err("Printing is not available in the web version.".into()) +} + +#[cfg(target_arch = "wasm32")] +pub fn open_in_viewer(_path: &std::path::Path) -> Result<(), String> { + Err("Preview is not available in the web version.".into()) +} + +#[cfg(target_arch = "wasm32")] +pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Result { + Err("Printing is not available in the web version.".into()) +} + /// Render `wires` (plus hatch / wipeout fills) to a temp PDF and dispatch it /// to the default system printer. /// @@ -67,6 +117,186 @@ pub async fn print_wires( dispatch_to_printer(&tmp_path) } +/// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`; +/// Windows returns an empty list (the "printto" dispatch targets a named +/// printer directly and the system default is always available). +#[cfg(not(target_arch = "wasm32"))] +pub fn list_printers() -> Vec { + #[cfg(not(target_os = "windows"))] + { + std::process::Command::new("lpstat") + .arg("-e") + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| { + String::from_utf8_lossy(&o.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect() + }) + .unwrap_or_default() + } + #[cfg(target_os = "windows")] + { + Vec::new() + } +} + +/// Like [`print_wires`] but honours a [`PrintOptions`] bundle (printer, copies, +/// grayscale, quality, DPI). +#[cfg(not(target_arch = "wasm32"))] +#[allow(clippy::too_many_arguments)] +pub async fn print_wires_with( + wires: Vec, + hatches: Vec, + wipeouts: Vec, + paper_w: f64, + paper_h: f64, + offset_x: f32, + offset_y: f32, + rotation_deg: i32, + plot_style: Option, + opts: PrintOptions, +) -> Result { + let tmp_path = std::env::temp_dir().join("open_cad_studio_print.pdf"); + pdf_export::export_pdf( + &wires, + &hatches, + &wipeouts, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + 1.0, + None, + &tmp_path, + plot_style.as_ref(), + )?; + dispatch_to_printer_opts(&tmp_path, &opts) +} + +/// Send an already-rendered PDF to a printer with [`PrintOptions`]. Used for +/// clipped window plots, whose PDF is built with a scale + clip the plain +/// `print_wires_with` path doesn't expose. +#[cfg(not(target_arch = "wasm32"))] +pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result { + dispatch_to_printer_opts(path, opts) +} + +/// Open a file with the OS default application (used for print preview). +#[cfg(not(target_arch = "wasm32"))] +pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> { + let p = path.to_string_lossy().to_string(); + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", "", &p]); + c + }; + #[cfg(target_os = "macos")] + let mut cmd = { + let mut c = std::process::Command::new("open"); + c.arg(&p); + c + }; + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + let mut cmd = { + let mut c = std::process::Command::new("xdg-open"); + c.arg(&p); + c + }; + cmd.spawn() + .map(|_| ()) + .map_err(|e| format!("Could not open preview: {e}")) +} + +/// Dispatch a PDF to a specific printer with [`PrintOptions`]. +#[cfg(not(target_arch = "wasm32"))] +fn dispatch_to_printer_opts( + path: &std::path::Path, + opts: &PrintOptions, +) -> Result { + #[cfg(target_os = "windows")] + { + // Target a named printer via the "printto" verb; fall back to the + // default-printer "print" verb. Copies / quality / colour aren't + // expressible through a shell verb, so they are ignored here. + use std::ffi::OsStr; + use std::os::windows::ffi::OsStrExt; + let wide = |s: &str| -> Vec { OsStr::new(s).encode_wide().chain(Some(0)).collect() }; + let path_wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); + let (verb, params, label) = match opts.printer.as_deref() { + Some(p) if !p.is_empty() => (wide("printto"), Some(wide(p)), p.to_string()), + _ => (wide("print"), None, "default printer".to_string()), + }; + let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null()); + let result = unsafe { + windows_sys::Win32::UI::Shell::ShellExecuteW( + std::ptr::null_mut(), + verb.as_ptr(), + path_wide.as_ptr(), + params_ptr, + std::ptr::null(), + windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, + ) as usize + }; + if result > 32 { + Ok(label) + } else { + Err(format!("ShellExecute failed (code {result})")) + } + } + + #[cfg(not(target_os = "windows"))] + { + let path_str = path.to_string_lossy(); + let mut cmd = std::process::Command::new("lp"); + if let Some(p) = opts.printer.as_deref() { + if !p.is_empty() { + cmd.arg("-d").arg(p); + } + } + let copies = opts.copies.max(1); + if copies > 1 { + cmd.arg("-n").arg(copies.to_string()); + } + if opts.mono { + cmd.arg("-o").arg("ColorModel=Gray"); + } + if let Some(dpi) = opts.dpi { + cmd.arg("-o").arg(format!("Resolution={dpi}dpi")); + } + if let Some(q) = opts.quality.as_deref() { + // CUPS print-quality: 3 = draft, 4 = normal, 5 = high / best. + let pq = match q { + "Draft" => "3", + "High" | "Maximum" => "5", + _ => "4", + }; + cmd.arg("-o").arg(format!("print-quality={pq}")); + } + let out = cmd + .arg("--") + .arg(path_str.as_ref()) + .output() + .map_err(|e| format!("Could not launch lp: {e}"))?; + if out.status.success() { + let msg = String::from_utf8_lossy(&out.stdout); + let printer = msg + .split_whitespace() + .find(|w| w.contains('-')) + .unwrap_or("printer") + .to_string(); + Ok(printer) + } else { + Err(String::from_utf8_lossy(&out.stderr).into_owned()) + } + } +} + /// Platform-specific dispatch of a PDF path to the system printer. #[cfg(not(target_arch = "wasm32"))] fn dispatch_to_printer(path: &std::path::Path) -> Result { diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 9b6620ad7..f09b454db 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,6 +1,6 @@ pub mod about; pub mod layout_manager; -pub mod page_setup; +pub mod plot; pub mod plugin_manager; pub mod shortcuts; pub mod layers; diff --git a/src/ui/window/page_setup.rs b/src/ui/window/page_setup.rs deleted file mode 100644 index f4f83950c..000000000 --- a/src/ui/window/page_setup.rs +++ /dev/null @@ -1,432 +0,0 @@ -//! Page Setup window — fills the entire OS window. - -use crate::app::Message; -use crate::io::paper_sizes::{Orientation, PaperSize}; -use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; -use iced::{Background, Border, Color, Element, Fill, Theme}; - -const TB: Color = Color { - r: 0.13, - g: 0.13, - b: 0.13, - a: 1.0, -}; -const BG: Color = Color { - r: 0.15, - g: 0.15, - b: 0.15, - a: 1.0, -}; -const BORDER: Color = Color { - r: 0.35, - g: 0.35, - b: 0.35, - a: 1.0, -}; -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 ACTIVE: Color = Color { - r: 0.20, - g: 0.40, - b: 0.70, - a: 1.0, -}; -const FIELD: Color = Color { - r: 0.10, - g: 0.10, - b: 0.10, - a: 1.0, -}; - -fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.20, - g: 0.42, - b: 0.72, - a: 1.0, - }, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - (true, _) => ACCENT, - _ => Color { - r: 0.22, - g: 0.22, - b: 0.22, - a: 1.0, - }, - })), - text_color: TEXT, - border: Border { - color: BORDER, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - } -} - -fn pill(active: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |_: &Theme, st| button::Style { - background: Some(Background::Color(match (active, st) { - (true, _) => ACTIVE, - (false, button::Status::Hovered | button::Status::Pressed) => Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 1.0, - }, - _ => Color { - r: 0.20, - g: 0.20, - b: 0.20, - a: 1.0, - }, - })), - text_color: TEXT, - border: Border { - color: BORDER, - width: 1.0, - radius: 3.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - } -} - -fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { - text_input::Style { - background: Background::Color(FIELD), - border: Border { - color: BORDER, - width: 1.0, - radius: 3.0.into(), - }, - icon: TEXT, - placeholder: DIM, - value: TEXT, - selection: ACCENT, - } -} - -fn hdivider<'a>() -> Element<'a, Message> { - container(Space::new().width(Fill).height(1)) - .width(Fill) - .height(1) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BORDER)), - ..Default::default() - }) - .into() -} - -fn section_label<'a>(s: &'static str) -> Element<'a, Message> { - text(s).size(11).color(DIM).into() -} - -pub fn view_window<'a>( - w_buf: &'a str, - h_buf: &'a str, - plot_area: &'a str, - center: bool, - offset_x: &'a str, - offset_y: &'a str, - rotation: &'a str, - scale: &'a str, - plot_format: PaperSize, - plot_orientation: Orientation, -) -> Element<'a, Message> { - // ── Toolbar ─────────────────────────────────────────────────────────── - let toolbar = container( - row![ - button(text("Cancel").size(12)) - .on_press(Message::PageSetupClose) - .style(btn(false)) - .padding([4, 14]), - Space::new().width(Fill), - button(text("OK").size(12)) - .on_press(Message::PageSetupCommit) - .style(btn(true)) - .padding([4, 20]), - ] - .align_y(iced::Center), - ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TB)), - ..Default::default() - }) - .width(Fill) - .padding([5, 10]); - - let lbl = |s: &'static str| text(s).size(11).color(DIM).width(130); - - // ── Paper size presets ──────────────────────────────────────────────── - let presets1 = row![ - button(text("A4 P").size(10)) - .on_press(Message::PageSetupPreset("A4 Portrait".into())) - .style(pill(false)) - .padding([3, 6]), - button(text("A4 L").size(10)) - .on_press(Message::PageSetupPreset("A4 Landscape".into())) - .style(pill(false)) - .padding([3, 6]), - button(text("A3 P").size(10)) - .on_press(Message::PageSetupPreset("A3 Portrait".into())) - .style(pill(false)) - .padding([3, 6]), - button(text("A3 L").size(10)) - .on_press(Message::PageSetupPreset("A3 Landscape".into())) - .style(pill(false)) - .padding([3, 6]), - ] - .spacing(4); - - let presets2 = row![ - button(text("A2 L").size(10)) - .on_press(Message::PageSetupPreset("A2 Landscape".into())) - .style(pill(false)) - .padding([3, 6]), - button(text("A1 L").size(10)) - .on_press(Message::PageSetupPreset("A1 Landscape".into())) - .style(pill(false)) - .padding([3, 6]), - button(text("A0 L").size(10)) - .on_press(Message::PageSetupPreset("A0 Landscape".into())) - .style(pill(false)) - .padding([3, 6]), - button(text("Letter").size(10)) - .on_press(Message::PageSetupPreset("Letter Landscape".into())) - .style(pill(false)) - .padding([3, 6]), - ] - .spacing(4); - - // ── Rotation buttons ────────────────────────────────────────────────── - let rot_row = row![ - button(text("0°").size(10)) - .on_press(Message::PageSetupRotation("0".into())) - .style(pill(rotation == "0")) - .padding([3, 8]), - button(text("90°").size(10)) - .on_press(Message::PageSetupRotation("90".into())) - .style(pill(rotation == "90")) - .padding([3, 8]), - button(text("180°").size(10)) - .on_press(Message::PageSetupRotation("180".into())) - .style(pill(rotation == "180")) - .padding([3, 8]), - button(text("270°").size(10)) - .on_press(Message::PageSetupRotation("270".into())) - .style(pill(rotation == "270")) - .padding([3, 8]), - ] - .spacing(4); - - // ── Scale buttons ───────────────────────────────────────────────────── - let scale_row1 = row![ - button(text("Fit").size(10)) - .on_press(Message::PageSetupScale("Fit".into())) - .style(pill(scale == "Fit")) - .padding([3, 8]), - button(text("1:1").size(10)) - .on_press(Message::PageSetupScale("1:1".into())) - .style(pill(scale == "1:1")) - .padding([3, 8]), - button(text("1:2").size(10)) - .on_press(Message::PageSetupScale("1:2".into())) - .style(pill(scale == "1:2")) - .padding([3, 8]), - button(text("1:5").size(10)) - .on_press(Message::PageSetupScale("1:5".into())) - .style(pill(scale == "1:5")) - .padding([3, 8]), - button(text("1:10").size(10)) - .on_press(Message::PageSetupScale("1:10".into())) - .style(pill(scale == "1:10")) - .padding([3, 8]), - ] - .spacing(4); - - let scale_row2 = row![ - button(text("1:20").size(10)) - .on_press(Message::PageSetupScale("1:20".into())) - .style(pill(scale == "1:20")) - .padding([3, 8]), - button(text("1:50").size(10)) - .on_press(Message::PageSetupScale("1:50".into())) - .style(pill(scale == "1:50")) - .padding([3, 8]), - button(text("1:100").size(10)) - .on_press(Message::PageSetupScale("1:100".into())) - .style(pill(scale == "1:100")) - .padding([3, 8]), - button(text("2:1").size(10)) - .on_press(Message::PageSetupScale("2:1".into())) - .style(pill(scale == "2:1")) - .padding([3, 8]), - ] - .spacing(4); - - // ── Model-space window plot ─────────────────────────────────────────── - // Sheet size/orientation for PLOTWINDOW's clipped export; the scale - // pills above (`scale_row1`/`scale_row2`) double as its plot scale. - let format_row = { - let mut r = row![lbl("Format")].spacing(4).align_y(iced::Center); - for size in PaperSize::ALL { - r = r.push( - button(text(size.label()).size(10)) - .on_press(Message::PlotFormat(size)) - .style(pill(plot_format == size)) - .padding([3, 8]), - ); - } - r - }; - let orient_row = row![ - lbl("Orientation"), - button(text("Portrait").size(10)) - .on_press(Message::PlotOrientation(Orientation::Portrait)) - .style(pill(plot_orientation == Orientation::Portrait)) - .padding([3, 8]), - button(text("Landscape").size(10)) - .on_press(Message::PlotOrientation(Orientation::Landscape)) - .style(pill(plot_orientation == Orientation::Landscape)) - .padding([3, 8]), - ] - .spacing(4) - .align_y(iced::Center); - let window_row = row![ - button(text("Pick window").size(10)) - .on_press(Message::Command("PLOTWINDOW".into())) - .style(btn(false)) - .padding([4, 10]), - button(text("Plot window → PDF").size(10)) - .on_press(Message::PlotWindowExport) - .style(btn(true)) - .padding([4, 10]), - ] - .spacing(6); - - // ── Main scrollable form ────────────────────────────────────────────── - let form = column![ - section_label("Paper Size"), - presets1, - presets2, - row![ - lbl("Width (mm)"), - text_input("297", w_buf) - .on_input(Message::PageSetupWidthEdit) - .on_submit(Message::PageSetupCommit) - .style(field_style) - .size(12) - .width(90), - ] - .spacing(8) - .align_y(iced::Center), - row![ - lbl("Height (mm)"), - text_input("210", h_buf) - .on_input(Message::PageSetupHeightEdit) - .on_submit(Message::PageSetupCommit) - .style(field_style) - .size(12) - .width(90), - ] - .spacing(8) - .align_y(iced::Center), - hdivider(), - section_label("Plot Area"), - row![ - button(text("Layout").size(10)) - .on_press(Message::PageSetupPlotArea("Layout".into())) - .style(pill(plot_area == "Layout")) - .padding([3, 8]), - button(text("Extents").size(10)) - .on_press(Message::PageSetupPlotArea("Extents".into())) - .style(pill(plot_area == "Extents")) - .padding([3, 8]), - ] - .spacing(6), - hdivider(), - section_label("Position"), - button( - row![ - crate::ui::icons::check_cell(center, Color::WHITE), - text("Center on page").size(11), - ] - .spacing(2) - .align_y(iced::Center) - ) - .on_press(Message::PageSetupCenterToggle) - .style(pill(center)) - .padding([4, 10]), - row![ - lbl("Offset X (mm)"), - text_input("0", offset_x) - .on_input(Message::PageSetupOffsetXEdit) - .style(field_style) - .size(12) - .width(90), - ] - .spacing(8) - .align_y(iced::Center), - row![ - lbl("Offset Y (mm)"), - text_input("0", offset_y) - .on_input(Message::PageSetupOffsetYEdit) - .style(field_style) - .size(12) - .width(90), - ] - .spacing(8) - .align_y(iced::Center), - hdivider(), - section_label("Rotation"), - rot_row, - hdivider(), - section_label("Plot Scale"), - scale_row1, - scale_row2, - hdivider(), - section_label("Model-Space Window Plot"), - format_row, - orient_row, - window_row, - ] - .spacing(10) - .padding(16) - .width(Fill); - - let content = scrollable(form).width(Fill).height(Fill); - - container(column![toolbar, hdivider(), content].spacing(0)) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(BG)), - ..Default::default() - }) - .width(Fill) - .height(Fill) - .into() -} diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs new file mode 100644 index 000000000..f434f71e0 --- /dev/null +++ b/src/ui/window/plot.rs @@ -0,0 +1,521 @@ +//! Plot / Print dialog — a full plot setup surface rendered as an in-canvas +//! modal (Plan B). Bundles printer choice, paper, scale, offset, plot style, +//! quality and output options into one dialog; on commit it either sends the +//! current layout to a system printer (with the chosen options) or writes a +//! PDF. Styled to match the other OCS dialogs (dark pills + fields). + +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, +}; +use iced::{Background, Border, Color, Element, Fill, Length, Theme}; + +const TB: Color = Color { r: 0.13, g: 0.13, b: 0.13, a: 1.0 }; +const BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 }; +const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; +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 }; + +/// 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…"; + +/// 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)] +pub enum PlotFlag { + Center, + ScaleLw, + Mono, + Lineweights, + WithStyles, + Transparency, + PaperspaceLast, + HidePaperspace, + Stamp, + SaveLayout, +} + +/// Every edit the Plot dialog can emit. Wrapped in `Message::PlotDlg` so the +/// top-level match stays a single arm. +#[derive(Debug, Clone)] +pub enum PlotDlgMsg { + Close, + Commit, + Preview, + Printer(String), + Paper(String), + Orientation(String), + Rotation(String), + Area(String), + Scale(String), + Quality(String), + Shade(String), + Copies(String), + OffsetX(String), + OffsetY(String), + Dpi(String), + Flag(PlotFlag), + LoadStyle, + ClearStyle, + PickWindow, +} + +/// Transient state backing the Plot dialog. Seeded from the layout's plot +/// settings when the dialog opens; consumed on commit. +#[derive(Debug, Clone)] +pub struct PlotDialogState { + /// Printer names discovered on the system (via `lpstat`), never the + /// sentinels. + pub printers: Vec, + /// Chosen printer name, or `None` for the system default. + pub printer: Option, + /// Output goes to a PDF file instead of a printer. + pub to_file: bool, + pub paper: String, + pub orientation: String, + pub rotation: String, + pub copies: String, + pub area: String, + pub center: bool, + pub offset_x: String, + pub offset_y: String, + pub scale: String, + pub scale_lw: bool, + pub quality: String, + pub dpi: String, + pub shade: String, + pub mono: bool, + pub lineweights: bool, + pub with_styles: bool, + pub transparency: bool, + pub paperspace_last: bool, + pub hide_paperspace: bool, + pub stamp: bool, + pub save_layout: bool, + /// Display name of the active plot style table ("" = none). + pub style_name: String, +} + +impl Default for PlotDialogState { + fn default() -> Self { + Self { + printers: Vec::new(), + printer: None, + to_file: false, + paper: "A4".into(), + orientation: "Landscape".into(), + rotation: "0°".into(), + copies: "1".into(), + area: "Window".into(), + center: true, + offset_x: "0.0".into(), + offset_y: "0.0".into(), + scale: "Fit".into(), + scale_lw: true, + quality: "Normal".into(), + dpi: "300".into(), + shade: "As displayed".into(), + mono: false, + lineweights: true, + with_styles: true, + transparency: false, + paperspace_last: false, + hide_paperspace: false, + stamp: false, + save_layout: false, + style_name: String::new(), + } + } +} + +impl PlotDialogState { + /// 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 + /// seeded from the active layout each time the dialog opens. + pub fn load() -> Self { + #[cfg(target_arch = "wasm32")] + { + Self::default() + } + #[cfg(not(target_arch = "wasm32"))] + { + let mut s = Self::default(); + if let Some(body) = prefs_path().and_then(|p| std::fs::read_to_string(p).ok()) { + let flag = |v: &str| v == "1"; + for line in body.lines() { + let Some((k, v)) = line.split_once('=') else { + continue; + }; + let (k, v) = (k.trim(), v.trim()); + match k { + "printer" => { + s.printer = if v.is_empty() { None } else { Some(v.to_string()) } + } + "to_file" => s.to_file = flag(v), + "area" => s.area = v.to_string(), + "scale" => s.scale = v.to_string(), + "copies" => s.copies = v.to_string(), + "quality" => s.quality = v.to_string(), + "dpi" => s.dpi = v.to_string(), + "shade" => s.shade = v.to_string(), + "mono" => s.mono = flag(v), + "lineweights" => s.lineweights = flag(v), + "with_styles" => s.with_styles = flag(v), + "transparency" => s.transparency = flag(v), + "paperspace_last" => s.paperspace_last = flag(v), + "hide_paperspace" => s.hide_paperspace = flag(v), + "stamp" => s.stamp = flag(v), + "save_layout" => s.save_layout = flag(v), + "scale_lw" => s.scale_lw = flag(v), + _ => {} + } + } + } + s + } + } + + /// Best-effort persist of the print preferences (silent on failure). + #[cfg(not(target_arch = "wasm32"))] + pub fn save(&self) { + let Some(path) = prefs_path() else { + return; + }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let b = |v: bool| if v { "1" } else { "0" }; + let printer = self.printer.clone().unwrap_or_default(); + let body = format!( + "printer={}\nto_file={}\narea={}\nscale={}\ncopies={}\nquality={}\ndpi={}\nshade={}\nmono={}\n\ + lineweights={}\nwith_styles={}\ntransparency={}\npaperspace_last={}\n\ + hide_paperspace={}\nstamp={}\nsave_layout={}\nscale_lw={}\n", + printer, + b(self.to_file), + self.area, + self.scale, + self.copies, + self.quality, + self.dpi, + self.shade, + b(self.mono), + b(self.lineweights), + b(self.with_styles), + b(self.transparency), + b(self.paperspace_last), + b(self.hide_paperspace), + b(self.stamp), + b(self.save_layout), + b(self.scale_lw), + ); + let _ = std::fs::write(path, body); + } + + /// No-op persist on the web build (no filesystem). + #[cfg(target_arch = "wasm32")] + pub fn save(&self) {} +} + +#[cfg(not(target_arch = "wasm32"))] +fn prefs_path() -> Option { + Some(crate::config::config_dir()?.join("plot.txt")) +} + +fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { + move |_: &Theme, st| button::Style { + background: Some(Background::Color(match (accent, st) { + (true, button::Status::Hovered | button::Status::Pressed) => { + Color { r: 0.20, g: 0.42, b: 0.72, a: 1.0 } + } + (false, button::Status::Hovered | button::Status::Pressed) => { + Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 } + } + (true, _) => ACCENT, + _ => Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 }, + })), + text_color: TEXT, + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + shadow: iced::Shadow::default(), + snap: false, + } +} + +fn field_style(_: &Theme, _: text_input::Status) -> text_input::Style { + text_input::Style { + background: Background::Color(FIELD), + border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, + icon: TEXT, + placeholder: DIM, + value: TEXT, + selection: ACCENT, + } +} + +fn hdivider<'a>() -> Element<'a, Message> { + container(Space::new().width(Fill).height(1)) + .width(Fill) + .height(1) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BORDER)), + ..Default::default() + }) + .into() +} + +fn section_label<'a>(s: &'static str) -> Element<'a, Message> { + text(s).size(11).color(DIM).into() +} + +/// A `label : dropdown` row. `ctor` turns the picked string into a dialog +/// message. +fn drop_row<'a>( + label: &'a str, + options: Vec, + selected: Option, + ctor: fn(String) -> PlotDlgMsg, +) -> Element<'a, Message> { + let pl = pick_list(options, selected, move |s| Message::PlotDlg(ctor(s))) + .text_size(12) + .padding([3, 6]) + .width(Length::Fill); + row![text(label).size(11).color(DIM).width(92), pl] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` row. +fn field_row<'a>( + label: &'a str, + value: &'a str, + ctor: fn(String) -> PlotDlgMsg, + width: u16, +) -> Element<'a, Message> { + row![ + text(label).size(11).color(DIM).width(92), + text_input("", value) + .on_input(move |s| Message::PlotDlg(ctor(s))) + .style(field_style) + .size(12) + .width(width as f32), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A single option checkbox bound to a `PlotFlag`. +fn check<'a>(label: &'a str, on: bool, flag: PlotFlag) -> Element<'a, Message> { + checkbox(on) + .label(label) + .on_toggle(move |_| Message::PlotDlg(PlotDlgMsg::Flag(flag))) + .size(14) + .text_size(11) + .into() +} + +fn strs(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() +} + +pub fn view_window(s: &PlotDialogState) -> Element<'_, Message> { + // ── Toolbar: Cancel … Preview Print/Export ────────────────────────── + let action = if s.to_file { "Export PDF" } else { "Print" }; + let toolbar = container( + row![ + button(text("Cancel").size(12)) + .on_press(Message::PlotDlg(PlotDlgMsg::Close)) + .style(btn(false)) + .padding([4, 14]), + Space::new().width(Fill), + button(text("Preview").size(12)) + .on_press(Message::PlotDlg(PlotDlgMsg::Preview)) + .style(btn(false)) + .padding([4, 12]), + Space::new().width(8), + button(text(action).size(12)) + .on_press(Message::PlotDlg(PlotDlgMsg::Commit)) + .style(btn(true)) + .padding([4, 20]), + ] + .align_y(iced::Center), + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(TB)), + ..Default::default() + }) + .width(Fill) + .padding([5, 10]); + + // ── Printer dropdown: default + discovered printers + PDF sentinel ──── + let mut printer_opts = vec![OUT_DEFAULT.to_string()]; + printer_opts.extend(s.printers.iter().cloned()); + printer_opts.push(OUT_PDF.to_string()); + let printer_sel = if s.to_file { + Some(OUT_PDF.to_string()) + } else { + Some(s.printer.clone().unwrap_or_else(|| OUT_DEFAULT.to_string())) + }; + + let paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); + + // ── Left column ────────────────────────────────────────────────────── + let style_label: String = if s.style_name.is_empty() { + "(none)".into() + } else { + s.style_name.clone() + }; + let left = column![ + section_label("Printer / plotter"), + drop_row("Output", printer_opts, printer_sel, PlotDlgMsg::Printer), + field_row("Copies", &s.copies, PlotDlgMsg::Copies, 60), + hdivider(), + section_label("Paper"), + drop_row("Size", paper_opts, Some(s.paper.clone()), PlotDlgMsg::Paper), + drop_row( + "Orientation", + strs(&["Portrait", "Landscape"]), + Some(s.orientation.clone()), + PlotDlgMsg::Orientation, + ), + drop_row( + "Rotation", + strs(&["0°", "90°", "180°", "270°"]), + Some(s.rotation.clone()), + PlotDlgMsg::Rotation, + ), + hdivider(), + section_label("Plot area"), + row![ + container( + pick_list( + strs(&["Layout", "Extents", "Display", "Window"]), + Some(s.area.clone()), + move |v| Message::PlotDlg(PlotDlgMsg::Area(v)), + ) + .text_size(12) + .padding([3, 6]) + .width(Length::Fill) + ) + .width(Fill), + button(text("Pick…").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::PickWindow)) + .style(btn(false)) + .padding([4, 10]), + ] + .spacing(8) + .align_y(iced::Center), + section_label("Plot offset"), + column![ + field_row("X (mm)", &s.offset_x, PlotDlgMsg::OffsetX, 70), + field_row("Y (mm)", &s.offset_y, PlotDlgMsg::OffsetY, 70), + ] + .spacing(9), + check("Center the plot", s.center, PlotFlag::Center), + ] + .spacing(9) + .width(Fill); + + // ── Right column ───────────────────────────────────────────────────── + let right = column![ + section_label("Plot scale"), + drop_row( + "Scale", + strs(&["Fit", "1:1", "1:2", "1:5", "1:10", "1:20", "1:50", "1:100", "2:1"]), + Some(s.scale.clone()), + PlotDlgMsg::Scale, + ), + check("Scale lineweights", s.scale_lw, PlotFlag::ScaleLw), + hdivider(), + section_label("Plot style table (pen assignments)"), + row![ + container(text(style_label).size(12).color(TEXT)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(FIELD)), + border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, + ..Default::default() + }) + .padding([4, 8]) + .width(Fill), + button(text("Load…").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::LoadStyle)) + .style(btn(false)) + .padding([4, 10]), + button(text("Clear").size(11)) + .on_press(Message::PlotDlg(PlotDlgMsg::ClearStyle)) + .style(btn(false)) + .padding([4, 10]), + ] + .spacing(6) + .align_y(iced::Center), + hdivider(), + section_label("Quality"), + row![ + container( + pick_list( + strs(&["Draft", "Normal", "High", "Maximum"]), + Some(s.quality.clone()), + move |v| Message::PlotDlg(PlotDlgMsg::Quality(v)), + ) + .text_size(12) + .padding([3, 6]) + .width(Length::Fill) + ) + .width(Fill), + text("DPI").size(11).color(DIM), + text_input("", &s.dpi) + .on_input(move |v| Message::PlotDlg(PlotDlgMsg::Dpi(v))) + .style(field_style) + .size(12) + .width(56.0), + ] + .spacing(8) + .align_y(iced::Center), + drop_row( + "Shaded plot", + strs(&["As displayed", "Wireframe", "Hidden", "Rendered"]), + Some(s.shade.clone()), + PlotDlgMsg::Shade, + ), + hdivider(), + section_label("Plot options"), + row![ + column![ + check("Object lineweights", s.lineweights, PlotFlag::Lineweights), + check("Plot with styles", s.with_styles, PlotFlag::WithStyles), + check("Monochrome", s.mono, PlotFlag::Mono), + check("Plot transparency", s.transparency, PlotFlag::Transparency), + ] + .spacing(6) + .width(Fill), + column![ + check("Paperspace last", s.paperspace_last, PlotFlag::PaperspaceLast), + check("Hide paperspace", s.hide_paperspace, PlotFlag::HidePaperspace), + check("Plot stamp", s.stamp, PlotFlag::Stamp), + check("Save to layout", s.save_layout, PlotFlag::SaveLayout), + ] + .spacing(6) + .width(Fill), + ] + .spacing(10), + ] + .spacing(9) + .width(Fill); + + let body = row![left, right].spacing(18).padding(14).width(Fill); + let content = scrollable(body).width(Fill).height(Fill); + + container(column![toolbar, hdivider(), content].spacing(0)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(BG)), + ..Default::default() + }) + .width(Fill) + .height(Fill) + .into() +}