Files
OneCAD-Draft/src/ui/modal.rs
T
Hakan SevenandClaude Opus 5.5 178c7dff41 fix: open-issue sweep — drawing, plot, snap, layout and UI fixes
Fixes:
- Font prompt no longer lists bundled ltypeshp/qcadshp shape files (#1626)
- Wide polylines step at a discontinuous segment width; PEDIT marks the
  current vertex; keyword steps refresh the command preview (#1628)
- Radius-0 fillet drops the zero-length segment it left, so the corner
  fillets again (#1422)
- FILLET Trim / No trim option (#1558)
- True-colour wires plot in their own colour; colour picker stores the
  value it shows (#1621)
- Model tab keeps its page setup; "Save changes to layout" in Model (#1620)
- QNEW default template in Options > Files; .dwt in the open dialog
  (#1134, #1113)
- GRIDUNIT / GRIDMODE / PERSPECTIVE variables (#1618, #1524)
- PTYPE (#1433)
- ZOOM nX / nXP (#1465)
- CHSPACE between model and paper space through a viewport (#1428)
- Moving a dimension keeps its automatic text placement
- Shell icon; "&&" button labels (#1554)
- LINETYPE Load from a .lin file (#1588)
- XLINE / RAY hide the crosshair arms (#1437)
- Dynamic-input boxes keep clear of the crosshair and each other (#1546)
- Snap marker colour setting, dark default on light canvases
  (#1497, #1229, #1128)
- Dimension style precision as a 0.00 pattern dropdown (#1426)
- Node graph nodes left of the origin no longer clip away (#1592)
- Collapse button in the command history header (#1094)
- Freeze-in-current-viewport toggle in the ribbon layer list (#1466)
- Geometric Center object snap for closed polylines (#1369)

New strings translated in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 15:59:30 +03:00

477 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Shared in-canvas modal overlay.
//!
//! Former pop-up *windows* (layer manager, style editors, About, …) render as
//! centered overlays on top of the main view instead of separate OS windows.
//! The native build has one main window and the web build has only the canvas,
//! so both stack dialogs here — one code path for every platform.
use crate::app::Message;
use iced::advanced::layout::{self, Layout};
use iced::advanced::widget::{self, Widget};
use iced::advanced::{mouse, overlay, renderer, Shell};
use iced::widget::{button, column, container, mouse_area, opaque, row, sensor, stack, Space};
use iced::{
Background, Border, Element, Event, Length, Padding, Rectangle, Renderer, Size, Theme, Vector,
};
#[derive(Debug, Clone, Copy)]
pub struct ModalOptions {
movable: bool,
resizable: bool,
neutral_close: bool,
}
/// Sizing used inside an intrinsically measured modal.
///
/// [`INTRINSIC`](Self::INTRINSIC) lets each widget report the most space it can
/// use, while [`FILL`](Self::FILL) distributes the resulting shared frame to
/// sibling toolbars, panes and scroll areas.
#[derive(Debug, Clone, Copy)]
pub struct ModalSizing {
pub width: Length,
pub height: Length,
}
impl ModalSizing {
pub const INTRINSIC: Self = Self {
width: Length::Fluid(iced_core::length::Constraint::Max),
height: Length::Fluid(iced_core::length::Constraint::Max),
};
pub const FILL: Self = Self {
width: Length::Fill,
height: Length::Fill,
};
}
/// Measure an intrinsic copy of `content`, then lay the fill copy out in the
/// resulting shared frame. `extra` grows that frame when the resize handle is
/// dragged; `max` only caps the initial intrinsic pass.
pub fn intrinsic<'a>(
measurement: impl Into<Element<'a, Message>>,
content: impl Into<Element<'a, Message>>,
max: Size,
extra: Vector,
) -> Element<'a, Message> {
Element::new(Intrinsic {
children: [measurement.into(), content.into()],
max,
extra,
})
}
struct Intrinsic<'a> {
children: [Element<'a, Message>; 2],
max: Size,
extra: Vector,
}
impl Widget<Message, Theme, Renderer> for Intrinsic<'_> {
fn diff(&mut self, tree: &mut widget::Tree) {
tree.diff_children(&mut self.children);
}
fn size(&self) -> Size<Length> {
Size::new(
Length::Fit.max(self.max.width + self.extra.x),
Length::Fit.max(self.max.height + self.extra.y),
)
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
let measure_max = Size::new(
self.max.width.min(limits.max().width),
self.max.height.min(limits.max().height),
);
let measure_limits = layout::Limits::new(Size::ZERO, measure_max);
let measured = self.children[0]
.as_widget_mut()
.layout(&mut tree.children[0], renderer, &measure_limits);
let size = Size::new(
(measured.size().width + self.extra.x).min(limits.max().width),
(measured.size().height + self.extra.y).min(limits.max().height),
);
let final_limits = layout::Limits::new(size, size);
let content = self.children[1]
.as_widget_mut()
.layout(&mut tree.children[1], renderer, &final_limits);
layout::Node::with_children(size, vec![measured, content])
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.children[1]
.as_widget_mut()
.operate(&mut tree.children[1], layout.child(1), renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.children[1].as_widget_mut().update(
&mut tree.children[1],
event,
layout.child(1),
cursor,
renderer,
shell,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.children[1]
.as_widget()
.mouse_interaction(
&tree.children[1],
layout.child(1),
cursor,
viewport,
renderer,
)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.children[1]
.as_widget()
.draw(
&tree.children[1],
renderer,
theme,
style,
layout.child(1),
cursor,
viewport,
);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut widget::Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.children[1]
.as_widget_mut()
.overlay(
&mut tree.children[1],
layout.child(1),
renderer,
viewport,
translation,
)
}
}
impl ModalOptions {
pub const STANDARD: Self = Self {
movable: true,
resizable: true,
neutral_close: false,
};
pub const NOTICE: Self = Self {
movable: true,
resizable: false,
neutral_close: true,
};
}
/// Dim and block the application beneath an overlay-owned dialog.
///
/// Some third-party overlays ignore pointer movement outside their interactive
/// controls. The shield owns the cursor and pointer presses across the window,
/// making such an overlay behave modally.
pub fn backdrop<'a>(
base: impl Into<Element<'a, Message>>,
on_close: Message,
) -> Element<'a, Message> {
let shield = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme
.palette()
.background
.strongest
.color
.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_press(on_close)
.interaction(iced::mouse::Interaction::Idle);
stack![base.into(), opaque(shield)].into()
}
/// A dialog's "unsaved changes" guard: `main` dimmed behind a small panel
/// that offers to discard the changes and close, or to keep editing. Clicking
/// the dim area keeps editing. Callers show it in place of the plain dialog
/// once the user asked to close with something to lose.
pub fn discard_guard<'a>(
main: impl Into<Element<'a, Message>>,
on_discard: Message,
on_keep: Message,
) -> Element<'a, Message> {
let shield = mouse_area(
container(Space::new())
.width(Length::Fill)
.height(Length::Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme
.palette()
.background
.strongest
.color
.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_press(on_keep.clone());
let panel = container(
column![
iced::widget::text(crate::t!("Unsaved changes will be discarded.")).size(13.5),
Space::new().height(12),
row![
button(iced::widget::text(crate::t!("Discard & close")).size(12))
.on_press(on_discard)
.padding([5, 14])
.style(button::danger),
button(iced::widget::text(crate::t!("Keep editing")).size(12))
.on_press(on_keep)
.padding([5, 14])
.style(button::secondary),
]
.spacing(10),
]
.spacing(0),
)
.padding([18, 22])
.style(container::rounded_box);
stack![
main.into(),
shield,
container(panel).center_x(Length::Fill).center_y(Length::Fill)
]
.into()
}
/// Stack `content` over `base` behind a dimmed backdrop, framed with a title bar
/// (the ✕ close button at its right end). The backdrop only dims and blocks
/// clicks from reaching the view beneath — it does **not** dismiss the dialog;
/// closing is the ✕ button alone (`on_close`).
///
/// `offset` shifts the dialog from screen-centre so it can be dragged by its
/// title bar; pass `Vector::ZERO` to keep it centred.
pub fn modal<'a>(
base: impl Into<Element<'a, Message>>,
title: impl iced::widget::text::IntoFragment<'a>,
content: impl Into<Element<'a, Message>>,
on_close: Message,
offset: Vector,
options: ModalOptions,
) -> Element<'a, Message> {
let close = if options.neutral_close {
button(crate::ui::icons::themed_secondary(
crate::ui::icons::CLOSE,
13.0,
))
.on_press(on_close)
.padding([1, 7])
.style(neutral_close_style)
} else {
button(crate::ui::icons::themed_danger(
crate::ui::icons::CLOSE,
13.0,
))
.on_press(on_close)
.padding([1, 7])
.style(close_style)
};
// The dialog name is centred across the content width with the ✕ overlaid
// at the right edge. The title bar is itself an overlay: the content layer
// below determines the intrinsic modal width before `Fill` is resolved.
let title_text = iced::widget::text(title).size(15);
let title_surface: Element<'a, Message> = container(title_text)
.width(Length::Fill)
.height(Length::Fixed(24.0))
.align_x(iced::alignment::Horizontal::Center)
.align_y(iced::alignment::Vertical::Center)
.into();
let title_surface: Element<'a, Message> = if options.movable {
mouse_area(title_surface)
.on_press(Message::ModalGrab)
.interaction(iced::mouse::Interaction::Grab)
.into()
} else {
title_surface
};
let controls: Element<'a, Message> = row![close].align_y(iced::Center).into();
let title_bar = stack![
title_surface,
container(controls)
.width(Length::Fill)
.height(Length::Fixed(24.0))
.align_x(iced::alignment::Horizontal::Right)
.align_y(iced::alignment::Vertical::Center),
]
.width(Length::Fill)
.height(Length::Fixed(24.0));
let panel_style = |theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().background.base.color,
)),
border: Border {
color: theme.palette().background.neutral.color,
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
};
// The first stack layer dictates its intrinsic size. Its top spacer reserves
// room for the title, while the actual title bar and resize grip overlay it
// without influencing the modal dimensions.
let measured_content = sensor(content).on_resize(Message::ModalContentResized);
let body_base = column![
Space::new().height(Length::Fixed(24.0)),
measured_content,
]
.spacing(6);
let mut body = stack![body_base, title_bar];
if options.resizable {
let resize = mouse_area(
container(crate::ui::icons::themed_primary(crate::ui::icons::RESIZE, 15.0))
.padding([0, 2]),
)
.on_press(Message::ModalResizeGrab)
.interaction(iced::mouse::Interaction::Grab);
body = body.push(
container(resize)
.width(Length::Fill)
.height(Length::Fill)
.align_x(iced::alignment::Horizontal::Right)
.align_y(iced::alignment::Vertical::Bottom),
);
}
let framed: Element<'a, Message> =
container(body).padding(10).style(panel_style).into();
// Position via asymmetric padding (padding is non-negative): shifting a
// centred box by `d` on an axis needs (near − far) padding = 2·d there.
let pad = Padding {
top: offset.y.max(0.0) * 2.0,
right: (-offset.x).max(0.0) * 2.0,
bottom: (-offset.y).max(0.0) * 2.0,
left: offset.x.max(0.0) * 2.0,
};
// The backdrop fills the screen so dragging keeps tracking the cursor even
// when it leaves the title bar; release anywhere ends the drag.
let backdrop = mouse_area(
container(framed)
.center_x(Length::Fill)
.center_y(Length::Fill)
.padding(pad)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme
.palette()
.background
.strongest
.color
.scale_alpha(0.55),
)),
..Default::default()
}),
)
.on_move(Message::ModalDragMove)
.on_release(Message::ModalDragRelease);
stack![
base.into(),
// `opaque` blocks pointer events from passing through, so the dimmed
// backdrop swallows clicks instead of closing or hitting the view.
opaque(backdrop),
]
.into()
}
fn close_style(theme: &Theme, status: button::Status) -> button::Style {
let palette = theme.palette();
let pair = match status {
button::Status::Hovered | button::Status::Pressed => palette.danger.strong,
_ => palette.background.weakest,
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
}
fn neutral_close_style(theme: &Theme, status: button::Status) -> button::Style {
let palette = theme.palette();
let pair = match status {
button::Status::Hovered | button::Status::Pressed => palette.background.weak,
_ => palette.background.weakest,
};
button::Style {
background: Some(Background::Color(pair.color)),
text_color: pair.text,
border: Border {
radius: 4.0.into(),
..Default::default()
},
..Default::default()
}
}