Skip to main content

teksilo_core/
modal.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use crate::overlay::OverlayDismissCallback;
5use crate::widget_id::WidgetId;
6
7/// How the framework should present a modal surface.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9pub enum ModalPresentation {
10    /// Let the framework pick the most appropriate backend for the runtime.
11    #[default]
12    Auto,
13    /// Present inside the current widget tree using the overlay system.
14    InTree,
15    /// Present in a separate native OS window.
16    NativeWindow,
17}
18
19/// How a presented modal can be closed by framework-managed interactions.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
21pub enum ModalCloseBehavior {
22    /// Close when clicking outside the modal surface.
23    ClickOutside,
24    /// Close when pressing Escape.
25    EscapeKey,
26    /// Close on either Escape or an outside click.
27    #[default]
28    EscapeOrClickOutside,
29    /// Only close through explicit application logic.
30    Manual,
31}
32
33/// Where an in-tree modal sits in the window.
34///
35/// Read only by the in-tree presentation: a modal presented as a native
36/// window is placed by the operating system.
37#[derive(Debug, Clone, Copy, PartialEq, Default)]
38pub enum ModalPlacement {
39    /// Centred in the window: the dialog convention.
40    #[default]
41    Centered,
42    /// Centred horizontally, with its top edge `offset` logical pixels below
43    /// the top of the window. For a surface whose height changes while it
44    /// is open, such as a command palette that narrows as the user types: a
45    /// centred one would move its search field with every keystroke, while
46    /// this keeps the top edge still and lets the bottom edge follow the
47    /// content. Raised as far as needed to keep the modal inside the window.
48    Top {
49        /// Distance from the top of the window to the modal's top edge.
50        offset: f32,
51    },
52}
53
54/// Builder used to create modal content in a target widget tree later.
55pub type ModalBuilder = Box<dyn FnOnce(&mut crate::widget_tree::WidgetTree) -> WidgetId>;
56
57/// Modal content source.
58pub enum ModalContent {
59    /// Reuse an already-inserted widget subtree in the current tree.
60    ExistingWidget(WidgetId),
61    /// Build the modal content into a target widget tree on demand.
62    Deferred(ModalBuilder),
63}
64
65impl std::fmt::Debug for ModalContent {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            Self::ExistingWidget(id) => f.debug_tuple("ExistingWidget").field(id).finish(),
69            Self::Deferred(_) => f.write_str("Deferred(..)"),
70        }
71    }
72}
73
74/// A framework-level request to present a modal.
75pub struct ModalRequest {
76    pub content: ModalContent,
77    pub presentation: ModalPresentation,
78    pub close_behavior: ModalCloseBehavior,
79    pub title: Option<String>,
80    pub size: Option<(u32, u32)>,
81    /// Where an in-tree presentation places the modal. Ignored by a native
82    /// window.
83    pub placement: ModalPlacement,
84    /// Optional explicit initial-focus target inside the modal content
85    /// subtree. When `None`, the framework falls back to
86    /// `first_focusable_descendant(content_id)`. When `Some`, the id
87    /// is consulted first and the framework focuses it if the widget
88    /// exists and is still active in the target tree; otherwise it
89    /// falls back to `first_focusable_descendant`. Required for
90    /// `MessageBox`-style alerts where the default button may not be
91    /// the first focusable descendant in tree-walk order.
92    pub focus_target: Option<WidgetId>,
93    /// Invoked when the modal is dismissed by any path (Escape, close
94    /// button, click-outside, explicit `ctx.dismiss_modal()`). Only
95    /// fired for in-tree presentations — native-window modals do not
96    /// have a reliable dismiss callback yet.
97    pub on_dismiss: Option<OverlayDismissCallback>,
98}
99
100impl ModalRequest {
101    /// Present an existing widget subtree as modal content.
102    pub fn in_tree(content_id: WidgetId) -> Self {
103        Self {
104            content: ModalContent::ExistingWidget(content_id),
105            presentation: ModalPresentation::Auto,
106            close_behavior: ModalCloseBehavior::default(),
107            title: None,
108            size: None,
109            placement: ModalPlacement::Centered,
110            focus_target: None,
111            on_dismiss: None,
112        }
113    }
114
115    /// Build modal content on demand in the presentation target tree.
116    pub fn deferred(
117        builder: impl FnOnce(&mut crate::widget_tree::WidgetTree) -> WidgetId + 'static,
118    ) -> Self {
119        Self {
120            content: ModalContent::Deferred(Box::new(builder)),
121            presentation: ModalPresentation::Auto,
122            close_behavior: ModalCloseBehavior::default(),
123            title: None,
124            size: None,
125            placement: ModalPlacement::Centered,
126            focus_target: None,
127            on_dismiss: None,
128        }
129    }
130
131    pub fn presentation(mut self, presentation: ModalPresentation) -> Self {
132        self.presentation = presentation;
133        self
134    }
135
136    pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self {
137        self.close_behavior = close_behavior;
138        self
139    }
140
141    pub fn title(mut self, title: impl Into<String>) -> Self {
142        self.title = Some(title.into());
143        self
144    }
145
146    pub fn size(mut self, width: u32, height: u32) -> Self {
147        self.size = Some((width, height));
148        self
149    }
150
151    /// Where an in-tree presentation places the modal. See [`ModalPlacement`].
152    pub fn placement(mut self, placement: ModalPlacement) -> Self {
153        self.placement = placement;
154        self
155    }
156
157    /// Register a callback invoked when the modal is dismissed by any
158    /// path (Escape, close button, click-outside, explicit
159    /// `ctx.dismiss_modal()`). Only fired for in-tree presentations.
160    pub fn on_dismiss(mut self, callback: OverlayDismissCallback) -> Self {
161        self.on_dismiss = Some(callback);
162        self
163    }
164
165    /// Direct initial focus to a specific widget inside the modal
166    /// content subtree. The id must resolve to a widget that exists
167    /// and is active at the time the modal is presented; if it does
168    /// not, the framework falls back to
169    /// `first_focusable_descendant(content_id)`.
170    pub fn focus_target(mut self, id: WidgetId) -> Self {
171        self.focus_target = Some(id);
172        self
173    }
174}
175
176impl std::fmt::Debug for ModalRequest {
177    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
178        f.debug_struct("ModalRequest")
179            .field("content", &self.content)
180            .field("presentation", &self.presentation)
181            .field("close_behavior", &self.close_behavior)
182            .field("title", &self.title)
183            .field("size", &self.size)
184            .field("placement", &self.placement)
185            .field("focus_target", &self.focus_target)
186            .field(
187                "on_dismiss",
188                &self.on_dismiss.as_ref().map(|_| "<callback>"),
189            )
190            .finish()
191    }
192}
193
194/// A modal request drained from a widget tree with its originating widget.
195#[derive(Debug)]
196pub struct QueuedModalRequest {
197    pub source_widget: WidgetId,
198    pub request: ModalRequest,
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::test_widgets::FillWidget;
205
206    #[test]
207    fn in_tree_request_defaults_to_auto() {
208        let mut tree = crate::WidgetTree::new();
209        let content_id = tree.add(FillWidget::new());
210        let request = ModalRequest::in_tree(content_id);
211        assert_eq!(request.presentation, ModalPresentation::Auto);
212        assert_eq!(
213            request.close_behavior,
214            ModalCloseBehavior::EscapeOrClickOutside
215        );
216        match request.content {
217            ModalContent::ExistingWidget(id) => assert_eq!(id, content_id),
218            ModalContent::Deferred(_) => panic!("expected ExistingWidget content"),
219        }
220    }
221
222    #[test]
223    fn deferred_request_can_override_metadata() {
224        let request =
225            ModalRequest::deferred(|tree| tree.add(crate::test_widgets::FillWidget::new()))
226                .presentation(ModalPresentation::NativeWindow)
227                .close_behavior(ModalCloseBehavior::Manual)
228                .title("Preferences")
229                .size(640, 480);
230
231        assert_eq!(request.presentation, ModalPresentation::NativeWindow);
232        assert_eq!(request.close_behavior, ModalCloseBehavior::Manual);
233        assert_eq!(request.title.as_deref(), Some("Preferences"));
234        assert_eq!(request.size, Some((640, 480)));
235        assert!(matches!(request.content, ModalContent::Deferred(_)));
236    }
237
238    #[test]
239    fn focus_target_defaults_to_none() {
240        let mut tree = crate::WidgetTree::new();
241        let content_id = tree.add(FillWidget::new());
242        let in_tree = ModalRequest::in_tree(content_id);
243        assert!(in_tree.focus_target.is_none());
244
245        let deferred =
246            ModalRequest::deferred(|tree| tree.add(crate::test_widgets::FillWidget::new()));
247        assert!(deferred.focus_target.is_none());
248    }
249
250    #[test]
251    fn focus_target_builder_sets_field() {
252        let mut tree = crate::WidgetTree::new();
253        let content_id = tree.add(FillWidget::new());
254        let target_id = tree.add(FillWidget::new());
255        let request = ModalRequest::in_tree(content_id).focus_target(target_id);
256        assert_eq!(request.focus_target, Some(target_id));
257    }
258}