From 737666b89452a22dcb6c1cbf2c4dc237b298b729 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:04:52 +0300 Subject: [PATCH] feat(plot): printer driver properties and a roomier plot dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 9 + locales/bg-BG/opencadstudio.ftl | 9 + locales/cs-CZ/opencadstudio.ftl | 9 + locales/de-DE/opencadstudio.ftl | 9 + locales/el-GR/opencadstudio.ftl | 9 + locales/en-US/opencadstudio.ftl | 9 + locales/es-ES/opencadstudio.ftl | 9 + locales/fi-FI/opencadstudio.ftl | 9 + locales/fr-FR/opencadstudio.ftl | 9 + locales/hi-IN/opencadstudio.ftl | 9 + locales/hu-HU/opencadstudio.ftl | 9 + locales/it-IT/opencadstudio.ftl | 9 + locales/ja-JP/opencadstudio.ftl | 9 + locales/ko-KR/opencadstudio.ftl | 9 + locales/nl-NL/opencadstudio.ftl | 9 + locales/pl-PL/opencadstudio.ftl | 9 + locales/pt-BR/opencadstudio.ftl | 9 + locales/ru-RU/opencadstudio.ftl | 9 + locales/tr-TR/opencadstudio.ftl | 9 + locales/zh-CN/opencadstudio.ftl | 9 + locales/zh-TW/opencadstudio.ftl | 9 + src/app/update/file.rs | 227 ++++++++++++++++++++++- src/app/view/modal.rs | 4 +- src/io/plot_device.rs | 21 ++- src/io/print_to_printer.rs | 306 ++++++++++++++++++++++++++++++- src/locale_catalog.rs | 9 + src/ui/style/form.rs | 29 +-- src/ui/window/plot.rs | 316 ++++++++++++++++++++++++-------- 29 files changed, 1000 insertions(+), 103 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1c1536f7d..e3e7ba349 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 7130e80c8..c11676a47 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -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 = الاتجاه diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index a24eb249c..1eb0a901d 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -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 = Ориентация diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 7d7364d18..5157d3f79 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -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 diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 2612d5f56..9c4911cd8 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -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 diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index a5664898f..edb6d9e6e 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -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 = Προσανατολισμός diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index dbcc88cd7..559a55db3 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -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 diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 8faefd7a0..597e224bd 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -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 diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 9de9ec986..8c306f69b 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -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 diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index c2e716831..b9f0044c8 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -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 diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 6e34cface..d799d20d0 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -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 = अभिविन्यास diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index b204a6052..4e2820102 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -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 diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 68d841798..4cc8190c9 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -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 diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 6bf94b41a..ce01e9dda 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -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 = 方向 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index a79acfaa8..e5a6fb083 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -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 = 오리엔테이션 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 4a7bbc633..f22b95fe8 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -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 diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index ede80253e..eb1491f71 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -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 diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 68e04964a..43df0a1d6 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -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 diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index cbd2bfe63..b89d617d4 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -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 = Ориентация diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 954050cba..fb9160dcc 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -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 diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 97d63c729..77a8561f4 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -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 = 方向 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 63ffd2c16..7925576ab 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -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 = 方向 diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 6ac933b02..eae7b5b5d 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -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 { + 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 { + 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::().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(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 993c7ec9d..9b0f5d305 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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, diff --git a/src/io/plot_device.rs b/src/io/plot_device.rs index 670e5218b..0d553db39 100644 --- a/src/io/plot_device.rs +++ b/src/io/plot_device.rs @@ -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) diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 35129dd61..d7282b547 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -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, + /// 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 ( "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) -> (&'static str, Vec) -> 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, +} + +#[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, 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, 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 { + 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 = 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 = 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)> = 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 { + 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 = 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::(), + std::ptr::null(), + DM_OUT_BUFFER, + ) + }; + if fetched < 0 { + return Err(format!("Could not read the preferences of \"{printer}\"")); + } + edited.copy_from_slice(¤t); + // 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::(), + current.as_ptr().cast::(), + 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::(), + }; + // 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::(), 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"); + } +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 576363792..ca31119d5 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -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")), diff --git a/src/ui/style/form.rs b/src/ui/style/form.rs index cc9eff39b..6f79d4fec 100644 --- a/src/ui/style/form.rs +++ b/src/ui/style/form.rs @@ -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() } diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index 24712901e..594263e54 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -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, 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>, + /// Current choice keyword per option key. + pub choices: std::collections::BTreeMap, + /// Why the options could not be listed, when they could not. + pub error: Option, +} + +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 { + 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, + /// 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>, + /// The in-line printer-properties editor while it is open. + #[serde(skip)] + pub printer_editor: Option, /// Chosen printer name, or `None` for the system default. pub printer: Option, /// 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 { 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 = 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 = 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));