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/// Builder used to create modal content in a target widget tree later.
34pub type ModalBuilder = Box<dyn FnOnce(&mut crate::widget_tree::WidgetTree) -> WidgetId>;
35
36/// Modal content source.
37pub enum ModalContent {
38    /// Reuse an already-inserted widget subtree in the current tree.
39    ExistingWidget(WidgetId),
40    /// Build the modal content into a target widget tree on demand.
41    Deferred(ModalBuilder),
42}
43
44impl std::fmt::Debug for ModalContent {
45    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
46        match self {
47            Self::ExistingWidget(id) => f.debug_tuple("ExistingWidget").field(id).finish(),
48            Self::Deferred(_) => f.write_str("Deferred(..)"),
49        }
50    }
51}
52
53/// A framework-level request to present a modal.
54pub struct ModalRequest {
55    pub content: ModalContent,
56    pub presentation: ModalPresentation,
57    pub close_behavior: ModalCloseBehavior,
58    pub title: Option<String>,
59    pub size: Option<(u32, u32)>,
60    /// Optional explicit initial-focus target inside the modal content
61    /// subtree. When `None`, the framework falls back to
62    /// `first_focusable_descendant(content_id)`. When `Some`, the id
63    /// is consulted first and the framework focuses it if the widget
64    /// exists and is still active in the target tree; otherwise it
65    /// falls back to `first_focusable_descendant`. Required for
66    /// `MessageBox`-style alerts where the default button may not be
67    /// the first focusable descendant in tree-walk order.
68    pub focus_target: Option<WidgetId>,
69    /// Invoked when the modal is dismissed by any path (Escape, close
70    /// button, click-outside, explicit `ctx.dismiss_modal()`). Only
71    /// fired for in-tree presentations — native-window modals do not
72    /// have a reliable dismiss callback yet.
73    pub on_dismiss: Option<OverlayDismissCallback>,
74}
75
76impl ModalRequest {
77    /// Present an existing widget subtree as modal content.
78    pub fn in_tree(content_id: WidgetId) -> Self {
79        Self {
80            content: ModalContent::ExistingWidget(content_id),
81            presentation: ModalPresentation::Auto,
82            close_behavior: ModalCloseBehavior::default(),
83            title: None,
84            size: None,
85            focus_target: None,
86            on_dismiss: None,
87        }
88    }
89
90    /// Build modal content on demand in the presentation target tree.
91    pub fn deferred(
92        builder: impl FnOnce(&mut crate::widget_tree::WidgetTree) -> WidgetId + 'static,
93    ) -> Self {
94        Self {
95            content: ModalContent::Deferred(Box::new(builder)),
96            presentation: ModalPresentation::Auto,
97            close_behavior: ModalCloseBehavior::default(),
98            title: None,
99            size: None,
100            focus_target: None,
101            on_dismiss: None,
102        }
103    }
104
105    pub fn presentation(mut self, presentation: ModalPresentation) -> Self {
106        self.presentation = presentation;
107        self
108    }
109
110    pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self {
111        self.close_behavior = close_behavior;
112        self
113    }
114
115    pub fn title(mut self, title: impl Into<String>) -> Self {
116        self.title = Some(title.into());
117        self
118    }
119
120    pub fn size(mut self, width: u32, height: u32) -> Self {
121        self.size = Some((width, height));
122        self
123    }
124
125    /// Register a callback invoked when the modal is dismissed by any
126    /// path (Escape, close button, click-outside, explicit
127    /// `ctx.dismiss_modal()`). Only fired for in-tree presentations.
128    pub fn on_dismiss(mut self, callback: OverlayDismissCallback) -> Self {
129        self.on_dismiss = Some(callback);
130        self
131    }
132
133    /// Direct initial focus to a specific widget inside the modal
134    /// content subtree. The id must resolve to a widget that exists
135    /// and is active at the time the modal is presented; if it does
136    /// not, the framework falls back to
137    /// `first_focusable_descendant(content_id)`.
138    pub fn focus_target(mut self, id: WidgetId) -> Self {
139        self.focus_target = Some(id);
140        self
141    }
142}
143
144impl std::fmt::Debug for ModalRequest {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("ModalRequest")
147            .field("content", &self.content)
148            .field("presentation", &self.presentation)
149            .field("close_behavior", &self.close_behavior)
150            .field("title", &self.title)
151            .field("size", &self.size)
152            .field("focus_target", &self.focus_target)
153            .field(
154                "on_dismiss",
155                &self.on_dismiss.as_ref().map(|_| "<callback>"),
156            )
157            .finish()
158    }
159}
160
161/// A modal request drained from a widget tree with its originating widget.
162#[derive(Debug)]
163pub struct QueuedModalRequest {
164    pub source_widget: WidgetId,
165    pub request: ModalRequest,
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::test_widgets::FillWidget;
172
173    #[test]
174    fn in_tree_request_defaults_to_auto() {
175        let mut tree = crate::WidgetTree::new();
176        let content_id = tree.add(FillWidget::new());
177        let request = ModalRequest::in_tree(content_id);
178        assert_eq!(request.presentation, ModalPresentation::Auto);
179        assert_eq!(
180            request.close_behavior,
181            ModalCloseBehavior::EscapeOrClickOutside
182        );
183        match request.content {
184            ModalContent::ExistingWidget(id) => assert_eq!(id, content_id),
185            ModalContent::Deferred(_) => panic!("expected ExistingWidget content"),
186        }
187    }
188
189    #[test]
190    fn deferred_request_can_override_metadata() {
191        let request =
192            ModalRequest::deferred(|tree| tree.add(crate::test_widgets::FillWidget::new()))
193                .presentation(ModalPresentation::NativeWindow)
194                .close_behavior(ModalCloseBehavior::Manual)
195                .title("Preferences")
196                .size(640, 480);
197
198        assert_eq!(request.presentation, ModalPresentation::NativeWindow);
199        assert_eq!(request.close_behavior, ModalCloseBehavior::Manual);
200        assert_eq!(request.title.as_deref(), Some("Preferences"));
201        assert_eq!(request.size, Some((640, 480)));
202        assert!(matches!(request.content, ModalContent::Deferred(_)));
203    }
204
205    #[test]
206    fn focus_target_defaults_to_none() {
207        let mut tree = crate::WidgetTree::new();
208        let content_id = tree.add(FillWidget::new());
209        let in_tree = ModalRequest::in_tree(content_id);
210        assert!(in_tree.focus_target.is_none());
211
212        let deferred =
213            ModalRequest::deferred(|tree| tree.add(crate::test_widgets::FillWidget::new()));
214        assert!(deferred.focus_target.is_none());
215    }
216
217    #[test]
218    fn focus_target_builder_sets_field() {
219        let mut tree = crate::WidgetTree::new();
220        let content_id = tree.add(FillWidget::new());
221        let target_id = tree.add(FillWidget::new());
222        let request = ModalRequest::in_tree(content_id).focus_target(target_id);
223        assert_eq!(request.focus_target, Some(target_id));
224    }
225}