forked from 1b-agent/opencadstudio-upstream
fix(ui): make modal layouts intrinsic
Pin Iced 0.15 so dialogs can measure content before distributing the resolved frame across child panels. Keep the web plugin notice compact and non-resizable.\n\nCloses #582
This commit is contained in:
1 parent
284d6ef517
commit
3ab8dc3ca5
59 files changed
+2197
-1652
No files matched your search
+303
-68
@@ -6,106 +6,324 @@
|
||||
//! so both stack dialogs here — one code path for every platform.
|
||||
|
||||
use crate::app::Message;
|
||||
use iced::widget::{button, column, container, mouse_area, opaque, row, stack};
|
||||
use iced::{Background, Border, Element, Length, Padding, Theme, Vector};
|
||||
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, stack, Space};
|
||||
use iced::{
|
||||
Background, Border, Element, Event, Length, Padding, Rectangle, Renderer, Size, Theme, Vector,
|
||||
};
|
||||
|
||||
/// Stack `content` over `base` behind a dimmed backdrop, framed with a
|
||||
/// draggable 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`).
|
||||
#[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,
|
||||
};
|
||||
|
||||
pub fn from_resize(_resize: Vector) -> Self {
|
||||
Self::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 MOVABLE_FIXED: Self = Self {
|
||||
movable: true,
|
||||
resizable: false,
|
||||
neutral_close: false,
|
||||
};
|
||||
|
||||
pub const NOTICE: Self = Self {
|
||||
movable: true,
|
||||
resizable: false,
|
||||
neutral_close: true,
|
||||
};
|
||||
}
|
||||
|
||||
/// 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: &'a str,
|
||||
title_width: f32,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
on_close: Message,
|
||||
offset: Vector,
|
||||
resizable: bool,
|
||||
options: ModalOptions,
|
||||
) -> Element<'a, Message> {
|
||||
let close = button(crate::ui::icons::themed_danger(
|
||||
crate::ui::icons::CLOSE,
|
||||
13.0,
|
||||
))
|
||||
.on_press(on_close)
|
||||
.padding([1, 7])
|
||||
.style(close_style);
|
||||
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)
|
||||
};
|
||||
|
||||
// Draggable title bar: a grip handle next to the ✕. Kept `Shrink` (no
|
||||
// `Fill`) so a single Fill child can't blow the dialog out to the full
|
||||
// screen width — the dialog stays sized to its content. Pressing the grip
|
||||
// starts a drag (handled in `update`).
|
||||
let grip = mouse_area(
|
||||
container(crate::ui::icons::themed_primary(crate::ui::icons::MOVE, 14.0))
|
||||
.padding([1, 7])
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme.extended_palette().background.weakest.color,
|
||||
)),
|
||||
border: Border {
|
||||
radius: 4.0.into(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.on_press(Message::ModalGrab)
|
||||
.interaction(iced::mouse::Interaction::Grab);
|
||||
|
||||
// The dialog name is centred across the dialog width with the grip + ✕
|
||||
// overlaid at the right edge. The bar takes an explicit `title_width`
|
||||
// (the caller's content width) instead of `Fill` — a Fill child inside
|
||||
// the Shrink frame would blow the dialog out to the full screen.
|
||||
// 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![
|
||||
container(title_text)
|
||||
.width(Length::Fixed(title_width))
|
||||
.height(Length::Fixed(24.0))
|
||||
.align_x(iced::alignment::Horizontal::Center)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
container(row![grip, close].spacing(6).align_y(iced::Center))
|
||||
.width(Length::Fixed(title_width))
|
||||
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.extended_palette().background.base.color,
|
||||
theme.palette().background.base.color,
|
||||
)),
|
||||
border: Border {
|
||||
color: theme.extended_palette().background.neutral.color,
|
||||
color: theme.palette().background.neutral.color,
|
||||
width: 1.0,
|
||||
radius: 6.0.into(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// The dialog is always sized to its content (the caller fixes the content's
|
||||
// width/height, growing it by the shared resize delta). When `resizable`, a
|
||||
// corner grip is appended bottom-right; dragging it drives `ModalResizeGrab`
|
||||
// + the shared drag move/release, which bumps that delta. The title bar sits
|
||||
// top-right above the content either way (`align_x(Right)`).
|
||||
let body = if resizable {
|
||||
// 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 body_base = column![
|
||||
Space::new().height(Length::Fixed(24.0)),
|
||||
content.into(),
|
||||
]
|
||||
.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);
|
||||
column![title_bar, content.into(), resize]
|
||||
} else {
|
||||
column![title_bar, content.into()]
|
||||
};
|
||||
let framed: Element<'a, Message> = container(
|
||||
body.spacing(6).align_x(iced::alignment::Horizontal::Right),
|
||||
)
|
||||
.padding(10)
|
||||
.style(panel_style)
|
||||
.into();
|
||||
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.
|
||||
@@ -126,7 +344,7 @@ pub fn modal<'a>(
|
||||
.style(|theme: &Theme| container::Style {
|
||||
background: Some(Background::Color(
|
||||
theme
|
||||
.extended_palette()
|
||||
.palette()
|
||||
.background
|
||||
.strongest
|
||||
.color
|
||||
@@ -148,7 +366,7 @@ pub fn modal<'a>(
|
||||
}
|
||||
|
||||
fn close_style(theme: &Theme, status: button::Status) -> button::Style {
|
||||
let palette = theme.extended_palette();
|
||||
let palette = theme.palette();
|
||||
let pair = match status {
|
||||
button::Status::Hovered | button::Status::Pressed => palette.danger.strong,
|
||||
_ => palette.background.weakest,
|
||||
@@ -163,3 +381,20 @@ fn close_style(theme: &Theme, status: button::Status) -> button::Style {
|
||||
..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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user