diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index f54eb5651..7130e80c8 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -5026,6 +5026,9 @@ plot = .fit-to-paper = ملاءمة للورق .layout-plots-the-current-sheet-using-the-selec = يطبع التخطيط الورقة الحالية باستخدام حجم الورق المحدد. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = دمج الخطوط المتداخلة .object-lineweights = سماكات خطوط الكائنات .orientation = الاتجاه diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 4cd537a82..a24eb249c 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Побери в хартията .layout-plots-the-current-sheet-using-the-selec = Оформлението плотира текущия лист, използвайки избрания размер хартия. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Обедини припокриващи се линии .object-lineweights = Дебелини на линии на обект .orientation = Ориентация diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index cd37022e6..7d7364d18 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -5011,6 +5011,9 @@ plot = .fit-to-paper = Přizpůsobit papíru .layout-plots-the-current-sheet-using-the-selec = Rozvržení vykreslí aktuální list pomocí zvolené velikosti papíru. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 e41fa3534..2612d5f56 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -5012,6 +5012,9 @@ plot = .fit-to-paper = An Papier anpassen .layout-plots-the-current-sheet-using-the-selec = Das Layout plottet das aktuelle Blatt mit dem ausgewählten Papierformat. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 5911988ce..a5664898f 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Προσαρμογή στο χαρτί .layout-plots-the-current-sheet-using-the-selec = Η επιλογή Διάταξη εκτυπώνει το τρέχον φύλλο με το επιλεγμένο μέγεθος χαρτιού. .sheet-sizes-and-printable-areas-reported-by-th = Μεγέθη φύλλων και εκτυπώσιμες περιοχές όπως τα αναφέρει ο εκτυπωτής. + .custom-paper-size = Προσαρμοσμένο μέγεθος χαρτιού + .printable-margins = Εκτυπώσιμα περιθώρια + .enter-a-positive-width-and-height = Δώστε θετικό πλάτος και ύψος. .merge-overlapping-lines = Συγχώνευση επικαλυπτόμενων γραμμών .object-lineweights = Πάχη γραμμών αντικειμένων .orientation = Προσανατολισμός diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index eddfd0028..dbcc88cd7 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -5015,6 +5015,9 @@ plot = .fit-to-paper = Fit to paper .layout-plots-the-current-sheet-using-the-selec = Layout plots the current sheet using the selected paper size. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 2a52f63aa..8faefd7a0 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Ajustar al papel .layout-plots-the-current-sheet-using-the-selec = La presentación traza la hoja actual con el tamaño de papel seleccionado. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 e3fbfd865..9de9ec986 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = Sovita paperille .layout-plots-the-current-sheet-using-the-selec = Asettelu piirtää nykyisen arkin käyttämällä valittua paperikokoa. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 0cbfa07b4..c2e716831 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Ajuster au papier .layout-plots-the-current-sheet-using-the-selec = La présentation trace la feuille courante avec le format de papier sélectionné. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 5a2a4f100..6e34cface 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -5002,6 +5002,9 @@ plot = .fit-to-paper = कागज़ पर फ़िट करें .layout-plots-the-current-sheet-using-the-selec = लेआउट चयनित कागज़ आकार पर वर्तमान शीट प्लॉट करता है। .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = मर्ज ओवरलैपिंग लाइन .object-lineweights = ऑब्जेक्ट लाइनवेट .orientation = अभिविन्यास diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 5222d1072..b204a6052 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = Papírra illeszthető .layout-plots-the-current-sheet-using-the-selec = Az Elrendezés az aktuális lapot a kiválasztott papírmérettel ábrázolja. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 d5277f8b7..68d841798 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = Adatta alla carta .layout-plots-the-current-sheet-using-the-selec = Layout traccia il foglio corrente utilizzando il formato carta selezionato. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 4d85663b0..6bf94b41a 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -5002,6 +5002,9 @@ plot = .fit-to-paper = 用紙にフィット .layout-plots-the-current-sheet-using-the-selec = 選択した用紙サイズで現在のシートを印刷します。 .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = 重複線を結合 .object-lineweights = オブジェクトの線の太さ .orientation = 方向 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 22cb62a7c..a79acfaa8 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = 종이에 맞추기 .layout-plots-the-current-sheet-using-the-selec = 레이아웃은 선택한 용지 크기를 사용하여 현재 시트를 플롯합니다. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = 겹치는 선 병합 .object-lineweights = 객체 선가중치 .orientation = 오리엔테이션 diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 7cd127c2f..4a7bbc633 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -5012,6 +5012,9 @@ plot = .fit-to-paper = Aan papier aanpassen .layout-plots-the-current-sheet-using-the-selec = De lay-out plot het huidige vel op het geselecteerde papierformaat. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 c215855f1..ede80253e 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -5013,6 +5013,9 @@ plot = .fit-to-paper = Dopasuj do papieru .layout-plots-the-current-sheet-using-the-selec = Układ drukuje bieżący arkusz przy użyciu wybranego rozmiaru papieru. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 5e306b0ea..68e04964a 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -5014,6 +5014,9 @@ plot = .fit-to-paper = Ajustar ao papel .layout-plots-the-current-sheet-using-the-selec = Layout plota a folha atual usando o tamanho de papel selecionado. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 92727daae..cbd2bfe63 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -5015,6 +5015,9 @@ plot = .fit-to-paper = Вписать в лист .layout-plots-the-current-sheet-using-the-selec = Лист печатается на выбранном формате бумаги. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = Слияние пересекающихся линий .object-lineweights = Весы для объектов .orientation = Ориентация diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index f24afc924..954050cba 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -4954,6 +4954,9 @@ plot = .fit-to-paper = Kağıda sığdır .layout-plots-the-current-sheet-using-the-selec = Yerleşim, geçerli sayfayı seçilen kağıt boyutuyla çizer. .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .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 34bc20fc7..97d63c729 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -4997,6 +4997,9 @@ plot = .fit-to-paper = 布满图纸 .layout-plots-the-current-sheet-using-the-selec = 布局使用所选纸张尺寸打印当前图纸。 .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = 合并重叠线 .object-lineweights = 对象线宽 .orientation = 方向 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 49f85db3c..63ffd2c16 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -5009,6 +5009,9 @@ plot = .fit-to-paper = 布滿圖紙 .layout-plots-the-current-sheet-using-the-selec = 配置使用所選紙張尺寸列印目前圖紙。 .sheet-sizes-and-printable-areas-reported-by-th = Sheet sizes and printable areas reported by the printer. + .custom-paper-size = Custom paper size + .printable-margins = Printable margins + .enter-a-positive-width-and-height = Enter a positive width and height. .merge-overlapping-lines = 合併重疊線 .object-lineweights = 物件線寬 .orientation = 方向 diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 463d4f5d4..6ac933b02 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -197,7 +197,7 @@ fn margins_for_settings( if paper_unchanged(d, stored) && stored_device == device { return stored.margins; } - let margins = device.margins_mm(&plot_dialog_paper(d)); + let margins = device.margins_mm(&plot_dialog_paper(d), &d.custom_papers); acadrust::objects::PaperMargin::new(margins.left, margins.bottom, margins.right, margins.top) } @@ -3855,6 +3855,86 @@ impl OpenCADStudio { ) } + /// The inline custom-sheet editor of the Plot dialog. A sheet is added + /// to (or replaced in) the persisted list under its media name and + /// selected at once; the list is saved immediately so it survives a + /// crash the way a plotter configuration would. + fn on_custom_paper_msg(&mut self, msg: crate::ui::window::plot::CustomPaperMsg) { + use crate::ui::window::plot::{CustomPaperDraft, CustomPaperMsg as C, MarginSide}; + let d = &mut self.plot_dialog; + match msg { + C::Open => { + let paper = plot_dialog_paper(d); + let existing = d + .custom_papers + .iter() + .find(|custom| custom.canonical() == paper.canonical); + let margins = match existing { + Some(custom) => custom.margins_mm(), + None => plot_dialog_device(d).margins_mm(&paper, &d.custom_papers), + }; + let mut draft = CustomPaperDraft::from_paper(&paper, margins); + // A catalogue sheet is only the starting size; the new sheet + // gets its own name (AutoCAD's generic one when left blank). + if existing.is_none() { + draft.name.clear(); + } + d.custom_editor = Some(draft); + } + C::Cancel => d.custom_editor = None, + C::Name(value) => Self::edit_custom_draft(d, |draft| draft.name = value), + C::Width(value) => Self::edit_custom_draft(d, |draft| draft.width = value), + C::Height(value) => Self::edit_custom_draft(d, |draft| draft.height = value), + C::Units(value) => Self::edit_custom_draft(d, |draft| draft.inches = value == "Inches"), + C::Margin(side, value) => Self::edit_custom_draft(d, |draft| { + let index = match side { + MarginSide::Left => 0, + MarginSide::Bottom => 1, + MarginSide::Right => 2, + MarginSide::Top => 3, + }; + draft.margins[index] = value; + }), + C::Add => { + let Some(draft) = d.custom_editor.as_mut() else { + return; + }; + match draft.build() { + Ok(custom) => { + let canonical = custom.canonical(); + d.custom_papers.retain(|existing| existing.canonical() != canonical); + d.custom_papers.push(custom); + d.custom_editor = None; + d.paper = canonical; + let (w, h) = plot_dialog_sheet_mm(d); + d.paper_width_mm = w; + d.paper_height_mm = h; + self.save_config(); + } + Err(error) => draft.error = Some(error), + } + } + C::Remove => { + let selected = plot_dialog_paper(d).canonical.into_owned(); + let before = d.custom_papers.len(); + d.custom_papers.retain(|custom| custom.canonical() != selected); + if d.custom_papers.len() != before { + self.save_config(); + } + } + } + } + + fn edit_custom_draft( + d: &mut crate::ui::window::plot::PlotDialogState, + edit: impl FnOnce(&mut crate::ui::window::plot::CustomPaperDraft), + ) { + if let Some(draft) = d.custom_editor.as_mut() { + edit(draft); + draft.error = None; + } + } + fn normalize_common_plot_dialog(&mut self) { self.plot_dialog.center = true; self.plot_dialog.offset_x = "0.0".into(); @@ -3895,6 +3975,10 @@ impl OpenCADStudio { } Task::none() } + M::CustomPaper(msg) => { + 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(), @@ -5077,6 +5161,65 @@ mod plot_paper_tests { assert!(app.plot_dialog.printer_media.is_none()); } + #[test] + fn a_custom_sheet_is_added_selected_persisted_and_written_with_its_margins() { + use crate::ui::window::plot::{CustomPaperMsg as C, MarginSide}; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::Printer(crate::ui::window::plot::OUT_PDF.into())); + let custom = |m: C| PlotDlgMsg::CustomPaper(m); + let _ = app.on_plot_dlg(custom(C::Open)); + // Seeded from the selected ISO A4 on the PDF driver, nameless. + let draft = app.plot_dialog.custom_editor.clone().expect("editor open"); + assert_eq!((draft.width.as_str(), draft.height.as_str()), ("210", "297")); + assert_eq!(draft.margins, ["5", "17", "6", "18"]); + assert!(draft.name.is_empty()); + let _ = app.on_plot_dlg(custom(C::Name("Roll 24".into()))); + let _ = app.on_plot_dlg(custom(C::Width("1500".into()))); + let _ = app.on_plot_dlg(custom(C::Height("609.6".into()))); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "3".into()))); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Top, "4".into()))); + let _ = app.on_plot_dlg(custom(C::Add)); + assert!(app.plot_dialog.custom_editor.is_none(), "Add closes the editor"); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!(super::plot_dialog_sheet_mm(&app.plot_dialog), (1500.0, 609.6)); + assert_eq!(app.current_config().plot.custom_papers.len(), 1, "persisted in the plot config"); + let _ = app.on_plot_dlg(PlotDlgMsg::SetCurrent); + let ps = app.tabs[app.active_tab].scene.plot_settings_for("Layout1").unwrap(); + assert_eq!(ps.paper_size, "Roll_24_(609.60_x_1500.00_MM)"); + assert_eq!((ps.paper_width, ps.paper_height), (1500.0, 609.6)); + assert_eq!(ps.margins, acadrust::objects::PaperMargin::new(3.0, 17.0, 6.0, 4.0)); + // Re-adding the same size replaces the definition instead of duplicating it. + let _ = app.on_plot_dlg(custom(C::Open)); + assert_eq!(app.plot_dialog.custom_editor.as_ref().map(|d| d.name.as_str()), Some("Roll 24")); + let _ = app.on_plot_dlg(custom(C::Margin(MarginSide::Left, "9".into()))); + let _ = app.on_plot_dlg(custom(C::Add)); + assert_eq!(app.plot_dialog.custom_papers.len(), 1); + assert_eq!(app.plot_dialog.custom_papers[0].margins.left, 9.0); + // Remove drops the definition; the sheet itself stays selectable. + let _ = app.on_plot_dlg(custom(C::Remove)); + assert!(app.plot_dialog.custom_papers.is_empty()); + assert_eq!(app.plot_dialog.paper, "Roll_24_(609.60_x_1500.00_MM)"); + } + + #[test] + fn an_invalid_custom_sheet_is_refused_with_a_reason() { + use crate::ui::window::plot::{CustomPaperError, CustomPaperMsg as C}; + let mut app = app_with_printer_named_sheet(); + let _ = app.on_plot_dialog_open(); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Open)); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("-5".into()))); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Add)); + let draft = app.plot_dialog.custom_editor.as_ref().expect("editor stays open"); + assert_eq!(draft.error, Some(CustomPaperError::Size)); + assert!(app.plot_dialog.custom_papers.is_empty()); + // Typing again clears the message. + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Width("500".into()))); + assert!(app.plot_dialog.custom_editor.as_ref().unwrap().error.is_none()); + let _ = app.on_plot_dlg(PlotDlgMsg::CustomPaper(C::Cancel)); + assert!(app.plot_dialog.custom_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/io/paper_catalog.rs b/src/io/paper_catalog.rs index 04e7164ee..1532b2354 100644 --- a/src/io/paper_catalog.rs +++ b/src/io/paper_catalog.rs @@ -16,7 +16,7 @@ use std::sync::OnceLock; /// Unit the media is defined in. AutoCAD keeps ISO/JIS sheets in millimetres /// and ANSI/ARCH sheets in inches, and the unit is part of the media name. -#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash)] +#[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, serde::Serialize, serde::Deserialize)] pub enum PaperUnits { Millimeters, Inches, @@ -107,6 +107,88 @@ pub enum Orientation { Landscape, } +/// Unprintable margins of a sheet, in the order AutoCAD stores them. The unit +/// is whatever the context says — millimetres in a drawing, the sheet's own +/// unit in a user-defined size. +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Margins { + pub left: f64, + pub bottom: f64, + pub right: f64, + pub top: f64, +} + +impl Margins { + pub const ZERO: Margins = Margins { + left: 0.0, + bottom: 0.0, + right: 0.0, + top: 0.0, + }; + + pub const fn uniform(value: f64) -> Self { + Margins { + left: value, + bottom: value, + right: value, + top: value, + } + } + + /// The same margins expressed in millimetres when they were given in + /// `units`. + pub fn to_mm(self, units: PaperUnits) -> Self { + Margins { + left: units.to_mm(self.left), + bottom: units.to_mm(self.bottom), + right: units.to_mm(self.right), + top: units.to_mm(self.top), + } + } +} + +/// A sheet the user defined, kept in the application settings the way +/// AutoCAD keeps custom sizes in a plotter configuration: a name, the +/// dimensions in the sheet's own unit, and the printable margins. +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct CustomPaper { + /// Human name, e.g. `Roll 24`; becomes `Roll_24_(…)` in the media name. + pub name: String, + pub width: f64, + pub height: f64, + pub units: PaperUnits, + /// Unprintable margins in `units`. + pub margins: Margins, +} + +impl Default for CustomPaper { + fn default() -> Self { + Self { + name: String::new(), + width: 0.0, + height: 0.0, + units: PaperUnits::Millimeters, + margins: Margins::ZERO, + } + } +} + +impl CustomPaper { + /// The catalogue sheet this definition describes. + pub fn paper(&self) -> PaperSize { + PaperSize::custom_named(&self.name, self.width, self.height, self.units) + } + + pub fn canonical(&self) -> String { + self.paper().canonical.into_owned() + } + + pub fn margins_mm(&self) -> Margins { + self.margins.to_mm(self.units) + } +} + /// One sheet of the catalogue, or a sheet reconstructed from a media name. #[derive(Clone, PartialEq, Debug)] pub struct PaperSize { @@ -156,18 +238,43 @@ impl PaperSize { /// in the plot dialog (`UserDefinedMetric_(420.00_x_297.00_MM)`). The /// dimensions are normalised to portrait so the name stays canonical. pub fn custom(width: f64, height: f64, units: PaperUnits) -> Self { + let label = match units { + PaperUnits::Millimeters => "UserDefinedMetric", + PaperUnits::Inches => "UserDefinedInches", + }; + Self::custom_named(label, width, height, units) + } + + /// A user-defined sheet under its own name, the way a plotter + /// configuration's custom sizes are named (`Roll_24_(609.60_x_1500.00_MM)`). + /// A blank name falls back to AutoCAD's generic one; spaces become + /// underscores in the media name and parentheses are dropped so the name + /// stays parseable. + pub fn custom_named(name: &str, width: f64, height: f64, units: PaperUnits) -> Self { let (width, height) = if width <= height { (width, height) } else { (height, width) }; - let label = match units { - PaperUnits::Millimeters => "UserDefinedMetric", - PaperUnits::Inches => "UserDefinedInches", - }; + let cleaned: String = name + .trim() + .chars() + .filter(|c| !matches!(c, '(' | ')')) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + if cleaned.is_empty() { + return Self::custom(width, height, units); + } Self { - canonical: Cow::Owned(canonical_name(label, width, height, units)), - label: Cow::Borrowed(label), + canonical: Cow::Owned(canonical_name( + &cleaned.replace(' ', "_"), + width, + height, + units, + )), + label: Cow::Owned(cleaned), series: PaperSeries::Other, variant: PaperVariant::Standard, width, @@ -688,6 +795,39 @@ mod tests { assert_eq!(default_paper().canonical, a4().canonical); } + #[test] + fn user_defined_sheets_carry_their_name_and_margins() { + let roll = CustomPaper { + name: "Roll 24 (long)".into(), + width: 1500.0, + height: 609.6, + units: PaperUnits::Millimeters, + margins: Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0, + }, + }; + assert_eq!(roll.canonical(), "Roll_24_long_(609.60_x_1500.00_MM)"); + assert_eq!(roll.paper().label, "Roll 24 long"); + assert_eq!(roll.paper().display(), "Roll 24 long (609.6 × 1500 mm)"); + // Round-trips through the parser like any AutoCAD name. + assert_eq!( + resolve(&roll.canonical()).unwrap().portrait_mm(), + (609.6, 1500.0) + ); + let arch = CustomPaper { + name: String::new(), + width: 30.0, + height: 42.0, + units: PaperUnits::Inches, + margins: Margins::uniform(0.25), + }; + assert_eq!(arch.canonical(), "UserDefinedInches_(30.00_x_42.00_Inches)"); + assert_eq!(arch.margins_mm(), Margins::uniform(6.35)); + } + #[test] fn fit_centers_and_scales_within_margin() { // 100×100 window onto a 210×297 sheet, Fit. Limiting axis is width: diff --git a/src/io/plot_device.rs b/src/io/plot_device.rs index ac652f899..670e5218b 100644 --- a/src/io/plot_device.rs +++ b/src/io/plot_device.rs @@ -15,7 +15,8 @@ //! a fixed table; a CUPS printer reports its media and margins over IPP //! (`media-col-database`), which is queried once per printer and cached. -use crate::io::paper_catalog::{self, PaperSize, PaperUnits, PaperVariant}; +pub use crate::io::paper_catalog::Margins; +use crate::io::paper_catalog::{self, CustomPaper, PaperSize, PaperUnits, PaperVariant}; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -73,51 +74,23 @@ impl PlotDevice { } /// Unprintable margins the device imposes on `paper`, in millimetres. - pub fn margins_mm(&self, paper: &PaperSize) -> Margins { + /// A sheet the user defined carries its own margins for every device, + /// the way a custom size in a plotter configuration does; a printer that + /// reports the same size still wins, since it knows its hardware. + pub fn margins_mm(&self, paper: &PaperSize, custom: &[CustomPaper]) -> Margins { + let user_defined = custom + .iter() + .find(|c| c.canonical() == paper.canonical) + .map(CustomPaper::margins_mm); match self { - PlotDevice::Pdf => pdf_margins_mm(paper), - PlotDevice::Printer(name) => printer_margins_mm(name, paper), + PlotDevice::Pdf => user_defined.unwrap_or_else(|| pdf_margins_mm(paper)), + PlotDevice::Printer(name) => cached_printer_capabilities(name) + .and_then(|caps| caps.margins_for(paper)) + .or(user_defined) + .unwrap_or(Margins::ZERO), // No device: AutoCAD keeps the margins of the last device or its // defaults; without one we show the full sheet as printable. - PlotDevice::None => Margins::ZERO, - } - } -} - -/// Unprintable margins in millimetres, in the order AutoCAD stores them. -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Margins { - pub left: f64, - pub bottom: f64, - pub right: f64, - pub top: f64, -} - -impl Margins { - pub const ZERO: Margins = Margins { - left: 0.0, - bottom: 0.0, - right: 0.0, - top: 0.0, - }; - - pub const fn uniform(value: f64) -> Self { - Margins { - left: value, - bottom: value, - right: value, - top: value, - } - } - - /// The same margins expressed in millimetres when they were given in - /// `units`. - pub fn to_mm(self, units: PaperUnits) -> Self { - Margins { - left: units.to_mm(self.left), - bottom: units.to_mm(self.bottom), - right: units.to_mm(self.right), - top: units.to_mm(self.top), + PlotDevice::None => user_defined.unwrap_or(Margins::ZERO), } } } @@ -234,12 +207,6 @@ pub fn printer_capabilities(printer: &str) -> Option> { Some(caps) } -fn printer_margins_mm(printer: &str, paper: &PaperSize) -> Margins { - cached_printer_capabilities(printer) - .and_then(|caps| caps.margins_for(paper)) - .unwrap_or(Margins::ZERO) -} - /// Name of the system default printer, when the platform reports one. #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))] pub fn default_printer_name() -> Option { @@ -459,7 +426,8 @@ mod tests { #[test] fn pdf_margins_follow_autocad_per_family_and_variant() { - let m = |name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap()); + let m = + |name: &str| PlotDevice::Pdf.margins_mm(&paper_catalog::resolve(name).unwrap(), &[]); // ISO sheets share one millimetre set regardless of size. let iso_standard = Margins { left: 5.0, @@ -517,12 +485,49 @@ mod tests { inch(0.03, 0.03, 0.03, 0.03) )); assert_eq!( - PlotDevice::None - .margins_mm(&paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap()), + PlotDevice::None.margins_mm( + &paper_catalog::resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(), + &[] + ), Margins::ZERO ); } + #[test] + fn user_defined_sheets_bring_their_own_margins() { + let roll = CustomPaper { + name: "Roll".into(), + width: 900.0, + height: 1200.0, + units: PaperUnits::Millimeters, + margins: Margins::uniform(3.0), + }; + let paper = roll.paper(); + assert_eq!( + PlotDevice::Pdf.margins_mm(&paper, &[roll.clone()]), + Margins::uniform(3.0) + ); + assert_eq!( + PlotDevice::None.margins_mm(&paper, &[roll.clone()]), + Margins::uniform(3.0) + ); + // An unknown printer falls back to the user's margins too. + assert_eq!( + PlotDevice::Printer("Nowhere".into()).margins_mm(&paper, &[roll]), + Margins::uniform(3.0) + ); + // Without a definition a custom-looking sheet gets the family set. + assert_eq!( + PlotDevice::Pdf.margins_mm(&paper, &[]), + Margins { + left: 5.0, + bottom: 17.0, + right: 6.0, + top: 18.0 + } + ); + } + #[test] fn ipp_media_parses_sizes_margins_borderless_and_default() { let caps = parse_ipp_media(IPP_SAMPLE).expect("media parsed"); diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 4ae3bd537..576363792 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1457,6 +1457,9 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Layout name cannot be empty." => Some(("common", "layout-name-cannot-be-empty")), "Layout plots the current sheet using the selected paper size." => Some(("plot", "layout-plots-the-current-sheet-using-the-selec")), "Sheet sizes and printable areas reported by the printer." => Some(("plot", "sheet-sizes-and-printable-areas-reported-by-th")), + "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")), "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 new file mode 100644 index 000000000..cc9eff39b --- /dev/null +++ b/src/ui/style/form.rs @@ -0,0 +1,206 @@ +//! Form building blocks shared by the in-canvas dialogs. +//! +//! The Plot dialog grew its own `label : control` rows, field and button +//! styles, and section dividers; every dialog that followed copied them. They +//! live here once so a new dialog reads like the existing ones and a theme +//! tweak lands everywhere. + +use crate::app::Message; +use crate::ui::style::common::muted_style; +use iced::widget::{button, container, pick_list, row, text, text_input, Space}; +use iced::{Background, Border, Element, Length, Theme}; +use std::borrow::Cow; + +/// Width of the label column in a `label : control` row. +pub const LABEL_WIDTH: f32 = 92.0; + +/// Dialog button: accent (primary action) or neutral. +pub fn button_style(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { + move |theme: &Theme, status| { + let palette = theme.palette(); + let pair = match (accent, status) { + (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, + (false, button::Status::Hovered | button::Status::Pressed) => palette.background.strong, + (true, _) => palette.primary.base, + _ => palette.background.weak, + }; + button::Style { + background: Some(Background::Color(pair.color)), + text_color: pair.text, + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + shadow: iced::Shadow::default(), + snap: false, + } + } +} + +/// Text field: base background, neutral border that turns primary on focus. +pub fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { + let palette = theme.palette(); + let border = match status { + text_input::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; + text_input::Style { + background: Background::Color(palette.background.base.color), + border: Border { + color: border, + width: 1.0, + radius: 3.0.into(), + }, + icon: palette.background.base.text, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } +} + +/// One-pixel horizontal rule. +pub fn hdivider<'a>(width: Length) -> Element<'a, Message> { + container(Space::new().width(width).height(1)) + .width(width) + .height(1) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .into() +} + +/// One-pixel vertical rule. +pub fn vseparator<'a>(height: Length) -> Element<'a, Message> { + container(Space::new().width(1).height(height)) + .width(1) + .height(height) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color(theme.palette().background.neutral.color)), + ..Default::default() + }) + .into() +} + +/// Muted section heading. +pub fn section_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> { + text(label).size(11).style(muted_style).into() +} + +fn row_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> { + text(label) + .size(11) + .style(muted_style) + .width(LABEL_WIDTH) + .into() +} + +/// A `label : dropdown` row. +pub fn labeled_pick_list<'a, T>( + label: Cow<'static, str>, + options: Vec, + selected: Option, + on_select: impl Fn(T) -> Message + 'a, + width: Length, +) -> Element<'a, Message> +where + T: ToString + Clone + PartialEq + 'a, +{ + let list = pick_list(selected, options, |value| value.to_string()) + .on_select(on_select) + .text_size(12) + .padding([3, 6]) + .width(width); + row![row_label(label), list] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : dropdown` row that renders read-only when `enabled` is false. +pub fn labeled_pick_list_enabled<'a, T>( + label: Cow<'static, str>, + options: Vec, + selected: Option, + on_select: impl Fn(T) -> Message + 'a, + width: Length, + enabled: bool, +) -> Element<'a, Message> +where + T: ToString + Clone + PartialEq + 'a, +{ + if enabled { + return labeled_pick_list(label, options, selected, on_select, width); + } + row![ + row_label(label), + crate::ui::read_only::field( + selected + .map(|choice| choice.to_string()) + .unwrap_or_default() + .as_str(), + 12.0, + width, + ), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` row. +pub fn labeled_field<'a>( + label: Cow<'static, str>, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + width: f32, +) -> Element<'a, Message> { + row![ + row_label(label), + text_input("", value) + .on_input(on_input) + .style(field_style) + .size(12) + .width(width), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A `label : text field` row that renders read-only when `enabled` is false. +pub fn labeled_field_enabled<'a>( + label: Cow<'static, str>, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + width: f32, + enabled: bool, +) -> Element<'a, Message> { + if enabled { + return labeled_field(label, value, on_input, width); + } + row![ + row_label(label), + crate::ui::read_only::field(value, 12.0, Length::Fixed(width)), + ] + .spacing(8) + .align_y(iced::Center) + .into() +} + +/// A short unlabeled text field for a compact multi-value row (`L B R T`). +pub fn compact_field<'a>( + placeholder: &'a str, + value: &'a str, + on_input: impl Fn(String) -> Message + 'a, + width: f32, +) -> Element<'a, Message> { + text_input(placeholder, value) + .on_input(on_input) + .style(field_style) + .size(12) + .padding([3, 6]) + .width(width) + .into() +} diff --git a/src/ui/style/mod.rs b/src/ui/style/mod.rs index f8c3fa3c7..2a86accac 100644 --- a/src/ui/style/mod.rs +++ b/src/ui/style/mod.rs @@ -10,6 +10,7 @@ pub mod scale_manager; pub mod anno_object_scale; pub mod style_manager; pub mod common; +pub mod form; #[cfg(test)] mod common_tests { diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index ee6064326..24712901e 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -5,8 +5,10 @@ //! PDF. Styled to match the other OCS dialogs (dark pills + fields). use crate::app::Message; -use crate::io::paper_catalog::{self, PaperSize}; +use crate::io::paper_catalog::{self, CustomPaper, Margins, PaperSize, PaperUnits}; use crate::io::plot_device::PrinterCapabilities; +use crate::ui::style::common::muted_style; +use crate::ui::style::form; use iced::widget::{ button, checkbox, column, container, mouse_area, row, scrollable, text, text_input, Space, @@ -75,7 +77,45 @@ impl fmt::Display for PlotChoice { #[cfg(test)] mod tests { - use super::PlotChoice; + use super::{CustomPaperDraft, CustomPaperError, PlotChoice}; + use crate::io::paper_catalog::{resolve, Margins, PaperUnits}; + + #[test] + fn custom_sheet_draft_validates_and_builds() { + let mut draft = CustomPaperDraft { + name: " Roll 24 ".into(), + width: "1500".into(), + height: "609,6".into(), + inches: false, + margins: ["5".into(), "17".into(), "".into(), "abc".into()], + error: None, + }; + let custom = draft.build().expect("valid sheet"); + assert_eq!(custom.name, "Roll 24"); + assert_eq!((custom.width, custom.height), (1500.0, 609.6)); + assert_eq!(custom.units, PaperUnits::Millimeters); + // Blank or unparsable margins mean no margin, never a refusal. + assert_eq!(custom.margins, Margins { left: 5.0, bottom: 17.0, right: 0.0, top: 0.0 }); + assert_eq!(custom.canonical(), "Roll_24_(609.60_x_1500.00_MM)"); + draft.width = "0".into(); + assert_eq!(draft.build().unwrap_err(), CustomPaperError::Size); + draft.width = "x".into(); + assert_eq!(draft.build().unwrap_err(), CustomPaperError::Size); + } + + #[test] + fn custom_sheet_draft_seeds_from_a_sheet_in_its_own_unit() { + let arch_d = resolve("ARCH_D_(24.00_x_36.00_Inches)").unwrap(); + let draft = CustomPaperDraft::from_paper(&arch_d, Margins::uniform(6.35)); + assert_eq!(draft.name, "ARCH D"); + assert_eq!((draft.width.as_str(), draft.height.as_str()), ("24", "36")); + assert!(draft.inches); + assert_eq!(draft.margins, ["0.25", "0.25", "0.25", "0.25"]); + let a4 = resolve("ISO_A4_(210.00_x_297.00_MM)").unwrap(); + let draft = CustomPaperDraft::from_paper(&a4, Margins { left: 5.0, bottom: 17.0, right: 6.0, top: 18.0 }); + assert!(!draft.inches); + assert_eq!(draft.margins, ["5", "17", "6", "18"]); + } #[test] fn named_view_display_preserves_the_saved_plot_area() { @@ -119,6 +159,8 @@ pub enum PlotDlgMsg { /// the user has since left is ignored. PrinterMedia(String, Option>), Paper(String), + /// Edits of the inline "custom paper size" editor under the Size row. + CustomPaper(CustomPaperMsg), Orientation(String), Area(String), Scale(String), @@ -153,6 +195,121 @@ pub enum PlotDlgMsg { NameCancel, } +/// Which side of the printable-margin row a value belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarginSide { + Left, + Bottom, + Right, + Top, +} + +#[derive(Debug, Clone)] +pub enum CustomPaperMsg { + /// Open the editor, seeded from the selected sheet. + Open, + Cancel, + Name(String), + Width(String), + Height(String), + /// Units picked from the dropdown, by their catalogue label. + Units(String), + Margin(MarginSide, String), + /// Add (or replace) the sheet and select it. + Add, + /// Remove the selected user-defined sheet. + Remove, +} + +/// The inline custom-sheet editor's fields, kept as typed text so a half-typed +/// number does not snap to a value while editing. Everything is in the +/// draft's own units. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CustomPaperDraft { + pub name: String, + pub width: String, + pub height: String, + pub inches: bool, + /// Left, bottom, right, top. + pub margins: [String; 4], + /// Why the last Add was refused, shown under the fields. + pub error: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CustomPaperError { + /// Width or height is not a positive number. + Size, +} + +impl CustomPaperDraft { + fn units(&self) -> PaperUnits { + if self.inches { + PaperUnits::Inches + } else { + PaperUnits::Millimeters + } + } + + /// Seed the editor from a sheet: its dimensions and unit, and the margins + /// given (in millimetres) converted into that unit. + pub fn from_paper(paper: &PaperSize, margins_mm: Margins) -> Self { + let units = paper.units; + let fmt = |v: f64| { + let v = units.from_mm(v); + if (v - v.round()).abs() < 1e-6 { + format!("{}", v.round() as i64) + } else { + format!("{v:.2}") + } + }; + let dim = |v: f64| { + if (v - v.round()).abs() < 1e-6 { + format!("{}", v.round() as i64) + } else { + format!("{v:.2}") + } + }; + Self { + name: paper.label.to_string(), + width: dim(paper.width), + height: dim(paper.height), + inches: units == PaperUnits::Inches, + margins: [ + fmt(margins_mm.left), + fmt(margins_mm.bottom), + fmt(margins_mm.right), + fmt(margins_mm.top), + ], + error: None, + } + } + + /// The sheet the fields describe, or the reason they do not describe one. + pub fn build(&self) -> Result { + let number = |text: &str| text.trim().replace(',', ".").parse::().ok(); + 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() { + return Err(CustomPaperError::Size); + } + let margin = |text: &str| number(text).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0); + Ok(CustomPaper { + name: self.name.trim().to_string(), + width, + height, + units: self.units(), + margins: Margins { + left: margin(&self.margins[0]), + bottom: margin(&self.margins[1]), + right: margin(&self.margins[2]), + top: margin(&self.margins[3]), + }, + }) + } +} + /// 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 @@ -173,6 +330,12 @@ pub struct PlotDialogState { /// while unknown, when the platform cannot ask, or for PDF output. #[serde(skip)] pub printer_media: Option>, + /// Sheets the user defined, offered for every device and remembered + /// across sessions. + pub custom_papers: Vec, + /// The inline custom-sheet editor while it is open. + #[serde(skip)] + pub custom_editor: Option, /// Chosen printer name, or `None` for the system default. pub printer: Option, /// Output goes to a PDF file instead of a printer. @@ -238,6 +401,8 @@ impl Default for PlotDialogState { printers: Vec::new(), default_printer: None, printer_media: None, + custom_papers: Vec::new(), + custom_editor: None, printer: None, to_file: false, paper: paper_catalog::default_paper().canonical.to_string(), @@ -318,80 +483,19 @@ fn legacy_fit_to_paper_default() -> bool { } fn btn(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style { - move |theme: &Theme, st| { - let palette = theme.palette(); - let pair = match (accent, st) { - (true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong, - (false, button::Status::Hovered | button::Status::Pressed) => { - palette.background.strong - } - (true, _) => palette.primary.base, - _ => palette.background.weak, - }; - button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - shadow: iced::Shadow::default(), - snap: false, - } - } -} - -fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style { - let palette = theme.palette(); - let border = match status { - text_input::Status::Focused { .. } => palette.primary.base.color, - _ => palette.background.neutral.color, - }; - text_input::Style { - background: Background::Color(palette.background.base.color), - border: Border { color: border, width: 1.0, radius: 3.0.into() }, - icon: palette.background.base.text, - placeholder: palette.background.base.text.scale_alpha(0.48), - value: palette.background.base.text, - selection: palette.primary.base.color.scale_alpha(0.5), - } -} - -fn muted_style(theme: &Theme) -> iced::widget::text::Style { - iced::widget::text::Style { - color: Some(theme.palette().background.base.text.scale_alpha(0.68)), - } + form::button_style(accent) } fn hdivider<'a>(width: Length) -> Element<'a, Message> { - container(Space::new().width(width).height(1)) - .width(width) - .height(1) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() + form::hdivider(width) } fn section_label<'a>(s: Cow<'static, str>) -> Element<'a, Message> { - text(s).size(11).style(muted_style).into() + form::section_label(s) } fn vsep<'a>(height: Length) -> Element<'a, Message> { - container(Space::new().width(1).height(height)) - .width(1) - .height(height) - .style(|theme: &Theme| container::Style { - background: Some(Background::Color( - theme.palette().background.neutral.color - )), - ..Default::default() - }) - .into() + form::vseparator(height) } fn setup_row<'a>( @@ -404,7 +508,7 @@ fn setup_row<'a>( return text_input("", rename_buf) .on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value))) .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) - .style(field_style) + .style(form::field_style) .size(11) .padding([4, 8]) .width(Fit) @@ -442,15 +546,7 @@ fn drop_row<'a>( ctor: fn(String) -> PlotDlgMsg, width: Length, ) -> Element<'a, Message> { - let pl = iced::widget::pick_list(selected, options, |value| value.to_string()) - .on_select(move |choice| Message::PlotDlg(ctor(choice.raw))) - .text_size(12) - .padding([3, 6]) - .width(width); - row![text(label).size(11).style(muted_style).width(92), pl] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_pick_list(label, options, selected, move |choice| Message::PlotDlg(ctor(choice.raw)), width) } fn drop_row_enabled<'a>( @@ -461,20 +557,14 @@ fn drop_row_enabled<'a>( width: Length, enabled: bool, ) -> Element<'a, Message> { - if enabled { - return drop_row(label, options, selected, ctor, width); - } - row![ - text(label).size(11).style(muted_style).width(92), - crate::ui::read_only::field( - selected.map(|choice| choice.to_string()).unwrap_or_default().as_str(), - 12.0, - width, - ), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_pick_list_enabled( + label, + options, + selected, + move |choice| Message::PlotDlg(ctor(choice.raw)), + width, + enabled, + ) } /// A `label : text field` row. @@ -484,17 +574,7 @@ fn field_row<'a>( ctor: fn(String) -> PlotDlgMsg, width: u16, ) -> Element<'a, Message> { - row![ - text(label).size(11).style(muted_style).width(92), - text_input("", value) - .on_input(move |s| Message::PlotDlg(ctor(s))) - .style(field_style) - .size(12) - .width(width as f32), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_field(label, value, move |s| Message::PlotDlg(ctor(s)), width as f32) } fn field_row_enabled<'a>( @@ -504,19 +584,9 @@ fn field_row_enabled<'a>( width: u16, enabled: bool, ) -> Element<'a, Message> { - if enabled { - return field_row(label, value, ctor, width); - } - row![ - text(label).size(11).style(muted_style).width(92), - crate::ui::read_only::field(value, 12.0, Length::Fixed(width as f32)), - ] - .spacing(8) - .align_y(iced::Center) - .into() + form::labeled_field_enabled(label, value, move |s| Message::PlotDlg(ctor(s)), width as f32, enabled) } -/// A single option checkbox bound to a `PlotFlag`. fn check<'a>(label: Cow<'static, str>, on: bool, flag: PlotFlag) -> Element<'a, Message> { checkbox(on) .label(label) @@ -553,6 +623,92 @@ fn choices(items: &[&str]) -> Vec { items.iter().map(|&value| PlotChoice::localized(value)).collect() } +/// The inline editor for a user-defined sheet: name, dimensions and unit, +/// printable margins, Add / Cancel. Numbers stay text until Add validates +/// them. +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_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) + }; + let error: Element<'_, Message> = match draft.error { + Some(CustomPaperError::Size) => text(t!("Enter a positive width and height.")) + .size(10) + .style(|theme: &Theme| iced::widget::text::Style { + color: Some(theme.palette().danger.base.color), + }) + .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_pick_list( + t!("Units"), + unit_options, + unit_selected, + move |choice| msg(CustomPaperMsg::Units(choice.raw)), + Length::Fixed(140.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, + ] + .spacing(6) + .align_y(iced::Center), + error, + row![ + Space::new().width(Length::Fill), + button(text(t!("Cancel")).size(11)) + .on_press(msg(CustomPaperMsg::Cancel)) + .style(btn(false)) + .padding([4, 10]), + button(text(t!("Add")).size(11)) + .on_press(msg(CustomPaperMsg::Add)) + .style(btn(true)) + .padding([4, 12]), + ] + .spacing(6), + ] + .spacing(6), + ) + .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() +} + pub fn view_window( s: &PlotDialogState, print_all_options: bool, @@ -599,7 +755,7 @@ pub fn view_window( text_input("", rename_buf) .on_input(|value| Message::PlotDlg(PlotDlgMsg::NameInput(value))) .on_submit(Message::PlotDlg(PlotDlgMsg::NameCommit)) - .style(field_style) + .style(form::field_style) .size(11) .padding([4, 8]) .width(Length::Fill), @@ -708,10 +864,21 @@ pub fn view_window( Some(caps) => caps.media.iter().map(|media| PlotChoice::paper(&media.paper)).collect(), None => paper_catalog::catalog().iter().map(PlotChoice::paper).collect(), }; + // User-defined sheets come last, after whatever the device offers. + for custom in &s.custom_papers { + let choice = PlotChoice::paper(&custom.paper()); + if !paper_opts.contains(&choice) { + paper_opts.push(choice); + } + } let paper_sel = PlotChoice::paper(&selected_paper); if !paper_opts.contains(&paper_sel) { paper_opts.insert(0, paper_sel.clone()); } + let selected_is_custom = s + .custom_papers + .iter() + .any(|custom| custom.canonical() == selected_paper.canonical); let paper_source_note: Element<'_, Message> = if printer_sheets.is_some() { text(t!("Sheet sizes and printable areas reported by the printer.")) .size(10) @@ -768,9 +935,36 @@ pub fn view_window( ); // ── 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 { + Some(draft) => custom_paper_editor(draft, width), + None => Space::new().height(0).into(), + }; let paper_panel = panel(column![ section_label(t!("Paper")), - drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, width), + size_row, + custom_editor, paper_source_note, paper_note, ].spacing(7));