forked from 1b-agent/opencadstudio-upstream
Embed debug locales and expand the WebAssembly stack so modal construction remains responsive. Keep the Options scrollbar in its own lane and refine Turkish CAD terminology.
423 lines
13 KiB
Rust
423 lines
13 KiB
Rust
//! 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()
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
}
|