feat(plot): printer driver properties and a roomier plot dialog

Properties… next to the printer used to open the CUPS web interface in a
browser. It now edits the driver options of the *selected* printer the
way LibreOffice's Properties… does, without leaving the plot dialog:

- CUPS platforms (Linux, any desktop; macOS): an in-line editor lists the
  printer's PPD options as `lpoptions -l` reports them — media type,
  colour mode, quality, duplex, trays, scaling — with readable choice
  labels, Reset / Cancel / Apply. Apply keeps only the choices that differ
  from the driver defaults, per printer, in the plot config, and every job
  for that printer passes them as `lp -o key=value`. Where CUPS cannot
  answer, the desktop's printer settings panel opens instead of a browser.
- Windows: the driver's document-properties sheet (`DocumentPropertiesW`
  with prompt, owned by the main window) and, on OK, the result stored as
  the user's printing preferences for that printer (`SetPrinter` level 9),
  which the job honours. `printui /e` (printing preferences) replaces the
  device-properties sheet as the fallback.

The desktop print portal was tried first and dropped: GTK 4's print
dialog loses the preselected printer when the default queue is listed
before it, so the dialog always opened on the wrong printer.

Dialog layout: the modal grows to 940×620; Printer / plotter and Paper
size share a line with their pickers (long queue and media names now fit);
Copies + Properties… and Custom… sit beneath; the custom-sheet editor
labels each margin (Left / Right, Top / Bottom) and shows the unit.

Also recognises `none_device`, AutoCAD's stored spelling for "no plotter",
and writes it back unchanged.

Tests: `lpoptions` parsing and keyword humanising, the editor flow (load,
stale answer ignored, set, apply, remember, reset, error, cancel), device
name round-trips including `none_device`.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-16 16:05:47 +03:00
1 parent 5e271e2dbb
commit 737666b894
29 files changed
+1000 -103

No files matched your search

