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:
Hakan Seven committed 2026-07-30 14:47:28 +03:00
1 parent 284d6ef517
commit 3ab8dc3ca5
59 files changed
+2197 -1652

No files matched your search

+303 -68
View File
@@ -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()
}
}