forked from 1b-agent/opencadstudio-upstream
feat(plot): printer driver properties and a roomier plot dialog
Properties… next to the printer used to open the CUPS web interface in a browser. It now edits the driver options of the *selected* printer the way LibreOffice's Properties… does, without leaving the plot dialog: - CUPS platforms (Linux, any desktop; macOS): an in-line editor lists the printer's PPD options as `lpoptions -l` reports them — media type, colour mode, quality, duplex, trays, scaling — with readable choice labels, Reset / Cancel / Apply. Apply keeps only the choices that differ from the driver defaults, per printer, in the plot config, and every job for that printer passes them as `lp -o key=value`. Where CUPS cannot answer, the desktop's printer settings panel opens instead of a browser. - Windows: the driver's document-properties sheet (`DocumentPropertiesW` with prompt, owned by the main window) and, on OK, the result stored as the user's printing preferences for that printer (`SetPrinter` level 9), which the job honours. `printui /e` (printing preferences) replaces the device-properties sheet as the fallback. The desktop print portal was tried first and dropped: GTK 4's print dialog loses the preselected printer when the default queue is listed before it, so the dialog always opened on the wrong printer. Dialog layout: the modal grows to 940×620; Printer / plotter and Paper size share a line with their pickers (long queue and media names now fit); Copies + Properties… and Custom… sit beneath; the custom-sheet editor labels each margin (Left / Right, Top / Bottom) and shows the unit. Also recognises `none_device`, AutoCAD's stored spelling for "no plotter", and writes it back unchanged. Tests: `lpoptions` parsing and keyword humanising, the editor flow (load, stale answer ignored, set, apply, remember, reset, error, cancel), device name round-trips including `none_device`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5e271e2dbb
commit
737666b894
29 files changed
+1000
-103
No files matched your search
+239
-77
@@ -153,6 +153,18 @@ pub enum PlotDlgMsg {
|
||||
Commit,
|
||||
Preview,
|
||||
PrinterProperties,
|
||||
/// The driver options of a printer arrived (or failed to), for the
|
||||
/// in-line properties editor; carries the printer's name so a late
|
||||
/// answer for a printer the user has since left is ignored.
|
||||
PrinterOptionsLoaded(String, Result<Vec<crate::io::print_to_printer::PrinterOption>, String>),
|
||||
/// One option of the properties editor was set to a choice keyword.
|
||||
PrinterOptionSet(String, String),
|
||||
/// Keep the editor's choices for the printer and close it.
|
||||
PrinterOptionsApply,
|
||||
/// Put every option back to the driver's default.
|
||||
PrinterOptionsReset,
|
||||
/// Close the editor without keeping its changes.
|
||||
PrinterOptionsCancel,
|
||||
Printer(String),
|
||||
/// A printer answered (or failed) the media query started when it was
|
||||
/// selected; carries the printer's name so a stale answer for a printer
|
||||
@@ -291,7 +303,7 @@ impl CustomPaperDraft {
|
||||
let (Some(width), Some(height)) = (number(&self.width), number(&self.height)) else {
|
||||
return Err(CustomPaperError::Size);
|
||||
};
|
||||
if !(width > 0.0) || !(height > 0.0) || !width.is_finite() || !height.is_finite() {
|
||||
if !(width.is_finite() && height.is_finite() && width > 0.0 && height > 0.0) {
|
||||
return Err(CustomPaperError::Size);
|
||||
}
|
||||
let margin = |text: &str| number(text).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0);
|
||||
@@ -310,6 +322,36 @@ impl CustomPaperDraft {
|
||||
}
|
||||
}
|
||||
|
||||
/// The in-line printer-properties editor: the printer it belongs to, the
|
||||
/// driver's options, and the choice currently shown for each.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PrinterOptionsDraft {
|
||||
pub printer: String,
|
||||
/// `None` while the options are still being fetched.
|
||||
pub options: Option<Vec<crate::io::print_to_printer::PrinterOption>>,
|
||||
/// Current choice keyword per option key.
|
||||
pub choices: std::collections::BTreeMap<String, String>,
|
||||
/// Why the options could not be listed, when they could not.
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl PrinterOptionsDraft {
|
||||
/// The choices that differ from the driver's defaults — all a job needs
|
||||
/// to pass along.
|
||||
pub fn overrides(&self) -> std::collections::BTreeMap<String, String> {
|
||||
let Some(options) = &self.options else {
|
||||
return std::collections::BTreeMap::new();
|
||||
};
|
||||
options
|
||||
.iter()
|
||||
.filter_map(|option| {
|
||||
let choice = self.choices.get(&option.key)?;
|
||||
(choice != &option.default).then(|| (option.key.clone(), choice.clone()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Transient state backing the Plot dialog. Seeded from the layout's plot
|
||||
/// settings when the dialog opens; consumed on commit.
|
||||
// The persisted fields form the "plot" section of the app config
|
||||
@@ -336,6 +378,13 @@ pub struct PlotDialogState {
|
||||
/// The inline custom-sheet editor while it is open.
|
||||
#[serde(skip)]
|
||||
pub custom_editor: Option<CustomPaperDraft>,
|
||||
/// Driver options chosen per printer (`printer → key → choice`), the way
|
||||
/// a plotter configuration remembers its device settings; applied to
|
||||
/// every job sent to that printer and kept across sessions.
|
||||
pub driver_options: std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>,
|
||||
/// The in-line printer-properties editor while it is open.
|
||||
#[serde(skip)]
|
||||
pub printer_editor: Option<PrinterOptionsDraft>,
|
||||
/// Chosen printer name, or `None` for the system default.
|
||||
pub printer: Option<String>,
|
||||
/// Output goes to a PDF file instead of a printer.
|
||||
@@ -403,6 +452,8 @@ impl Default for PlotDialogState {
|
||||
printer_media: None,
|
||||
custom_papers: Vec::new(),
|
||||
custom_editor: None,
|
||||
driver_options: std::collections::BTreeMap::new(),
|
||||
printer_editor: None,
|
||||
printer: None,
|
||||
to_file: false,
|
||||
paper: paper_catalog::default_paper().canonical.to_string(),
|
||||
@@ -623,25 +674,125 @@ fn choices(items: &[&str]) -> Vec<PlotChoice> {
|
||||
items.iter().map(|&value| PlotChoice::localized(value)).collect()
|
||||
}
|
||||
|
||||
/// The in-line printer-properties editor: one `label : choice` row per
|
||||
/// driver option (media type, colour mode, quality, duplex, trays…), with
|
||||
/// Reset / Cancel / Apply. Mirrors what LibreOffice's Properties… shows for a
|
||||
/// CUPS printer, without leaving the plot dialog.
|
||||
fn printer_options_editor<'a>(draft: &'a PrinterOptionsDraft, width: Length) -> Element<'a, Message> {
|
||||
let mut rows = column![section_label(Cow::Owned(crate::tf!(
|
||||
"Printer properties — {printer}",
|
||||
printer = draft.printer
|
||||
).into_owned()))]
|
||||
.spacing(6);
|
||||
match (&draft.options, &draft.error) {
|
||||
(Some(options), _) if options.is_empty() => {
|
||||
rows = rows.push(
|
||||
text(t!("This printer reports no driver options."))
|
||||
.size(10)
|
||||
.style(muted_style),
|
||||
);
|
||||
}
|
||||
(Some(options), _) => {
|
||||
for option in options {
|
||||
let current = draft
|
||||
.choices
|
||||
.get(&option.key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| option.default.clone());
|
||||
let choices: Vec<PlotChoice> = option
|
||||
.choices
|
||||
.iter()
|
||||
.map(|choice| PlotChoice {
|
||||
raw: choice.keyword.clone(),
|
||||
localized: false,
|
||||
display: Some(choice.label.clone()),
|
||||
})
|
||||
.collect();
|
||||
let selected = choices.iter().find(|c| c.raw == current).cloned();
|
||||
let key = option.key.clone();
|
||||
rows = rows.push(form::labeled_pick_list(
|
||||
Cow::Owned(option.label.clone()),
|
||||
choices,
|
||||
selected,
|
||||
move |choice| Message::PlotDlg(PlotDlgMsg::PrinterOptionSet(key.clone(), choice.raw)),
|
||||
Length::Fill,
|
||||
));
|
||||
}
|
||||
}
|
||||
(None, Some(error)) => {
|
||||
rows = rows.push(
|
||||
text(crate::tf!("Could not read the printer's options: {error}"))
|
||||
.size(10)
|
||||
.style(|theme: &Theme| iced::widget::text::Style {
|
||||
color: Some(theme.palette().danger.base.color),
|
||||
}),
|
||||
);
|
||||
}
|
||||
(None, None) => {
|
||||
rows = rows.push(text(t!("Reading the printer's options…")).size(10).style(muted_style));
|
||||
}
|
||||
}
|
||||
let mut buttons = row![
|
||||
button(text(t!("Reset")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsReset))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
Space::new().width(Length::Fill),
|
||||
button(text(t!("Cancel")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsCancel))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
]
|
||||
.spacing(6);
|
||||
if draft.options.is_some() {
|
||||
buttons = buttons.push(
|
||||
button(text(t!("Apply")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterOptionsApply))
|
||||
.style(btn(true))
|
||||
.padding([4, 12]),
|
||||
);
|
||||
}
|
||||
rows = rows.push(buttons);
|
||||
container(rows)
|
||||
.padding(8)
|
||||
.width(width)
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(theme.palette().background.weak.color)),
|
||||
border: Border {
|
||||
color: theme.palette().background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// The inline editor for a user-defined sheet: name, dimensions and unit,
|
||||
/// printable margins, Add / Cancel. Numbers stay text until Add validates
|
||||
/// them.
|
||||
/// them. Every margin field carries its own side label so the four values
|
||||
/// cannot be confused, and the unit is repeated where the numbers are typed.
|
||||
fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Element<'a, Message> {
|
||||
let msg = |m: CustomPaperMsg| Message::PlotDlg(PlotDlgMsg::CustomPaper(m));
|
||||
let units_label = |inches: bool| {
|
||||
if inches {
|
||||
crate::i18n::translate("Inches")
|
||||
} else {
|
||||
crate::i18n::translate("Millimeters")
|
||||
}
|
||||
};
|
||||
let unit = if draft.inches { "in" } else { "mm" };
|
||||
let unit_options: Vec<PlotChoice> = vec![
|
||||
PlotChoice::localized("Millimeters"),
|
||||
PlotChoice::localized("Inches"),
|
||||
];
|
||||
let unit_selected = Some(PlotChoice::localized(if draft.inches { "Inches" } else { "Millimeters" }));
|
||||
let margin_field = |side: MarginSide, index: usize, hint: &'a str| {
|
||||
form::compact_field(hint, &draft.margins[index], move |v| msg(CustomPaperMsg::Margin(side, v)), 56.0)
|
||||
const SIDE_LABEL: f32 = 52.0;
|
||||
const NUMBER: f32 = 64.0;
|
||||
let dimension = |label: Cow<'static, str>, value: &'a str, ctor: fn(String) -> CustomPaperMsg| {
|
||||
form::labeled_field_compact(label, value, move |v| msg(ctor(v)), SIDE_LABEL, NUMBER)
|
||||
};
|
||||
let margin = |label: Cow<'static, str>, side: MarginSide, index: usize| {
|
||||
form::labeled_field_compact(
|
||||
label,
|
||||
&draft.margins[index],
|
||||
move |v| msg(CustomPaperMsg::Margin(side, v)),
|
||||
SIDE_LABEL,
|
||||
NUMBER,
|
||||
)
|
||||
};
|
||||
let error: Element<'_, Message> = match draft.error {
|
||||
Some(CustomPaperError::Size) => text(t!("Enter a positive width and height."))
|
||||
@@ -652,33 +803,37 @@ fn custom_paper_editor<'a>(draft: &'a CustomPaperDraft, width: Length) -> Elemen
|
||||
.into(),
|
||||
None => Space::new().height(0).into(),
|
||||
};
|
||||
let unit_hint = text(units_label(draft.inches)).size(10).style(muted_style);
|
||||
container(
|
||||
column![
|
||||
section_label(t!("Custom paper size")),
|
||||
form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 220.0),
|
||||
row![
|
||||
form::labeled_field(t!("Width"), &draft.width, move |v| msg(CustomPaperMsg::Width(v)), 70.0),
|
||||
form::labeled_field(t!("Height"), &draft.height, move |v| msg(CustomPaperMsg::Height(v)), 70.0),
|
||||
]
|
||||
.spacing(12),
|
||||
form::labeled_field(t!("Name"), &draft.name, move |v| msg(CustomPaperMsg::Name(v)), 200.0),
|
||||
form::labeled_pick_list(
|
||||
t!("Units"),
|
||||
unit_options,
|
||||
unit_selected,
|
||||
move |choice| msg(CustomPaperMsg::Units(choice.raw)),
|
||||
Length::Fixed(140.0),
|
||||
Length::Fixed(150.0),
|
||||
),
|
||||
row![
|
||||
text(t!("Printable margins")).size(11).style(muted_style).width(form::LABEL_WIDTH),
|
||||
margin_field(MarginSide::Left, 0, "L"),
|
||||
margin_field(MarginSide::Bottom, 1, "B"),
|
||||
margin_field(MarginSide::Right, 2, "R"),
|
||||
margin_field(MarginSide::Top, 3, "T"),
|
||||
unit_hint,
|
||||
dimension(t!("Width"), &draft.width, CustomPaperMsg::Width),
|
||||
dimension(t!("Height"), &draft.height, CustomPaperMsg::Height),
|
||||
text(unit).size(11).style(muted_style),
|
||||
]
|
||||
.spacing(6)
|
||||
.spacing(14)
|
||||
.align_y(iced::Center),
|
||||
text(format!("{} ({unit})", t!("Printable margins")))
|
||||
.size(11)
|
||||
.style(muted_style),
|
||||
row![
|
||||
margin(t!("Left"), MarginSide::Left, 0),
|
||||
margin(t!("Right"), MarginSide::Right, 2),
|
||||
]
|
||||
.spacing(14),
|
||||
row![
|
||||
margin(t!("Top"), MarginSide::Top, 3),
|
||||
margin(t!("Bottom"), MarginSide::Bottom, 1),
|
||||
]
|
||||
.spacing(14),
|
||||
error,
|
||||
row![
|
||||
Space::new().width(Length::Fill),
|
||||
@@ -897,7 +1052,11 @@ pub fn view_window(
|
||||
} else {
|
||||
Space::new().height(0).into()
|
||||
};
|
||||
let mut output_row = row![
|
||||
// Heading and picker share one line — the section has nothing else to
|
||||
// announce — and the picker takes the whole row so a long queue name
|
||||
// stays readable; the driver's Properties… button sits beneath it.
|
||||
let output_field = row![
|
||||
text(t!("Printer / plotter")).size(11).style(muted_style).width(form::LABEL_WIDTH),
|
||||
iced::widget::pick_list(printer_sel, printer_opts, |value| value.to_string())
|
||||
.on_select(|choice| Message::PlotDlg(PlotDlgMsg::Printer(choice.raw)))
|
||||
.text_size(12)
|
||||
@@ -905,66 +1064,69 @@ pub fn view_window(
|
||||
.width(Length::Fill),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center);
|
||||
if !s.to_file {
|
||||
output_row = output_row.push(
|
||||
.align_y(iced::Center)
|
||||
.width(Length::Fill);
|
||||
let printer_controls: Element<'_, Message> = if s.to_file {
|
||||
Space::new().height(0).into()
|
||||
} else {
|
||||
row![
|
||||
field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60),
|
||||
Space::new().width(Length::Fill),
|
||||
button(text(t!("Properties…")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::PrinterProperties))
|
||||
.style(btn(false))
|
||||
.padding([4, 8]),
|
||||
);
|
||||
}
|
||||
let output_field = column![
|
||||
text(t!("Output")).size(11).style(muted_style),
|
||||
output_row,
|
||||
]
|
||||
.spacing(4)
|
||||
.width(Length::Fill);
|
||||
let copies_row: Element<'_, Message> = if s.to_file {
|
||||
Space::new().height(0).into()
|
||||
} else {
|
||||
field_row(t!("Copies"), &s.copies, PlotDlgMsg::Copies, 60)
|
||||
};
|
||||
let printer_panel = panel(
|
||||
column![
|
||||
section_label(t!("Printer / plotter")),
|
||||
output_field,
|
||||
copies_row,
|
||||
.padding([4, 10]),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
.align_y(iced::Center)
|
||||
.into()
|
||||
};
|
||||
// A printer with remembered driver choices says so under its row; the
|
||||
// editor itself unfolds there when Properties… is pressed.
|
||||
let printer_note: Element<'_, Message> = match (&s.printer_editor, &s.printer) {
|
||||
(Some(draft), _) => printer_options_editor(draft, width),
|
||||
(None, Some(printer)) if s.driver_options.get(printer).is_some_and(|o| !o.is_empty()) => {
|
||||
let count = s.driver_options[printer].len();
|
||||
text(crate::tf!("{count} driver option(s) set for this printer."))
|
||||
.size(10)
|
||||
.style(muted_style)
|
||||
.into()
|
||||
}
|
||||
_ => Space::new().height(0).into(),
|
||||
};
|
||||
let printer_panel = panel(column![output_field, printer_controls, printer_note].spacing(7));
|
||||
|
||||
// ── Paper, area, offset, scale ────────────────────────────────────────
|
||||
// Size row with the custom-sheet controls; the editor unfolds below it.
|
||||
let mut size_row = row![
|
||||
drop_row(t!("Size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center);
|
||||
if s.custom_editor.is_none() {
|
||||
size_row = size_row.push(
|
||||
button(text(t!("Custom…")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open)))
|
||||
.style(btn(false))
|
||||
.padding([4, 8]),
|
||||
);
|
||||
if selected_is_custom {
|
||||
size_row = size_row.push(
|
||||
button(text(t!("Remove")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove)))
|
||||
.style(btn(false))
|
||||
.padding([4, 8]),
|
||||
);
|
||||
}
|
||||
}
|
||||
let custom_editor: Element<'_, Message> = match &s.custom_editor {
|
||||
// The picker takes the whole row so long media names stay readable; the
|
||||
// custom-sheet buttons sit right-aligned beneath it, and the editor
|
||||
// unfolds in their place.
|
||||
let size_row = drop_row(t!("Paper size"), paper_opts, Some(paper_sel), PlotDlgMsg::Paper, Length::Fill);
|
||||
let custom_controls: Element<'_, Message> = match &s.custom_editor {
|
||||
Some(draft) => custom_paper_editor(draft, width),
|
||||
None => Space::new().height(0).into(),
|
||||
None => {
|
||||
let mut buttons = row![Space::new().width(Length::Fill)].spacing(6);
|
||||
if selected_is_custom {
|
||||
buttons = buttons.push(
|
||||
button(text(t!("Remove")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Remove)))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
);
|
||||
}
|
||||
buttons
|
||||
.push(
|
||||
button(text(t!("Custom…")).size(11))
|
||||
.on_press(Message::PlotDlg(PlotDlgMsg::CustomPaper(CustomPaperMsg::Open)))
|
||||
.style(btn(false))
|
||||
.padding([4, 10]),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
// Like the printer row: the section is its one picker, so heading and
|
||||
// control share a line.
|
||||
let paper_panel = panel(column![
|
||||
section_label(t!("Paper")),
|
||||
size_row,
|
||||
custom_editor,
|
||||
custom_controls,
|
||||
paper_source_note,
|
||||
paper_note,
|
||||
].spacing(7));
|
||||
|
||||
Reference in new issue
Block a user