Skip to main content

tui_lipan/widgets/
modal.rs

1//! Modal widget.
2
3use 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/// A modal dialog with optional title and child content.
13#[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    /// Create a new modal.
36    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    /// Set title.
59    pub fn title(mut self, title: impl Into<RichText>) -> Self {
60        self.title = Some(title.into());
61        self
62    }
63
64    /// Set modal child content.
65    pub fn child(mut self, child: impl Into<Element>) -> Self {
66        self.child = child.into();
67        self
68    }
69
70    /// Set width.
71    pub fn width(mut self, width: Length) -> Self {
72        self.width = width;
73        self
74    }
75
76    /// Set height.
77    pub fn height(mut self, height: Length) -> Self {
78        self.height = height;
79        self
80    }
81
82    /// Cap the modal height. Pair with `height(Length::Auto)` so the modal hugs its content
83    /// but never exceeds this cap; the inner content scrolls when it overflows.
84    pub fn max_height(mut self, max_height: Length) -> Self {
85        self.max_height = Some(max_height);
86        self
87    }
88
89    /// Center a `RootPortal` modal vertically as if it were this tall, then top-align the modal
90    /// within that reserved band. Its top edge stays fixed at `(viewport - reserve_height) / 2`
91    /// as its content grows and shrinks, instead of the whole modal drifting toward the vertical
92    /// center. Content taller than the band keeps that same top edge and extends past the band's
93    /// bottom, so pair this with [`max_height`](Self::max_height) to bound it. Has no effect in
94    /// `OverlayScope::Local`.
95    pub fn reserve_height(mut self, reserve_height: Length) -> Self {
96        self.reserve_height = Some(reserve_height);
97        self
98    }
99
100    /// Set on-close callback (fired when background is clicked).
101    pub fn on_close(mut self, cb: Callback<()>) -> Self {
102        self.on_close = Some(cb);
103        self
104    }
105
106    /// Set overlay scope (portal vs local rendering).
107    pub fn scope(mut self, scope: OverlayScope) -> Self {
108        self.scope = scope;
109        self
110    }
111
112    /// Control whether a root-portal modal focuses its first focusable descendant.
113    ///
114    /// Disabling this keeps keyboard and pointer capture active while focus is suspended.
115    pub fn auto_focus(mut self, auto_focus: bool) -> Self {
116        self.auto_focus = auto_focus;
117        self
118    }
119
120    /// Set backdrop style.
121    pub fn backdrop_style(mut self, style: Style) -> Self {
122        self.backdrop_style = style;
123        self
124    }
125
126    /// Set modal frame style.
127    pub fn frame_style(mut self, style: Style) -> Self {
128        self.frame_style = style;
129        self
130    }
131
132    /// Set the frame style used while the modal (or a descendant input) holds focus. Root-portal
133    /// modals capture focus as soon as they open, so without this the frame falls back to the theme
134    /// focus role, which overrides a deliberate `frame_style` border color. Set both to keep an
135    /// intentional accent (e.g. an error border) visible on a focused dialog.
136    pub fn focus_style(mut self, style: Style) -> Self {
137        self.focus_style = StyleSlot::Replace(style);
138        self
139    }
140
141    /// Extend the themed focus style for the modal frame.
142    pub fn extend_focus_style(mut self, style: Style) -> Self {
143        self.focus_style = StyleSlot::Extend(style);
144        self
145    }
146
147    /// Inherit the themed focus style for the modal frame.
148    pub fn inherit_focus_style(mut self) -> Self {
149        self.focus_style = StyleSlot::Inherit;
150        self
151    }
152
153    /// Enable or disable border decoration.
154    pub fn border(mut self, border: bool) -> Self {
155        self.border = border;
156        self
157    }
158
159    /// Set border style.
160    pub fn border_style(mut self, border_style: BorderStyle) -> Self {
161        self.border_style = border_style;
162        self
163    }
164
165    /// Set padding.
166    pub fn padding(mut self, padding: impl Into<Padding>) -> Self {
167        self.padding = padding.into();
168        self
169    }
170
171    /// Set title style.
172    pub fn title_style(mut self, style: Style) -> Self {
173        self.title_style = style;
174        self
175    }
176
177    /// Set title alignment.
178    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}