1use crate::callback::Callback;
4use crate::core::element::{Element, ElementKind};
5use crate::core::event::MouseEvent;
6use crate::overlay::{
7 DismissPolicy, OverlayLayer, OverlayPlacement, OverlayScope, PointerCapture, Portal,
8};
9use crate::style::{Align, BorderStyle, Color, Length, Padding, RichText, Size, Style, StyleSlot};
10use crate::widgets::{BorderLabels, Center, Frame, FrameLabel, MouseRegion, Spacer, ZStack};
11
12#[derive(Clone)]
14pub struct Modal {
15 title: Option<RichText>,
16 child: Element,
17 on_close: Option<Callback<()>>,
18 scope: OverlayScope,
19 width: Length,
20 height: Length,
21 max_height: Option<Length>,
22 reserve_height: Option<Length>,
23 backdrop_style: Style,
24 frame_style: Style,
25 focus_style: StyleSlot,
26 auto_focus: bool,
27 border: bool,
28 border_style: BorderStyle,
29 padding: Padding,
30 title_style: Style,
31 title_alignment: Align,
32}
33
34impl Modal {
35 pub fn new() -> Self {
37 Self {
38 title: None,
39 child: Spacer::new().into(),
40 on_close: None,
41 scope: OverlayScope::RootPortal,
42 width: Length::Px(60),
43 height: Length::Auto,
44 max_height: None,
45 reserve_height: None,
46 backdrop_style: Style::default(),
47 frame_style: Style::default(),
48 focus_style: StyleSlot::Inherit,
49 auto_focus: true,
50 border: true,
51 border_style: BorderStyle::Plain,
52 padding: 1.into(),
53 title_style: Style::default(),
54 title_alignment: Align::Start,
55 }
56 }
57
58 pub fn title(mut self, title: impl Into<RichText>) -> Self {
60 self.title = Some(title.into());
61 self
62 }
63
64 pub fn child(mut self, child: impl Into<Element>) -> Self {
66 self.child = child.into();
67 self
68 }
69
70 pub fn width(mut self, width: Length) -> Self {
72 self.width = width;
73 self
74 }
75
76 pub fn height(mut self, height: Length) -> Self {
78 self.height = height;
79 self
80 }
81
82 pub fn max_height(mut self, max_height: Length) -> Self {
85 self.max_height = Some(max_height);
86 self
87 }
88
89 pub fn reserve_height(mut self, reserve_height: Length) -> Self {
96 self.reserve_height = Some(reserve_height);
97 self
98 }
99
100 pub fn on_close(mut self, cb: Callback<()>) -> Self {
102 self.on_close = Some(cb);
103 self
104 }
105
106 pub fn scope(mut self, scope: OverlayScope) -> Self {
108 self.scope = scope;
109 self
110 }
111
112 pub fn auto_focus(mut self, auto_focus: bool) -> Self {
116 self.auto_focus = auto_focus;
117 self
118 }
119
120 pub fn backdrop_style(mut self, style: Style) -> Self {
122 self.backdrop_style = style;
123 self
124 }
125
126 pub fn frame_style(mut self, style: Style) -> Self {
128 self.frame_style = style;
129 self
130 }
131
132 pub fn focus_style(mut self, style: Style) -> Self {
137 self.focus_style = StyleSlot::Replace(style);
138 self
139 }
140
141 pub fn extend_focus_style(mut self, style: Style) -> Self {
143 self.focus_style = StyleSlot::Extend(style);
144 self
145 }
146
147 pub fn inherit_focus_style(mut self) -> Self {
149 self.focus_style = StyleSlot::Inherit;
150 self
151 }
152
153 pub fn border(mut self, border: bool) -> Self {
155 self.border = border;
156 self
157 }
158
159 pub fn border_style(mut self, border_style: BorderStyle) -> Self {
161 self.border_style = border_style;
162 self
163 }
164
165 pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
167 self.padding = padding.into();
168 self
169 }
170
171 pub fn title_style(mut self, style: Style) -> Self {
173 self.title_style = style;
174 self
175 }
176
177 pub fn title_alignment(mut self, align: Align) -> Self {
179 self.title_alignment = align;
180 self
181 }
182}
183
184impl Default for Modal {
185 fn default() -> Self {
186 Self::new()
187 }
188}
189
190impl From<Modal> for Element {
191 fn from(modal: Modal) -> Self {
192 let frame_style = if modal.frame_style.bg.is_none() {
193 modal.frame_style.bg(Color::Backdrop)
194 } else {
195 modal.frame_style
196 };
197
198 let mut base_frame = Frame::new()
199 .border(modal.border)
200 .border_style(modal.border_style)
201 .padding(modal.padding)
202 .child(modal.child)
203 .style(frame_style)
204 .focus_style_slot(modal.focus_style);
205 if let Some(title) = modal.title {
206 let label = FrameLabel::new(title).style(modal.title_style);
207 let header = match modal.title_alignment {
208 Align::Center => BorderLabels::new().center(label),
209 Align::End => BorderLabels::new().right(label),
210 Align::Start | Align::Stretch => BorderLabels::new().left(label),
211 };
212 base_frame = base_frame.header(header);
213 }
214
215 match modal.scope {
216 OverlayScope::Local => {
217 let mut backdrop = MouseRegion::new().capture_click(true);
218
219 if !modal.backdrop_style.is_empty() {
220 backdrop = backdrop.child(
221 Center::new()
222 .width(Size::Percent(100))
223 .height(Size::Percent(100))
224 .style(modal.backdrop_style),
225 );
226 }
227
228 if let Some(on_close) = modal.on_close {
229 let cb = on_close.clone();
230 backdrop = backdrop.on_click(Callback::new(move |_: MouseEvent| cb.emit(())));
231 } else {
232 backdrop = backdrop.enabled(false);
233 }
234
235 let local_width = match modal.width {
236 Length::Auto => Size::Auto,
237 Length::Px(px) => Size::Fixed(px),
238 Length::Percent(percent) => Size::Percent(percent),
239 Length::Flex(_) => Size::Percent(100),
240 };
241 let local_height = match modal.height {
242 Length::Auto => Size::Auto,
243 Length::Px(px) => Size::Fixed(px),
244 Length::Percent(percent) => Size::Percent(percent),
245 Length::Flex(_) => Size::Percent(100),
246 };
247
248 let local_frame_width = if matches!(modal.width, Length::Auto) {
249 Length::Auto
250 } else {
251 Length::Flex(1)
252 };
253 let local_frame_height = if matches!(modal.height, Length::Auto) {
254 Length::Auto
255 } else {
256 Length::Flex(1)
257 };
258
259 let frame = base_frame
260 .clone()
261 .width(local_frame_width)
262 .height(local_frame_height);
263
264 let content = Center::new()
265 .width(local_width)
266 .height(local_height)
267 .child(frame);
268 let content: Element = match modal.max_height {
269 Some(max_height) => Element::from(content).max_height(max_height),
270 None => content.into(),
271 };
272 ZStack::new().child(backdrop).child(content).into()
273 }
274 OverlayScope::RootPortal => {
275 let frame = base_frame.width(modal.width).height(modal.height);
276 let dismiss_policy = if modal.on_close.is_some() {
277 DismissPolicy::ClickOutsideOrEscape
278 } else {
279 DismissPolicy::None
280 };
281 let portal = Portal {
282 layer: OverlayLayer::Modal,
283 content: Box::new(frame.into()),
284 placement: OverlayPlacement::Center {
285 reserve_height: modal.reserve_height,
286 },
287 dismiss_policy,
288 on_close: modal.on_close,
289 backdrop: Some(modal.backdrop_style),
290 captures_focus: true,
291 auto_focus: modal.auto_focus,
292 captures_pointer: PointerCapture::BackdropFullScreen,
293 };
294 let element = Element::new(ElementKind::Portal(portal));
295 match modal.max_height {
296 Some(max_height) => element.max_height(max_height),
297 None => element,
298 }
299 }
300 }
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use crate::style::Color;
308
309 #[test]
310 fn local_scope_percent_size_is_resolved_by_center_layer() {
311 let element: Element = Modal::new()
312 .title("Local Percent")
313 .scope(OverlayScope::Local)
314 .width(Length::Percent(70))
315 .height(Length::Percent(50))
316 .child(Spacer::new())
317 .into();
318
319 let ElementKind::ZStack(zstack) = element.kind else {
320 panic!("modal local scope must render as zstack");
321 };
322 assert_eq!(zstack.children.len(), 2);
323
324 let ElementKind::Center(center) = &zstack.children[1].kind else {
325 panic!("modal content layer must be centered");
326 };
327 assert_eq!(center.width, Size::Percent(70));
328 assert_eq!(center.height, Size::Percent(50));
329
330 let frame = center
331 .child
332 .as_deref()
333 .expect("center must contain modal frame");
334 let ElementKind::Frame(frame) = &frame.kind else {
335 panic!("center child must be frame");
336 };
337 assert_eq!(frame.props.width, Length::Flex(1));
338 assert_eq!(frame.props.height, Length::Flex(1));
339 assert_eq!(frame.props.style.bg, Some(Color::Backdrop.into()));
340 }
341
342 #[test]
343 fn explicit_transparent_frame_style_bg_is_preserved() {
344 let element: Element = Modal::new()
345 .scope(OverlayScope::Local)
346 .frame_style(Style::new().bg(Color::Transparent))
347 .child(Spacer::new())
348 .into();
349
350 let ElementKind::ZStack(zstack) = element.kind else {
351 panic!("modal local scope must render as zstack");
352 };
353
354 let ElementKind::Center(center) = &zstack.children[1].kind else {
355 panic!("modal content layer must be centered");
356 };
357 let frame = center
358 .child
359 .as_deref()
360 .expect("center must contain modal frame");
361 let ElementKind::Frame(frame) = &frame.kind else {
362 panic!("center child must be frame");
363 };
364 assert_eq!(frame.props.style.bg, Some(Color::Transparent.into()));
365 }
366
367 #[test]
368 fn focus_style_is_forwarded_to_frame() {
369 let focus_style = Style::new().fg(Color::Red);
370 let element: Element = Modal::new()
371 .scope(OverlayScope::Local)
372 .focus_style(focus_style)
373 .child(Spacer::new())
374 .into();
375
376 let ElementKind::ZStack(zstack) = element.kind else {
377 panic!("modal local scope must render as zstack");
378 };
379 let ElementKind::Center(center) = &zstack.children[1].kind else {
380 panic!("modal content layer must be centered");
381 };
382 let frame = center
383 .child
384 .as_deref()
385 .expect("center must contain modal frame");
386 let ElementKind::Frame(frame) = &frame.kind else {
387 panic!("center child must be frame");
388 };
389 assert_eq!(frame.props.focus_style(), Some(focus_style));
390 }
391
392 #[test]
393 fn local_scope_backdrop_uses_mouse_region_layer() {
394 let element: Element = Modal::new()
395 .title("Local Backdrop")
396 .scope(OverlayScope::Local)
397 .backdrop_style(Style::new().dim_by(0.5))
398 .child(Spacer::new())
399 .into();
400
401 let ElementKind::ZStack(zstack) = element.kind else {
402 panic!("modal local scope must render as zstack");
403 };
404 assert_eq!(zstack.children.len(), 2);
405
406 let ElementKind::MouseRegion(backdrop) = &zstack.children[0].kind else {
407 panic!("first local layer must be mouse region backdrop");
408 };
409 let backdrop_child = backdrop
410 .child
411 .as_deref()
412 .expect("non-empty backdrop style must attach backdrop child");
413 let ElementKind::Center(backdrop_layer) = &backdrop_child.kind else {
414 panic!("backdrop child must be center layer");
415 };
416 assert_eq!(backdrop_layer.style.dim_amount, Some(0.5));
417 }
418}