feat(plot): custom paper sizes with printable margins

AutoCAD lets a plotter configuration carry user-defined sheets; a drawing
then names them like any other media (`Roll_24_(609.60_x_1500.00_MM)`).
The plot dialog now defines such sheets inline, under the Size row:
name, width/height, unit, and the printable margins in that unit. Added
sheets are selected at once, offered for every device after the device's
own list, remembered in the plot config across sessions, and written to
the layout with their own margins (a printer that reports the same size
still wins with its hardware margins). A selected user-defined sheet can
be removed; the sheet itself stays selectable in the drawing that uses it.

`CustomPaper` / `Margins` live in the paper catalogue with serde support;
`PlotDevice::margins_mm` consults the user's definitions. The Plot
dialog's form helpers (button and field styles, dividers, `label :
control` rows) move to `ui::style::form` so the next dialogs share them
instead of copying.

Tests: draft validation and seeding, catalogue naming of user sheets,
device margins with user definitions, and dialog round-trips (add,
select, persist, write, replace, remove, refuse an invalid size).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
PanosandClaude Opus 5 committed 2026-09-16 16:05:47 +03:00
1 parent a7c89eca33
commit 5e271e2dbb
28 files changed
+930 -175

No files matched your search

+206
View File
@@ -0,0 +1,206 @@
//! Form building blocks shared by the in-canvas dialogs.
//!
//! The Plot dialog grew its own `label : control` rows, field and button
//! styles, and section dividers; every dialog that followed copied them. They
//! live here once so a new dialog reads like the existing ones and a theme
//! tweak lands everywhere.
use crate::app::Message;
use crate::ui::style::common::muted_style;
use iced::widget::{button, container, pick_list, row, text, text_input, Space};
use iced::{Background, Border, Element, Length, Theme};
use std::borrow::Cow;
/// Width of the label column in a `label : control` row.
pub const LABEL_WIDTH: f32 = 92.0;
/// Dialog button: accent (primary action) or neutral.
pub fn button_style(accent: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, status| {
let palette = theme.palette();
let pair = match (accent, status) {
(true, button::Status::Hovered | button::Status::Pressed) => palette.primary.strong,
(false, button::Status::Hovered | button::Status::Pressed) => palette.background.strong,
(true, _) => palette.primary.base,
_ => palette.background.weak,
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
color: palette.background.neutral.color,
width: 1.0,
radius: 4.0.into(),
},
shadow: iced::Shadow::default(),
snap: false,
}
}
}
/// Text field: base background, neutral border that turns primary on focus.
pub fn field_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
let palette = theme.palette();
let border = match status {
text_input::Status::Focused { .. } => palette.primary.base.color,
_ => palette.background.neutral.color,
};
text_input::Style {
background: Background::Color(palette.background.base.color),
border: Border {
color: border,
width: 1.0,
radius: 3.0.into(),
},
icon: palette.background.base.text,
placeholder: palette.background.base.text.scale_alpha(0.48),
value: palette.background.base.text,
selection: palette.primary.base.color.scale_alpha(0.5),
}
}
/// One-pixel horizontal rule.
pub fn hdivider<'a>(width: Length) -> Element<'a, Message> {
container(Space::new().width(width).height(1))
.width(width)
.height(1)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
})
.into()
}
/// One-pixel vertical rule.
pub fn vseparator<'a>(height: Length) -> Element<'a, Message> {
container(Space::new().width(1).height(height))
.width(1)
.height(height)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
})
.into()
}
/// Muted section heading.
pub fn section_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> {
text(label).size(11).style(muted_style).into()
}
fn row_label<'a>(label: Cow<'static, str>) -> Element<'a, Message> {
text(label)
.size(11)
.style(muted_style)
.width(LABEL_WIDTH)
.into()
}
/// A `label : dropdown` row.
pub fn labeled_pick_list<'a, T>(
label: Cow<'static, str>,
options: Vec<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 short unlabeled text field for a compact multi-value row (`L B R T`).
pub fn compact_field<'a>(
placeholder: &'a str,
value: &'a str,
on_input: impl Fn(String) -> Message + 'a,
width: f32,
) -> Element<'a, Message> {
text_input(placeholder, value)
.on_input(on_input)
.style(field_style)
.size(12)
.padding([3, 6])
.width(width)
.into()
}
+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 {