forked from 1b-agent/opencadstudio-upstream
feat(plot): unified plot/print dialog, retire Page Setup
Replace the old Page Setup panel with a single OCS-styled plot/print dialog (dropdowns for printer, paper, orientation, rotation, plot area, scale, quality, shaded plot; checkboxes for plot options). PLOT / PRINT / PAGESETUP all open it. - Printer output via lp: -d printer, -n copies, -o ColorModel=Gray, print-quality, Resolution; Windows printto verb; list_printers via lpstat. Preview opens a temp PDF in the OS viewer (never a save dialog). PDF export reuses the tested pipeline. - Window pick returns to the new dialog (was the old Page Setup) and the clipped window plot drives PDF / printer / preview. - Remember the user's print preferences across sessions in config/plot.txt (printer, copies, quality, output options, area, scale); drawing-specific fields (offset/center/rotation) seed from the layout. Default plot area Window, scale Fit. - Delete src/ui/window/page_setup.rs and its ModalKind / Message / buffer state; fold on_page_setup_commit into apply_plot_page_settings. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
e9621d618b
commit
8820b43127
11 files changed
+1242
-660
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 ─────────
|
||||
|
||||
+11
-49
@@ -513,26 +513,14 @@ pub(super) struct OpenCADStudio {
|
||||
last_vp_click_time: Option<Instant>,
|
||||
/// Screen position of the previous viewport left-click release.
|
||||
last_vp_click_pos: Option<Point>,
|
||||
/// 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<String, String>),
|
||||
/// 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(),
|
||||
|
||||
+455
-75
@@ -808,18 +808,28 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
iced::exit()
|
||||
}
|
||||
|
||||
pub(super) fn on_page_setup_commit(&mut self) -> Task<Message> {
|
||||
/// 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::<f64>().unwrap_or(297.0).max(1.0);
|
||||
let h: f64 = self.page_setup_h.parse::<f64>().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::<f64>().unwrap_or(0.0);
|
||||
let offset_y = self.page_setup_offset_y.parse::<f64>().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<Message> {
|
||||
};
|
||||
// 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<Message> {
|
||||
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<Message> {
|
||||
@@ -1062,10 +1070,10 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
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<Message> {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_print_to_printer(&mut self) -> Task<Message> {
|
||||
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<crate::scene::WireModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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<Message> {
|
||||
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::<f64>().unwrap_or(0.0);
|
||||
let off_y = d.offset_y.parse::<f64>().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::<u32>().unwrap_or(1).max(1),
|
||||
mono: d.mono,
|
||||
quality: Some(d.quality.clone()),
|
||||
dpi: d.dpi.trim().parse::<u32>().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<crate::scene::WireModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
Vec<crate::scene::model::hatch_model::HatchModel>,
|
||||
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<Message> {
|
||||
|
||||
+7
-73
@@ -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 => {
|
||||
|
||||
+1
-1
@@ -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),
|
||||
|
||||
+3
-16
@@ -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();
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// Rasterisation resolution in DPI.
|
||||
pub dpi: Option<u32>,
|
||||
}
|
||||
|
||||
// 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<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn print_wires_with(
|
||||
_wires: Vec<WireModel>,
|
||||
_hatches: Vec<HatchModel>,
|
||||
_wipeouts: Vec<HatchModel>,
|
||||
_paper_w: f64,
|
||||
_paper_h: f64,
|
||||
_offset_x: f32,
|
||||
_offset_y: f32,
|
||||
_rotation_deg: i32,
|
||||
_plot_style: Option<PlotStyleTable>,
|
||||
_opts: PrintOptions,
|
||||
) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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<String> {
|
||||
#[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<WireModel>,
|
||||
hatches: Vec<HatchModel>,
|
||||
wipeouts: Vec<HatchModel>,
|
||||
paper_w: f64,
|
||||
paper_h: f64,
|
||||
offset_x: f32,
|
||||
offset_y: f32,
|
||||
rotation_deg: i32,
|
||||
plot_style: Option<PlotStyleTable>,
|
||||
opts: PrintOptions,
|
||||
) -> Result<String, String> {
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
#[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<u16> { OsStr::new(s).encode_wide().chain(Some(0)).collect() };
|
||||
let path_wide: Vec<u16> = 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<String, String> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Chosen printer name, or `None` for the system default.
|
||||
pub printer: Option<String>,
|
||||
/// 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 `<config>/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<std::path::PathBuf> {
|
||||
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<String>,
|
||||
selected: Option<String>,
|
||||
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<String> {
|
||||
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<String> = 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()
|
||||
}
|
||||
Reference in new issue
Block a user