forked from 1b-agent/opencadstudio-upstream
Merge pull request #1311 from ghost/plotting-station-a
This commit is contained in:
commit
1701e672dd
36 files changed
+3567
-425
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
|
||||
Reference in new issue
Block a user