Merge pull request #1311 from ghost/plotting-station-a

This commit is contained in:
Hakan Seven committed 2026-09-17 06:53:00 +03:00
commit 1701e672dd
36 files changed
+3567 -425

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
//! 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<T>,
selected: Option<T>,
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<T>,
selected: Option<T>,
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 `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,
label_width: f32,
field_width: f32,
) -> Element<'a, Message> {
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()
}
+1
View File
@@ -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 {
+565 -156
View File
@@ -5,7 +5,10 @@
//! PDF. Styled to match the other OCS dialogs (dark pills + fields).
use crate::app::Message;
use crate::io::paper_sizes::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,
@@ -28,25 +31,39 @@ pub const STYLE_NONE: &str = "<none>";
/// A plot dropdown value keeps its persisted/raw value separate from the
/// localized label shown by Iced. Printer names, scale names, paper sizes, and
/// style-table file names remain verbatim; built-in choices use the catalog.
/// A paper choice keeps the canonical media name as its value and shows the
/// sheet's human name with its dimensions.
#[derive(Clone, Debug, Eq, PartialEq)]
struct PlotChoice {
raw: String,
localized: bool,
/// Display text when it differs from `raw` (paper sizes).
display: Option<String>,
}
impl PlotChoice {
fn raw(value: impl Into<String>) -> Self {
Self { raw: value.into(), localized: false }
Self { raw: value.into(), localized: false, display: None }
}
fn localized(value: impl Into<String>) -> Self {
Self { raw: value.into(), localized: true }
Self { raw: value.into(), localized: true, display: None }
}
fn paper(paper: &PaperSize) -> Self {
Self {
raw: paper.canonical.to_string(),
localized: false,
display: Some(paper.display()),
}
}
}
impl fmt::Display for PlotChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.localized {
if let Some(display) = &self.display {
formatter.write_str(display)
} else if self.localized {
if let Some(name) = self.raw.strip_prefix("View: ") {
formatter.write_str(crate::tf!("View: {name}").as_ref())
} else {
@@ -60,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() {
@@ -98,8 +153,26 @@ pub enum PlotDlgMsg {
Commit,
Preview,
PrinterProperties,
/// The driver options of a printer arrived (or failed to), for the
/// in-line properties editor; carries the printer's name so a late
/// answer for a printer the user has since left is ignored.
PrinterOptionsLoaded(String, Result<Vec<crate::io::print_to_printer::PrinterOption>, String>),
/// One option of the properties editor was set to a choice keyword.
PrinterOptionSet(String, String),
/// Keep the editor's choices for the printer and close it.
PrinterOptionsApply,
/// Put every option back to the driver's default.
PrinterOptionsReset,
/// Close the editor without keeping its changes.
PrinterOptionsCancel,
Printer(String),
/// A printer answered (or failed) the media query started when it was
/// selected; carries the printer's name so a stale answer for a printer
/// the user has since left is ignored.
PrinterMedia(String, Option<std::sync::Arc<PrinterCapabilities>>),
Paper(String),
/// Edits of the inline "custom paper size" editor under the Size row.
CustomPaper(CustomPaperMsg),
Orientation(String),
Area(String),
Scale(String),
@@ -134,6 +207,151 @@ 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<CustomPaperError>,
}
#[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<CustomPaper, CustomPaperError> {
let number = |text: &str| text.trim().replace(',', ".").parse::<f64>().ok();
let (Some(width), Some(height)) = (number(&self.width), number(&self.height)) else {
return Err(CustomPaperError::Size);
};
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);
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]),
},
})
}
}
/// The in-line printer-properties editor: the printer it belongs to, the
/// driver's options, and the choice currently shown for each.
#[derive(Debug, Clone, PartialEq)]
pub struct PrinterOptionsDraft {
pub printer: String,
/// `None` while the options are still being fetched.
pub options: Option<Vec<crate::io::print_to_printer::PrinterOption>>,
/// Current choice keyword per option key.
pub choices: std::collections::BTreeMap<String, String>,
/// Why the options could not be listed, when they could not.
pub error: Option<String>,
}
impl PrinterOptionsDraft {
/// The choices that differ from the driver's defaults — all a job needs
/// to pass along.
pub fn overrides(&self) -> std::collections::BTreeMap<String, String> {
let Some(options) = &self.options else {
return std::collections::BTreeMap::new();
};
options
.iter()
.filter_map(|option| {
let choice = self.choices.get(&option.key)?;
(choice != &option.default).then(|| (option.key.clone(), choice.clone()))
})
.collect()
}
}
/// Transient state backing the Plot dialog. Seeded from the layout's plot
/// settings when the dialog opens; consumed on commit.
// The persisted fields form the "plot" section of the app config
@@ -147,6 +365,26 @@ pub struct PlotDialogState {
/// sentinels.
#[serde(skip)]
pub printers: Vec<String>,
/// Name of the system default printer, shown next to the default entry.
#[serde(skip)]
pub default_printer: Option<String>,
/// Sheets and printable areas the selected printer reported; `None`
/// while unknown, when the platform cannot ask, or for PDF output.
#[serde(skip)]
pub printer_media: Option<std::sync::Arc<PrinterCapabilities>>,
/// Sheets the user defined, offered for every device and remembered
/// across sessions.
pub custom_papers: Vec<CustomPaper>,
/// The inline custom-sheet editor while it is open.
#[serde(skip)]
pub custom_editor: Option<CustomPaperDraft>,
/// Driver options chosen per printer (`printer → key → choice`), the way
/// a plotter configuration remembers its device settings; applied to
/// every job sent to that printer and kept across sessions.
pub driver_options: std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>,
/// The in-line printer-properties editor while it is open.
#[serde(skip)]
pub printer_editor: Option<PrinterOptionsDraft>,
/// Chosen printer name, or `None` for the system default.
pub printer: Option<String>,
/// Output goes to a PDF file instead of a printer.
@@ -210,9 +448,15 @@ impl Default for PlotDialogState {
fn default() -> Self {
Self {
printers: Vec::new(),
default_printer: None,
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: "A4".into(),
paper: paper_catalog::default_paper().canonical.to_string(),
paper_width_mm: 297.0,
paper_height_mm: 210.0,
orientation: "Landscape".into(),
@@ -290,80 +534,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>(
@@ -376,7 +559,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)
@@ -414,15 +597,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>(
@@ -433,20 +608,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.
@@ -456,17 +625,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>(
@@ -476,19 +635,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)
@@ -525,6 +674,196 @@ fn choices(items: &[&str]) -> Vec<PlotChoice> {
items.iter().map(|&value| PlotChoice::localized(value)).collect()
}
/// The in-line printer-properties editor: one `label : choice` row per
/// driver option (media type, colour mode, quality, duplex, trays…), with
/// Reset / Cancel / Apply. Mirrors what LibreOffice's Properties… shows for a
/// CUPS printer, without leaving the plot dialog.
fn printer_options_editor<'a>(draft: &'a PrinterOptionsDraft, width: Length) -> Element<'a, Message> {
let mut rows = column![section_label(Cow::Owned(crate::tf!(
"Printer properties — {printer}",
printer = draft.printer
).into_owned()))]
.spacing(6);
match (&draft.options, &draft.error) {
(Some(options), _) if options.is_empty() => {
rows = rows.push(
text(t!("This printer reports no driver options."))
.size(10)
.style(muted_style),
);
}
(Some(options), _) => {
for option in options {
let current = draft
.choices
.get(&option.key)
.cloned()
.unwrap_or_else(|| option.default.clone());
let choices: Vec<PlotChoice> = option
.choices
.iter()
.map(|choice| PlotChoice {
raw: choice.keyword.clone(),
localized: false,
display: Some(choice.label.clone()),
})
.collect();
let selected = choices.iter().find(|c| c.raw == current).cloned();
let key = option.key.clone();
rows = rows.push(form::labeled_pick_list(
Cow::Owned(option.label.clone()),
choices,
selected,
move |choice| Message::PlotDlg(PlotDlgMsg::PrinterOptionSet(key.clone(), choice.raw)),
Length::Fill,
));
}
}
(None, Some(error)) => {
rows = rows.push(
text(crate::tf!("Could not read the printer's options: {error}"))
.size(10)
.style(|theme: &Theme| iced::widget::text::Style {
color: Some(theme.palette().danger.base.color),
}),
);
}
(None, None) => {
rows = rows.push(text(t!("Reading the printer's options…")).size(10).style(muted_style));
}
}
let mut buttons = row![
button(text(t!("Reset")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsReset))
.style(btn(false))
.padding([4, 10]),
Space::new().width(Length::Fill),
button(text(t!("Cancel")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsCancel))
.style(btn(false))
.padding([4, 10]),
]
.spacing(6);
if draft.options.is_some() {
buttons = buttons.push(
button(text(t!("Apply")).size(11))
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsApply))
.style(btn(true))
.padding([4, 12]),
);
}
rows = rows.push(buttons);
container(rows)
.padding(8)
.width(width)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.weak.color)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.into()
}
/// The inline editor for a user-defined sheet: name, dimensions and unit,
/// printable margins, Add / Cancel. Numbers stay text until Add validates
/// them. 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 unit = if draft.inches { "in" } else { "mm" };
let unit_options: Vec<PlotChoice> = vec![
PlotChoice::localized("Millimeters"),
PlotChoice::localized("Inches"),
];
let unit_selected = Some(PlotChoice::localized(if draft.inches { "Inches" } else { "Millimeters" }));
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."))
.size(10)
.style(|theme: &Theme| iced::widget::text::Style {
color: Some(theme.palette().danger.base.color),
})
.into(),
None => Space::new().height(0).into(),
};
container(
column![
section_label(t!("Custom paper size")),
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(150.0),
),
row![
dimension(t!("Width"), &draft.width, CustomPaperMsg::Width),
dimension(t!("Height"), &draft.height, CustomPaperMsg::Height),
text(unit).size(11).style(muted_style),
]
.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),
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,
@@ -571,7 +910,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),
@@ -648,7 +987,17 @@ pub fn view_window(
.spacing(4);
// ── Printer / plotter ─────────────────────────────────────────────────
let mut printer_opts = vec![PlotChoice::localized(OUT_DEFAULT)];
// The default entry names the printer it resolves to, when known, so the
// user sees where a plot will go without leaving the dialog.
let default_entry = match &s.default_printer {
Some(name) => PlotChoice {
raw: OUT_DEFAULT.to_string(),
localized: true,
display: Some(format!("{} ({name})", crate::i18n::translate(OUT_DEFAULT))),
},
None => PlotChoice::localized(OUT_DEFAULT),
};
let mut printer_opts = vec![default_entry.clone()];
printer_opts.extend(s.printers.iter().cloned().map(PlotChoice::raw));
printer_opts.push(PlotChoice::localized(OUT_PDF));
let printer_sel = if s.to_file {
@@ -656,13 +1005,44 @@ pub fn view_window(
} else {
Some(match &s.printer {
Some(printer) => PlotChoice::raw(printer.clone()),
None => PlotChoice::localized(OUT_DEFAULT),
None => default_entry,
})
};
let mut paper_opts: Vec<String> = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect();
if !paper_opts.iter().any(|name| name == &s.paper) {
paper_opts.push(s.paper.clone());
// A printer that reported its media lists those sheets (with the
// printable areas it will actually honour); PDF output and printers that
// could not be asked list the catalogue by series. A sheet that only
// exists in the drawing (a driver's custom size, an old config's bare
// name) is added at the top so the current selection stays selectable.
let selected_paper = paper_catalog::from_drawing(&s.paper, s.paper_width_mm, s.paper_height_mm);
let printer_sheets = (!s.to_file).then_some(()).and(s.printer_media.as_deref());
let mut paper_opts: Vec<PlotChoice> = match printer_sheets {
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)
.style(muted_style)
.width(width)
.into()
} else {
Space::new().height(0).into()
};
let paper_note: Element<'_, Message> = if s.area == "Layout" {
text(t!("Layout plots the current sheet using the selected paper size."))
.size(10)
@@ -672,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)
@@ -680,45 +1064,70 @@ 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 ────────────────────────────────────────
// 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 => {
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")),
drop_row(
t!("Size"),
paper_opts.into_iter().map(PlotChoice::raw).collect(),
Some(PlotChoice::raw(s.paper.clone())),
PlotDlgMsg::Paper,
width,
),
size_row,
custom_controls,
paper_source_note,
paper_note,
].spacing(7));