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

+1 -1
View File
@@ -63,7 +63,7 @@ naga = { version = "27", features = ["wgsl-in"] }
[target.'cfg(target_os = "windows")'.dependencies]
libloading = "0.8"
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] }
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input", "Win32_System_LibraryLoader", "Win32_System_Threading", "Win32_Security", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing", "Win32_Graphics_Gdi"] }
[target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] }
+9
View File
@@ -5029,6 +5029,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = دمج الخطوط المتداخلة
.object-lineweights = سماكات خطوط الكائنات
.orientation = الاتجاه
+9
View File
@@ -5017,6 +5017,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Обедини припокриващи се линии
.object-lineweights = Дебелини на линии на обект
.orientation = Ориентация
+9
View File
@@ -5014,6 +5014,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Sloučit překrývající se čáry
.object-lineweights = Tloušťky čar objektů
.orientation = Orientace
+9
View File
@@ -5015,6 +5015,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Zusammenführung überlappender Leitungen
.object-lineweights = Objektliniengewichte
.orientation = Ausrichtung
+9
View File
@@ -5017,6 +5017,15 @@ plot =
.custom-paper-size = Προσαρμοσμένο μέγεθος χαρτιού
.printable-margins = Εκτυπώσιμα περιθώρια
.enter-a-positive-width-and-height = Δώστε θετικό πλάτος και ύψος.
.paper-size = Μέγεθος χαρτιού
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ επιλογή(-ές) driver ορισμένες για αυτόν τον εκτυπωτή.
.printer-properties-printer = Ιδιότητες εκτυπωτή — __ocs_fmt_0__
.this-printer-reports-no-driver-options = Ο εκτυπωτής δεν αναφέρει επιλογές driver.
.could-not-read-the-printer-s-options-error = Αδυναμία ανάγνωσης των επιλογών του εκτυπωτή: __ocs_fmt_0__
.reading-the-printer-s-options = Ανάγνωση των επιλογών του εκτυπωτή…
.printing-preferences-saved-for-printer = Οι προτιμήσεις εκτύπωσης αποθηκεύτηκαν για τον εκτυπωτή __ocs_fmt_0__.
.printing-preferences-unchanged = Οι προτιμήσεις εκτύπωσης δεν άλλαξαν.
.printer-properties = Ιδιότητες εκτυπωτή
.merge-overlapping-lines = Συγχώνευση επικαλυπτόμενων γραμμών
.object-lineweights = Πάχη γραμμών αντικειμένων
.orientation = Προσανατολισμός
+9
View File
@@ -5018,6 +5018,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Merge overlapping lines
.object-lineweights = Object lineweights
.orientation = Orientation
+9
View File
@@ -5017,6 +5017,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Fusionar líneas solapadas
.object-lineweights = Grosores de línea de objetos
.orientation = Orientación
+9
View File
@@ -5016,6 +5016,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Yhdistä päällekkäiset rivit
.object-lineweights = Objektin viivan painot
.orientation = Suuntautuminen
+9
View File
@@ -5017,6 +5017,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Fusionner les lignes qui se chevauchent
.object-lineweights = Poids de ligne d'objet
.orientation = Orientation
+9
View File
@@ -5005,6 +5005,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = मर्ज ओवरलैपिंग लाइन
.object-lineweights = ऑब्जेक्ट लाइनवेट
.orientation = अभिविन्यास
+9
View File
@@ -5016,6 +5016,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Egyesítse az átfedő vonalakat
.object-lineweights = Objektumvonalvastagságok
.orientation = Tájékozódás
+9
View File
@@ -5016,6 +5016,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Unisci linee sovrapposte
.object-lineweights = Spessori linea degli oggetti
.orientation = Orientamento
+9
View File
@@ -5005,6 +5005,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = 重複線を結合
.object-lineweights = オブジェクトの線の太さ
.orientation = 方向
+9
View File
@@ -5016,6 +5016,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = 겹치는 선 병합
.object-lineweights = 객체 선가중치
.orientation = 오리엔테이션
+9
View File
@@ -5015,6 +5015,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Overlaplijnen samenvoegen
.object-lineweights = Objectlijndiktes
.orientation = Oriëntatie
+9
View File
@@ -5016,6 +5016,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Połącz nakładające się linie
.object-lineweights = Szerokości linii obiektu
.orientation = Orientacja
+9
View File
@@ -5017,6 +5017,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Mesclar linhas sobrepostas
.object-lineweights = Espessuras de linha dos objetos
.orientation = Orientação
+9
View File
@@ -5018,6 +5018,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Слияние пересекающихся линий
.object-lineweights = Весы для объектов
.orientation = Ориентация
+9
View File
@@ -4957,6 +4957,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = Çakışan çizgileri birleştir
.object-lineweights = Nesne çizgi kalınlıkları
.orientation = Yönelim
+9
View File
@@ -5000,6 +5000,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = 合并重叠线
.object-lineweights = 对象线宽
.orientation = 方向
+9
View File
@@ -5012,6 +5012,15 @@ plot =
.custom-paper-size = Custom paper size
.printable-margins = Printable margins
.enter-a-positive-width-and-height = Enter a positive width and height.
.paper-size = Paper size
.count-driver-option-s-set-for-this-printer = __ocs_fmt_0__ driver option(s) set for this printer.
.printer-properties-printer = Printer properties — __ocs_fmt_0__
.this-printer-reports-no-driver-options = This printer reports no driver options.
.could-not-read-the-printer-s-options-error = Could not read the printer's options: __ocs_fmt_0__
.reading-the-printer-s-options = Reading the printer's options…
.printing-preferences-saved-for-printer = Printing preferences saved for __ocs_fmt_0__.
.printing-preferences-unchanged = Printing preferences unchanged.
.printer-properties = Printer properties
.merge-overlapping-lines = 合併重疊線
.object-lineweights = 物件線寬
.orientation = 方向
+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,
+18 -3
View File
@@ -22,8 +22,10 @@ use std::sync::{Arc, Mutex, OnceLock};
/// AutoCAD's plotter configuration name for PDF output.
pub const PDF_DEVICE_NAME: &str = "DWG To PDF.pc3";
/// AutoCAD's spelling for "no plotter assigned".
/// AutoCAD's spelling for "no plotter assigned" in the plot dialog…
pub const NO_DEVICE_NAME: &str = "None";
/// …and the name it actually stores in the drawing for that choice.
const NO_DEVICE_STORED_NAME: &str = "none_device";
/// The label older Open CAD Studio builds wrote into drawings for PDF output.
/// Recognised on read so those drawings keep plotting to PDF, and always
/// rewritten with [`PDF_DEVICE_NAME`] on save.
@@ -44,7 +46,7 @@ impl PlotDevice {
/// The name AutoCAD would store for this device.
pub fn canonical_name(&self) -> String {
match self {
PlotDevice::None => NO_DEVICE_NAME.to_string(),
PlotDevice::None => NO_DEVICE_STORED_NAME.to_string(),
PlotDevice::Pdf => PDF_DEVICE_NAME.to_string(),
PlotDevice::Printer(name) => name.clone(),
}
@@ -61,6 +63,10 @@ impl PlotDevice {
if name.is_empty() || name.eq_ignore_ascii_case(NO_DEVICE_NAME) {
return (PlotDevice::None, false);
}
if name.eq_ignore_ascii_case(NO_DEVICE_STORED_NAME) {
// AutoCAD's own spelling: keep it rather than rewriting it.
return (PlotDevice::None, true);
}
if name == LEGACY_PDF_LABEL {
return (PlotDevice::Pdf, false);
}
@@ -387,7 +393,8 @@ mod tests {
#[test]
fn canonical_names_follow_autocad() {
assert_eq!(PlotDevice::Pdf.canonical_name(), "DWG To PDF.pc3");
assert_eq!(PlotDevice::None.canonical_name(), "None");
// What AutoCAD writes for "None" in its plot dialog.
assert_eq!(PlotDevice::None.canonical_name(), "none_device");
assert_eq!(
PlotDevice::Printer("L3270".into()).canonical_name(),
"L3270"
@@ -401,6 +408,14 @@ mod tests {
PlotDevice::from_stored_name("None"),
(PlotDevice::None, false)
);
assert_eq!(
PlotDevice::from_stored_name("none_device"),
(PlotDevice::None, true)
);
assert_eq!(
PlotDevice::from_stored_name("NONE_DEVICE"),
(PlotDevice::None, true)
);
assert_eq!(
PlotDevice::from_stored_name("Save to PDF file…"),
(PlotDevice::Pdf, false)
+305 -1
View File
@@ -47,6 +47,12 @@ pub struct PrintOptions {
/// setting wins, so the field is legitimately unread there.
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub quality: Option<String>,
/// Driver options chosen in the printer-properties editor, as CUPS
/// `key=value` pairs (`ColorModel=Gray`, `MediaType=Photographic`). Read
/// on the CUPS path (`lp -o`); Windows keeps such choices in the
/// printer's own preferences instead.
#[cfg_attr(target_os = "windows", allow(dead_code))]
pub driver_options: Vec<(String, String)>,
}
#[cfg(target_arch = "wasm32")]
@@ -238,13 +244,16 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
.map(str::trim)
.filter(|name| !name.is_empty());
// `/e` opens the driver's *printing preferences* (paper, tray, duplex,
// quality — what a print job honours), not the device's admin properties
// sheet that `/p` shows.
#[cfg(target_os = "windows")]
let command = match named {
Some(name) => (
"rundll32.exe",
vec![
"printui.dll,PrintUIEntry".to_string(),
"/p".to_string(),
"/e".to_string(),
"/n".to_string(),
name.to_string(),
],
@@ -263,6 +272,7 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
let command = {
// Reached only outside Linux; Linux opens the desktop's own panel.
let target = named
.map(|name| format!("http://localhost:631/printers/{name}"))
.unwrap_or_else(|| "http://localhost:631/printers".to_string());
@@ -275,6 +285,11 @@ fn printer_properties_command(printer: Option<&str>) -> (&'static str, Vec<Strin
/// Open the operating system's printer configuration surface.
#[cfg(not(target_arch = "wasm32"))]
pub fn open_printer_properties(printer: Option<&str>) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
return open_desktop_printer_settings(printer);
}
#[allow(unreachable_code)]
let (program, args) = printer_properties_command(printer);
std::process::Command::new(program)
.args(args)
@@ -406,6 +421,10 @@ fn dispatch_to_printer_opts(
};
cmd.arg("-o").arg(format!("print-quality={pq}"));
}
// The driver's own options, exactly as the PPD names them.
for (key, value) in &opts.driver_options {
cmd.arg("-o").arg(format!("{key}={value}"));
}
let lp_result = cmd
.arg("--")
.arg(path_str.as_ref())
@@ -523,3 +542,288 @@ mod printer_properties_tests {
);
}
}
// ── Driver options (CUPS PPD) ─────────────────────────────────────────────
/// One option a CUPS printer's driver exposes, as `lpoptions -l` lists it:
/// the PPD keyword, its description, the choices, and the current default.
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterOption {
/// PPD keyword, e.g. `MediaType`, passed back verbatim as `-o key=value`.
pub key: String,
/// The driver's description, e.g. `Media Type`.
pub label: String,
/// Keyword of the currently selected choice.
pub default: String,
pub choices: Vec<PrinterChoice>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct PrinterChoice {
/// PPD choice keyword, e.g. `PhotographicHighGloss`.
pub keyword: String,
/// Readable form of the keyword, e.g. `Photographic High Gloss`.
pub label: String,
}
impl PrinterOption {
pub fn label_for(&self, keyword: &str) -> String {
self.choices
.iter()
.find(|choice| choice.keyword == keyword)
.map(|choice| choice.label.clone())
.unwrap_or_else(|| humanize_keyword(keyword))
}
}
/// Ask CUPS for the options of `printer` (blocking, one `lpoptions` spawn).
/// The sheet (`PageSize`) is left out: the plot dialog chooses it.
#[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
pub fn printer_options(printer: &str) -> Result<Vec<PrinterOption>, String> {
let output = std::process::Command::new("lpoptions")
.args(["-p", printer, "-l"])
.output()
.map_err(|error| format!("lpoptions: {error}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
}
Ok(parse_lpoptions(&String::from_utf8_lossy(&output.stdout)))
}
/// Windows drivers keep their options behind `DocumentProperties`; there is
/// no list to show in-line.
#[cfg(any(target_arch = "wasm32", target_os = "windows"))]
pub fn printer_options(_printer: &str) -> Result<Vec<PrinterOption>, String> {
Ok(Vec::new())
}
/// Parse `lpoptions -l` output: one option per line as
/// `Key/Description: choice *default choice …`, the asterisk marking the
/// current default. Numeric-range options print the same way and are kept.
pub fn parse_lpoptions(text: &str) -> Vec<PrinterOption> {
let mut options = Vec::new();
for line in text.lines() {
let Some((head, tail)) = line.split_once(':') else {
continue;
};
let (key, label) = head
.split_once('/')
.map(|(key, label)| (key.trim(), label.trim()))
.unwrap_or((head.trim(), head.trim()));
if key.is_empty() || key.eq_ignore_ascii_case("PageSize") {
continue;
}
let mut default = String::new();
let choices: Vec<PrinterChoice> = tail
.split_whitespace()
.map(|token| {
let (keyword, is_default) = match token.strip_prefix('*') {
Some(keyword) => (keyword, true),
None => (token, false),
};
if is_default {
default = keyword.to_string();
}
PrinterChoice {
keyword: keyword.to_string(),
label: humanize_keyword(keyword),
}
})
.collect();
if choices.is_empty() {
continue;
}
if default.is_empty() {
default = choices[0].keyword.clone();
}
options.push(PrinterOption {
key: key.to_string(),
label: if label.is_empty() { key.to_string() } else { label.to_string() },
default,
choices,
});
}
options
}
/// `PhotographicHighGloss` → `Photographic High Gloss`, `DuplexNoTumble` →
/// `Duplex No Tumble`; keywords that are already words or numbers pass
/// through. Consecutive capitals (`RGB`, `A4`) stay together.
pub fn humanize_keyword(keyword: &str) -> String {
let mut out = String::with_capacity(keyword.len() + 4);
let chars: Vec<char> = keyword.chars().collect();
for (index, &ch) in chars.iter().enumerate() {
if index > 0 && ch.is_ascii_uppercase() {
let previous = chars[index - 1];
let next_lower = chars.get(index + 1).is_some_and(|c| c.is_ascii_lowercase());
if previous.is_ascii_lowercase() || (previous.is_ascii_uppercase() && next_lower) {
out.push(' ');
}
}
out.push(ch);
}
out
}
/// The desktop environment's printer settings panel — where the driver
/// options cannot be listed (no CUPS) the user still gets somewhere useful.
/// Tries the panel of the running desktop first, then the
/// distribution-neutral tools, and only then the CUPS web interface.
#[cfg(target_os = "linux")]
pub fn open_desktop_printer_settings(printer: Option<&str>) -> Result<(), String> {
let desktop = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase();
let mut candidates: Vec<(&str, Vec<String>)> = Vec::new();
if desktop.contains("gnome") || desktop.contains("ubuntu") || desktop.contains("cinnamon") {
candidates.push(("gnome-control-center", vec!["printers".into()]));
}
if desktop.contains("kde") || desktop.contains("plasma") {
candidates.push(("kcmshell6", vec!["kcm_printer_manager".into()]));
candidates.push(("kcmshell5", vec!["kcm_printer_manager".into()]));
}
candidates.push(("system-config-printer", Vec::new()));
candidates.push(("gnome-control-center", vec!["printers".into()]));
for (program, args) in candidates {
if std::process::Command::new(program)
.args(&args)
.spawn()
.is_ok()
{
return Ok(());
}
}
let target = printer
.map(|name| format!("http://localhost:631/printers/{name}"))
.unwrap_or_else(|| "http://localhost:631/printers".to_string());
std::process::Command::new("xdg-open")
.arg(target)
.spawn()
.map(|_| ())
.map_err(|error| format!("Could not open printer settings: {error}"))
}
/// Show the driver's own document-properties sheet for `printer`, owned by
/// the window `owner` (an `HWND`, 0 for none), and on OK store the result as
/// the user's printing preferences for that printer — which is what the
/// print job then honours. Returns `Ok(false)` when the user cancelled.
#[cfg(target_os = "windows")]
pub fn edit_printer_preferences(printer: &str, owner: isize) -> Result<bool, String> {
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Graphics::Gdi::DEVMODEW;
use windows_sys::Win32::Graphics::Printing::{
ClosePrinter, DocumentPropertiesW, OpenPrinterW, SetPrinterW, PRINTER_INFO_9W,
};
// winspool.h: DM_OUT_BUFFER = DM_COPY, DM_IN_PROMPT = DM_PROMPT,
// DM_IN_BUFFER = DM_MODIFY.
const DM_OUT_BUFFER: u32 = 2;
const DM_IN_PROMPT: u32 = 4;
const DM_IN_BUFFER: u32 = 8;
const IDOK: i32 = 1;
let name: Vec<u16> = printer.encode_utf16().chain(std::iter::once(0)).collect();
let mut handle: HANDLE = std::ptr::null_mut();
// SAFETY: `name` is NUL-terminated and outlives the call; a null default
// asks for PRINTER_ACCESS_USE, enough for per-user preferences.
if unsafe { OpenPrinterW(name.as_ptr(), &mut handle, std::ptr::null()) } == 0 {
return Err(format!("Could not open printer \"{printer}\""));
}
let owner = owner as windows_sys::Win32::Foundation::HWND;
let outcome = (|| {
// SAFETY: with null buffers the call only reports the DEVMODE size.
let size = unsafe {
DocumentPropertiesW(owner, handle, name.as_ptr(), std::ptr::null_mut(), std::ptr::null(), 0)
};
if size <= 0 {
return Err(format!("Printer \"{printer}\" reports no document properties"));
}
// DEVMODEW is u16-aligned; a u16 buffer keeps the cast sound.
let words = (size as usize).div_ceil(2);
let mut current = vec![0u16; words];
let mut edited = vec![0u16; words];
// SAFETY: both buffers hold `size` bytes as the driver requested.
let fetched = unsafe {
DocumentPropertiesW(
owner,
handle,
name.as_ptr(),
current.as_mut_ptr().cast::<DEVMODEW>(),
std::ptr::null(),
DM_OUT_BUFFER,
)
};
if fetched < 0 {
return Err(format!("Could not read the preferences of \"{printer}\""));
}
edited.copy_from_slice(&current);
// SAFETY: as above; DM_IN_PROMPT runs the driver's modal sheet.
let response = unsafe {
DocumentPropertiesW(
owner,
handle,
name.as_ptr(),
edited.as_mut_ptr().cast::<DEVMODEW>(),
current.as_ptr().cast::<DEVMODEW>(),
DM_IN_BUFFER | DM_IN_PROMPT | DM_OUT_BUFFER,
)
};
if response != IDOK {
return Ok(false);
}
let info = PRINTER_INFO_9W {
pDevMode: edited.as_mut_ptr().cast::<DEVMODEW>(),
};
// SAFETY: level 9 takes a PRINTER_INFO_9W; the DEVMODE outlives the call.
if unsafe { SetPrinterW(handle, 9, (&info as *const PRINTER_INFO_9W).cast::<u8>(), 0) } == 0 {
return Err(format!("Could not save the preferences of \"{printer}\""));
}
Ok(true)
})();
// SAFETY: `handle` came from OpenPrinterW above.
unsafe { ClosePrinter(handle) };
outcome
}
#[cfg(test)]
mod driver_option_tests {
use super::*;
const LPOPTIONS: &str = "PageSize/Media Size: 101.6x180.6mm *A4 A4.Borderless A5\n\
MediaType/Media Type: *Stationery PhotographicHighGloss Photographic Envelope\n\
ColorModel/Print Color Mode: *RGB Gray AutoGray ProcessGray\n\
Duplex/2-Sided Printing: *None DuplexNoTumble DuplexTumble\n\
cupsPrintQuality/Print Quality: *Normal High\n\
print-scaling/Print Scaling: auto auto-fit fill *fit none\n\
Broken line without colon\n";
#[test]
fn lpoptions_output_becomes_options_with_defaults() {
let options = parse_lpoptions(LPOPTIONS);
let keys: Vec<&str> = options.iter().map(|o| o.key.as_str()).collect();
// PageSize is the plot dialog's business; everything else is listed.
assert_eq!(
keys,
["MediaType", "ColorModel", "Duplex", "cupsPrintQuality", "print-scaling"]
);
let media = &options[0];
assert_eq!(media.label, "Media Type");
assert_eq!(media.default, "Stationery");
assert_eq!(media.choices[1].keyword, "PhotographicHighGloss");
assert_eq!(media.choices[1].label, "Photographic High Gloss");
assert_eq!(media.label_for("Envelope"), "Envelope");
let scaling = &options[4];
assert_eq!(scaling.default, "fit");
assert_eq!(scaling.choices.len(), 5);
}
#[test]
fn keywords_read_as_words() {
assert_eq!(humanize_keyword("DuplexNoTumble"), "Duplex No Tumble");
assert_eq!(humanize_keyword("RGB"), "RGB");
assert_eq!(humanize_keyword("AutoGray"), "Auto Gray");
assert_eq!(humanize_keyword("ProcessGray"), "Process Gray");
assert_eq!(humanize_keyword("auto-fit"), "auto-fit");
assert_eq!(humanize_keyword("PhotographicSemiGloss"), "Photographic Semi Gloss");
assert_eq!(humanize_keyword("A4"), "A4");
}
}
+9
View File
@@ -1460,6 +1460,15 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Custom paper size" => Some(("plot", "custom-paper-size")),
"Printable margins" => Some(("plot", "printable-margins")),
"Enter a positive width and height." => Some(("plot", "enter-a-positive-width-and-height")),
"Paper size" => Some(("plot", "paper-size")),
"{count} driver option(s) set for this printer." => Some(("plot", "count-driver-option-s-set-for-this-printer")),
"Printer properties — {printer}" => Some(("plot", "printer-properties-printer")),
"This printer reports no driver options." => Some(("plot", "this-printer-reports-no-driver-options")),
"Could not read the printer's options: {error}" => Some(("plot", "could-not-read-the-printer-s-options-error")),
"Reading the printer's options…" => Some(("plot", "reading-the-printer-s-options")),
"Printing preferences saved for {printer}." => Some(("plot", "printing-preferences-saved-for-printer")),
"Printing preferences unchanged." => Some(("plot", "printing-preferences-unchanged")),
"Printer properties" => Some(("plot", "printer-properties")),
"Layout renamed: '{old_name}' → '{new_name}'" => Some(("common", "layout-renamed-old-name-new-name")),
"Layouts" => Some(("layout", "layouts")),
"Leader" => Some(("common", "leader")),
+18 -11
View File
@@ -189,18 +189,25 @@ pub fn labeled_field_enabled<'a>(
.into()
}
/// A short unlabeled text field for a compact multi-value row (`L B R T`).
pub fn compact_field<'a>(
placeholder: &'a str,
/// A `label : text field` pair with its own label width, for rows that put
/// several small values side by side.
pub fn labeled_field_compact<'a>(
label: Cow<'static, str>,
value: &'a str,
on_input: impl Fn(String) -> Message + 'a,
width: f32,
label_width: f32,
field_width: f32,
) -> Element<'a, Message> {
text_input(placeholder, value)
.on_input(on_input)
.style(field_style)
.size(12)
.padding([3, 6])
.width(width)
.into()
row![
text(label).size(11).style(muted_style).width(label_width),
text_input("", value)
.on_input(on_input)
.style(field_style)
.size(12)
.padding([3, 6])
.width(field_width),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
+239 -77
View File
@@ -153,6 +153,18 @@ pub enum PlotDlgMsg {
Commit,
Preview,
PrinterProperties,
/// The driver options of a printer arrived (or failed to), for the
/// in-line properties editor; carries the printer's name so a late
/// answer for a printer the user has since left is ignored.
PrinterOptionsLoaded(String, Result<Vec<crate::io::print_to_printer::PrinterOption>, String>),
/// One option of the properties editor was set to a choice keyword.
PrinterOptionSet(String, String),
/// Keep the editor's choices for the printer and close it.
PrinterOptionsApply,
/// Put every option back to the driver's default.
PrinterOptionsReset,
/// Close the editor without keeping its changes.
PrinterOptionsCancel,
Printer(String),
/// A printer answered (or failed) the media query started when it was
/// selected; carries the printer's name so a stale answer for a printer
@@ -291,7 +303,7 @@ impl CustomPaperDraft {
let (Some(width), Some(height)) = (number(&self.width), number(&self.height)) else {
return Err(CustomPaperError::Size);
};
if !(width > 0.0) || !(height > 0.0) || !width.is_finite() || !height.is_finite() {
if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
return Err(CustomPaperError::Size);
}
let margin = |text: &str| number(text).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
@@ -310,6 +322,36 @@ impl CustomPaperDraft {
}
}
/// The in-line printer-properties editor: the printer it belongs to, the
/// driver's options, and the choice currently shown for each.
#[derive(Debug, Clone, PartialEq)]
pub struct PrinterOptionsDraft {
pub printer: String,
/// `None` while the options are still being fetched.
pub options: Option<Vec<crate::io::print_to_printer::PrinterOption>>,
/// Current choice keyword per option key.
pub choices: std::collections::BTreeMap<String, String>,
/// Why the options could not be listed, when they could not.
pub error: Option<String>,
}
impl PrinterOptionsDraft {
/// The choices that differ from the driver's defaults — all a job needs
/// to pass along.
pub fn overrides(&self) -> std::collections::BTreeMap<String, String> {
let Some(options) = &self.options else {
return std::collections::BTreeMap::new();
};
options
.iter()
.filter_map(|option| {
let choice = self.choices.get(&option.key)?;
(choice != &option.default).then(|| (option.key.clone(), choice.clone()))
})
.collect()
}
}
/// Transient state backing the Plot dialog. Seeded from the layout's plot
/// settings when the dialog opens; consumed on commit.
// The persisted fields form the "plot" section of the app config
@@ -336,6 +378,13 @@ pub struct PlotDialogState {
/// The inline custom-sheet editor while it is open.
#[serde(skip)]
pub custom_editor: Option<CustomPaperDraft>,
/// Driver options chosen per printer (`printer → key → choice`), the way
/// a plotter configuration remembers its device settings; applied to
/// every job sent to that printer and kept across sessions.
pub driver_options: std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>,
/// The in-line printer-properties editor while it is open.
#[serde(skip)]
pub printer_editor: Option<PrinterOptionsDraft>,
/// Chosen printer name, or `None` for the system default.
pub printer: Option<String>,
/// Output goes to a PDF file instead of a printer.
@@ -403,6 +452,8 @@ impl Default for PlotDialogState {
printer_media: None,
custom_papers: Vec::new(),
custom_editor: None,
driver_options: std::collections::BTreeMap::new(),
printer_editor: None,
printer: None,
to_file: false,
paper: paper_catalog::default_paper().canonical.to_string(),
@@ -623,25 +674,125 @@ fn choices(items: &[&str]) -> Vec<PlotChoice> {
items.iter().map(|&value| PlotChoice::localized(value)).collect()
}
/// The in-line printer-properties editor: one `label : choice` row per
/// driver option (media type, colour mode, quality, duplex, trays…), with
/// Reset / Cancel / Apply. Mirrors what LibreOffice's Properties… shows for a
/// CUPS printer, without leaving the plot dialog.
fn printer_options_editor<'a>(draft: &'a PrinterOptionsDraft, width: Length) -> Element<'a, Message> {
let mut rows = column![section_label(Cow::Owned(crate::tf!(
"Printer properties — {printer}",
printer = draft.printer
).into_owned()))]
.spacing(6);
match (&draft.options, &draft.error) {
(Some(options), _) if options.is_empty() => {
rows = rows.push(
text(t!("This printer reports no driver options."))
.size(10)
.style(muted_style),
);
}
(Some(options), _) => {
for option in options {
let current = draft
.choices
.get(&option.key)
.cloned()
.unwrap_or_else(|| option.default.clone());
let choices: Vec<PlotChoice> = option
.choices
.iter()
.map(|choice| PlotChoice {
raw: choice.keyword.clone(),
localized: false,
display: Some(choice.label.clone()),
})
.collect();
let selected = choices.iter().find(|c| c.raw == current).cloned();
let key = option.key.clone();
rows = rows.push(form::labeled_pick_list(
Cow::Owned(option.label.clone()),
choices,
selected,
move |choice| Message::PlotDlg(PlotDlgMsg::PrinterOptionSet(key.clone(), choice.raw)),
Length::Fill,
));
}
}
(None, Some(error)) => {
rows = rows.push(
text(crate::tf!("Could not read the printer's options: {error}"))
.size(10)
.style(|theme: &Theme| iced::widget::text::Style {
color: Some(theme.palette().danger.base.color),
}),
);
}
(None, None) => {
rows = rows.push(text(t!("Reading the printer's options…")).size(10).style(muted_style));
}
}
let mut buttons = row![
button(text(t!("Reset")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsReset))
.style(btn(false))
.padding([4, 10]),
Space::new().width(Length::Fill),
button(text(t!("Cancel")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsCancel))
.style(btn(false))
.padding([4, 10]),
]
.spacing(6);
if draft.options.is_some() {
buttons = buttons.push(
button(text(t!("Apply")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsApply))
.style(btn(true))
.padding([4, 12]),
);
}
rows = rows.push(buttons);
container(rows)
.padding(8)
.width(width)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.into()
}
/// The inline editor for a user-defined sheet: name, dimensions and unit,
/// printable margins, Add / Cancel. Numbers stay text until Add validates
/// them.
/// them. Every margin field carries its own side label so the four values
/// cannot be confused, and the unit is repeated where the numbers are typed.
fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Element<'a, Message> {
let msg = |m: CustomPaperMsg| Message::PlotDlg(PlotDlgMsg::CustomPaper(m));
let units_label = |inches: bool| {
if inches {
crate::i18n::translate("Inches")
} else {
crate::i18n::translate("Millimeters")
}
};
let unit = if draft.inches { "in" } else { "mm" };
let unit_options: Vec<PlotChoice> = vec![
PlotChoice::localized("Millimeters"),
PlotChoice::localized("Inches"),
];
let unit_selected = Some(PlotChoice::localized(if draft.inches { "Inches" } else { "Millimeters" }));
let margin_field = |side: MarginSide, index: usize, hint: &'a str| {
form::compact_field(hint, &draft.margins[index], move |v| msg(CustomPaperMsg::Margin(side, v)), 56.0)
const SIDE_LABEL: f32 = 52.0;
const NUMBER: f32 = 64.0;
let dimension = |label: Cow<'static, str>, value: &'a str, ctor: fn(String) -> CustomPaperMsg| {
form::labeled_field_compact(label, value, move |v| msg(ctor(v)), SIDE_LABEL, NUMBER)
};
let margin = |label: Cow<'static, str>, side: MarginSide, index: usize| {
form::labeled_field_compact(
label,
&draft.margins[index],
move |v| msg(CustomPaperMsg::Margin(side, v)),
SIDE_LABEL,
NUMBER,
)
};
let error: Element<'_, Message> = match draft.error {
Some(CustomPaperError::Size) => text(t!("Enter a positive width and height."))
@@ -652,33 +803,37 @@ fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Elemen
.into(),
None => Space::new().height(0).into(),
};
let unit_hint = text(units_label(draft.inches)).size(10).style(muted_style);
container(
column![
section_label(t!("Custom paper size")),
form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 220.0),
row![
form::labeled_field(t!("Width"), &draft.width, move |v| msg(CustomPaperMsg::Width(v)), 70.0),
form::labeled_field(t!("Height"), &draft.height, move |v| msg(CustomPaperMsg::Height(v)), 70.0),
]
.spacing(12),
form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 200.0),
form::labeled_pick_list(
t!("Units"),
unit_options,
unit_selected,
move |choice| msg(CustomPaperMsg::Units(choice.raw)),
Length::Fixed(140.0),
Length::Fixed(150.0),
),
row![
text(t!("Printable margins")).size(11).style(muted_style).width(form::LABEL_WIDTH),
margin_field(MarginSide::Left, 0, "L"),
margin_field(MarginSide::Bottom, 1, "B"),
margin_field(MarginSide::Right, 2, "R"),
margin_field(MarginSide::Top, 3, "T"),
unit_hint,
dimension(t!("Width"), &draft.width, CustomPaperMsg::Width),
dimension(t!("Height"), &draft.height, CustomPaperMsg::Height),
text(unit).size(11).style(muted_style),
]
.spacing(6)
.spacing(14)
.align_y(iced::Center),
text(format!("{} ({unit})", t!("Printable margins")))
.size(11)
.style(muted_style),
row![
margin(t!("Left"), MarginSide::Left, 0),
margin(t!("Right"), MarginSide::Right, 2),
]
.spacing(14),
row![
margin(t!("Top"), MarginSide::Top, 3),
margin(t!("Bottom"), MarginSide::Bottom, 1),
]
.spacing(14),
error,
row![
Space::new().width(Length::Fill),
@@ -897,7 +1052,11 @@ pub fn view_window(
} else {
Space::new().height(0).into()
};
let mut output_row = row![
// Heading and picker share one line — the section has nothing else to
// announce — and the picker takes the whole row so a long queue name
// stays readable; the driver's Properties… button sits beneath it.
let output_field = row![
text(t!("Printer / plotter")).size(11).style(muted_style).width(form::LABEL_WIDTH),
iced::widget::pick_list(printer_sel, printer_opts, |value| value.to_string())
.on_select(|choice| Message::PlotDlg(PlotDlgMsg::Printer(choice.raw)))
.text_size(12)
@@ -905,66 +1064,69 @@ pub fn view_window(
.width(Length::Fill),
]
.spacing(8)
.align_y(iced::Center);
if !s.to_file {
output_row = output_row.push(
.align_y(iced::Center)
.width(Length::Fill);
let printer_controls: Element<'_, Message> = if s.to_file {
Space::new().height(0).into()
} else {
row![
field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60),
Space::new().width(Length::Fill),
button(text(t!("Properties…")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterProperties))
.style(btn(false))
.padding([4, 8]),
);
}
let output_field = column![
text(t!("Output")).size(11).style(muted_style),
output_row,
]
.spacing(4)
.width(Length::Fill);
let copies_row: Element<'_, Message> = if s.to_file {
Space::new().height(0).into()
} else {
field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60)
};
let printer_panel = panel(
column![
section_label(t!("Printer / plotter")),
output_field,
copies_row,
.padding([4, 10]),
]
.spacing(7),
);
.align_y(iced::Center)
.into()
};
// A printer with remembered driver choices says so under its row; the
// editor itself unfolds there when Properties… is pressed.
let printer_note: Element<'_, Message> = match (&s.printer_editor, &s.printer) {
(Some(draft), _) => printer_options_editor(draft, width),
(None, Some(printer)) if s.driver_options.get(printer).is_some_and(|o| !o.is_empty()) => {
let count = s.driver_options[printer].len();
text(crate::tf!("{count} driver option(s) set for this printer."))
.size(10)
.style(muted_style)
.into()
}
_ => Space::new().height(0).into(),
};
let printer_panel = panel(column![output_field, printer_controls, printer_note].spacing(7));
// ── Paper, area, offset, scale ────────────────────────────────────────
// Size row with the custom-sheet controls; the editor unfolds below it.
let mut size_row = row![
drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill),
]
.spacing(6)
.align_y(iced::Center);
if s.custom_editor.is_none() {
size_row = size_row.push(
button(text(t!("Custom…")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open)))
.style(btn(false))
.padding([4, 8]),
);
if selected_is_custom {
size_row = size_row.push(
button(text(t!("Remove")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove)))
.style(btn(false))
.padding([4, 8]),
);
}
}
let custom_editor: Element<'_, Message> = match &s.custom_editor {
// The picker takes the whole row so long media names stay readable; the
// custom-sheet buttons sit right-aligned beneath it, and the editor
// unfolds in their place.
let size_row = drop_row(t!("Paper size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill);
let custom_controls: Element<'_, Message> = match &s.custom_editor {
Some(draft) => custom_paper_editor(draft, width),
None => Space::new().height(0).into(),
None => {
let mut buttons = row![Space::new().width(Length::Fill)].spacing(6);
if selected_is_custom {
buttons = buttons.push(
button(text(t!("Remove")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove)))
.style(btn(false))
.padding([4, 10]),
);
}
buttons
.push(
button(text(t!("Custom…")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open)))
.style(btn(false))
.padding([4, 10]),
)
.into()
}
};
// Like the printer row: the section is its one picker, so heading and
// control share a line.
let paper_panel = panel(column![
section_label(t!("Paper")),
size_row,
custom_editor,
custom_controls,
paper_source_note,
paper_note,
].spacing(7));