+219 -8
View File
@@ -3935,6 +3935,85 @@ impl OpenCADStudio {
}
}
/// Properties… for the selected printer.
///
/// On CUPS platforms (Linux, macOS) the driver's options are listed in an
/// in-line editor and applied to every job for that printer through
/// `lp -o`. On Windows the driver's own document-properties sheet is
/// shown and its result stored as the printer's preferences, which the
/// print job honours. Without a named printer, or where the options
/// cannot be listed, the platform's printer settings open instead.
fn on_printer_properties(&mut self) -> Task<Message> {
let Some(printer) = self.plot_dialog.printer.clone().filter(|_| !self.plot_dialog.to_file)
else {
self.open_printer_settings_fallback();
return Task::none();
};
#[cfg(target_os = "windows")]
{
return self.edit_windows_printer_preferences(printer);
}
#[allow(unreachable_code)]
{
self.plot_dialog.printer_editor = Some(crate::ui::window::plot::PrinterOptionsDraft {
printer: printer.clone(),
options: None,
choices: Default::default(),
error: None,
});
let name = printer.clone();
background_task(
move || crate::io::print_to_printer::printer_options(&printer),
move |result| {
Message::PlotDlg(crate::ui::window::plot::PlotDlgMsg::PrinterOptionsLoaded(
name, result,
))
},
)
}
}
/// The driver's document-properties sheet, owned by the main window so
/// it sits on top of the plot dialog.
#[cfg(target_os = "windows")]
fn edit_windows_printer_preferences(&mut self, printer: String) -> Task<Message> {
let Some(window) = self.main_window else {
self.open_printer_settings_fallback();
return Task::none();
};
iced::window::run(window, move |w| {
use iced::window::raw_window_handle::RawWindowHandle;
let owner = match w.window_handle().ok().map(|h| h.as_raw()) {
Some(RawWindowHandle::Win32(handle)) => handle.hwnd.get(),
_ => 0,
};
match crate::io::print_to_printer::edit_printer_preferences(&printer, owner) {
Ok(true) => Ok(crate::tf!("Printing preferences saved for {printer}.").into_owned()),
Ok(false) => Ok(crate::t!("Printing preferences unchanged.").into_owned()),
Err(error) => Err(error),
}
})
.then(|result| {
Task::done(Message::BackgroundIoFinished(
result.map(|message| message),
false,
))
})
}
/// The platform's printer settings surface: a settings panel on Linux,
/// the printers control panel on Windows, System Settings on macOS.
fn open_printer_settings_fallback(&mut self) {
match crate::io::print_to_printer::open_printer_properties(
self.plot_dialog.printer.as_deref(),
) {
Ok(()) => self
.command_line
.push_info(crate::t!("Opened printer properties.").as_ref()),
Err(error) => self.command_line.push_error(&error),
}
}
fn normalize_common_plot_dialog(&mut self) {
self.plot_dialog.center = true;
self.plot_dialog.offset_x = "0.0".into();
@@ -3957,6 +4036,9 @@ impl OpenCADStudio {
Task::none()
}
M::Printer(s) => {
// Driver choices belong to a printer; the editor closes when
// the target changes.
self.plot_dialog.printer_editor = None;
if s == OUT_PDF {
self.plot_dialog.to_file = true;
} else if s == OUT_DEFAULT {
@@ -3979,17 +4061,73 @@ impl OpenCADStudio {
self.on_custom_paper_msg(msg);
Task::none()
}
M::PrinterProperties => {
match crate::io::print_to_printer::open_printer_properties(
self.plot_dialog.printer.as_deref(),
) {
Ok(()) => self
.command_line
.push_info(crate::t!("Opened printer properties.").as_ref()),
Err(error) => self.command_line.push_error(&error),
M::PrinterProperties => self.on_printer_properties(),
M::PrinterOptionsLoaded(printer, result) => {
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
if draft.printer == printer {
match result {
Ok(options) => {
// Start from the remembered choices, then the
// driver's defaults.
let remembered = self
.plot_dialog
.driver_options
.get(&printer)
.cloned()
.unwrap_or_default();
let draft = self.plot_dialog.printer_editor.as_mut().unwrap();
draft.choices = options
.iter()
.map(|option| {
let choice = remembered
.get(&option.key)
.filter(|c| option.choices.iter().any(|x| &x.keyword == *c))
.cloned()
.unwrap_or_else(|| option.default.clone());
(option.key.clone(), choice)
})
.collect();
draft.options = Some(options);
}
Err(error) => draft.error = Some(error),
}
}
}
Task::none()
}
M::PrinterOptionSet(key, choice) => {
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
draft.choices.insert(key, choice);
}
Task::none()
}
M::PrinterOptionsReset => {
if let Some(draft) = self.plot_dialog.printer_editor.as_mut() {
if let Some(options) = &draft.options {
draft.choices = options
.iter()
.map(|option| (option.key.clone(), option.default.clone()))
.collect();
}
}
Task::none()
}
M::PrinterOptionsApply => {
if let Some(draft) = self.plot_dialog.printer_editor.take() {
let overrides = draft.overrides();
if overrides.is_empty() {
self.plot_dialog.driver_options.remove(&draft.printer);
} else {
self.plot_dialog.driver_options.insert(draft.printer.clone(), overrides);
}
self.save_config();
}
Task::none()
}
M::PrinterOptionsCancel => {
self.plot_dialog.printer_editor = None;
Task::none()
}
M::Paper(s) => {
self.plot_dialog.paper = s;
let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog);
@@ -4720,6 +4858,12 @@ impl OpenCADStudio {
printer: d.printer.clone(),
copies: d.copies.trim().parse::<u32>().unwrap_or(1).max(1),
quality: Some(d.quality.clone()),
driver_options: d
.printer
.as_ref()
.and_then(|printer| d.driver_options.get(printer))
.map(|options| options.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default(),
}
}
@@ -5220,6 +5364,73 @@ mod plot_paper_tests {
assert!(app.plot_dialog.custom_editor.is_none());
}
#[test]
fn driver_options_are_edited_remembered_and_sent_with_the_job() {
use crate::io::print_to_printer::parse_lpoptions;
const LPOPTIONS: &str = "MediaType/Media Type: *Stationery Photographic\n\
ColorModel/Print Color Mode: *RGB Gray\n\
cupsPrintQuality/Print Quality: *Normal High\n";
let mut app = app_with_printer_named_sheet();
let _ = app.on_plot_dialog_open();
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
let draft = app.plot_dialog.printer_editor.as_ref().expect("editor opens");
assert_eq!(draft.printer, "OCS Test Printer");
assert!(draft.options.is_none(), "options load in the background");
// An answer for another printer is ignored; the right one fills the editor.
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
"Other".into(),
Ok(parse_lpoptions(LPOPTIONS)),
));
assert!(app.plot_dialog.printer_editor.as_ref().unwrap().options.is_none());
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
"OCS Test Printer".into(),
Ok(parse_lpoptions(LPOPTIONS)),
));
let draft = app.plot_dialog.printer_editor.as_ref().unwrap();
assert_eq!(draft.options.as_ref().map(|o| o.len()), Some(3));
assert_eq!(draft.choices["ColorModel"], "RGB");
assert!(draft.overrides().is_empty(), "defaults are not overrides");
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("ColorModel".into(), "Gray".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionSet("cupsPrintQuality".into(), "High".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply);
assert!(app.plot_dialog.printer_editor.is_none(), "Apply closes the editor");
let remembered = &app.plot_dialog.driver_options["OCS Test Printer"];
assert_eq!(remembered.len(), 2);
assert_eq!(remembered["ColorModel"], "Gray");
// The job carries exactly those overrides…
let opts = app.plot_print_options(&app.plot_dialog);
assert_eq!(
opts.driver_options,
vec![("ColorModel".to_string(), "Gray".to_string()), ("cupsPrintQuality".to_string(), "High".to_string())]
);
// …they survive in the config, and another printer gets none of them.
assert_eq!(app.current_config().plot.driver_options.len(), 1);
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("Another".into()));
assert!(app.plot_print_options(&app.plot_dialog).driver_options.is_empty());
// Reopening the editor starts from the remembered choices; Reset then
// Apply forgets them.
let _ = app.on_plot_dlg(PlotDlgMsg::Printer("OCS Test Printer".into()));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
"OCS Test Printer".into(),
Ok(parse_lpoptions(LPOPTIONS)),
));
assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().choices["ColorModel"], "Gray");
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsReset);
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsApply);
assert!(app.plot_dialog.driver_options.get("OCS Test Printer").is_none());
// A failed listing is reported inside the editor, not lost.
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterProperties);
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsLoaded(
"OCS Test Printer".into(),
Err("no cups".into()),
));
assert_eq!(app.plot_dialog.printer_editor.as_ref().unwrap().error.as_deref(), Some("no cups"));
let _ = app.on_plot_dlg(PlotDlgMsg::PrinterOptionsCancel);
assert!(app.plot_dialog.printer_editor.is_none());
}
#[test]
fn an_unknown_sheet_from_a_drawing_keeps_its_own_dimensions() {
let mut app = app_with_printer_named_sheet();
+2 -2
View File
@@ -64,8 +64,8 @@ impl OpenCADStudio {
) -> Element<'s, Message> {
sized_flow(
extra,
760,
540,
940,
620,
|flow| {
crate::ui::window::plot::view_window(
&self.plot_dialog,