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()`), and when the
95 /// window presenting it closes with it still up (a quit, a forced
96 /// close of the window), with
97 /// [`DismissReason::Programmatic`](crate::overlay::DismissReason::Programmatic).
98 /// For a native modal window, it runs in that window's tree as the
99 /// window closes, whatever closes it.
100 pub on_dismiss: Option<OverlayDismissCallback>,
101}
102
103impl ModalRequest {
104 /// Present an existing widget subtree as modal content.
105 ///
106 /// The content stays yours: shown in the window, it is parked (dormant)
107 /// when the modal is dismissed, not destroyed, and can be presented
108 /// again.
109 pub fn in_tree(content_id: WidgetId) -> Self {
110 Self {
111 content: ModalContent::ExistingWidget(content_id),
112 presentation: ModalPresentation::Auto,
113 close_behavior: ModalCloseBehavior::default(),
114 title: None,
115 size: None,
116 placement: ModalPlacement::Centered,
117 focus_target: None,
118 on_dismiss: None,
119 }
120 }
121
122 /// Build modal content on demand in the presentation target tree.
123 ///
124 /// The content is built for this modal alone. Shown in the window, it is
125 /// destroyed with the dialog's backdrop after the modal is dismissed:
126 /// once `on_dismiss` has run (it can still read the content) and focus
127 /// has gone back, at the next layout pass, so `Drop` implementations in
128 /// it run then. Shown as a native window, it goes with that window. The
129 /// ids of the widgets built here, and anything bound to them, are invalid
130 /// after dismissal: do not keep them to reuse on a later presentation,
131 /// build again.
132 pub fn deferred(
133 builder: impl FnOnce(&mut crate::widget_tree::WidgetTree) -> WidgetId + 'static,
134 ) -> Self {
135 Self {
136 content: ModalContent::Deferred(Box::new(builder)),
137 presentation: ModalPresentation::Auto,
138 close_behavior: ModalCloseBehavior::default(),
139 title: None,
140 size: None,
141 placement: ModalPlacement::Centered,
142 focus_target: None,
143 on_dismiss: None,
144 }
145 }
146
147 pub fn presentation(mut self, presentation: ModalPresentation) -> Self {
148 self.presentation = presentation;
149 self
150 }
151
152 pub fn close_behavior(mut self, close_behavior: ModalCloseBehavior) -> Self {
153 self.close_behavior = close_behavior;
154 self
155 }
156
157 pub fn title(mut self, title: impl Into<String>) -> Self {
158 self.title = Some(title.into());
159 self
160 }
161
162 /// The size, in logical pixels, of the native window the modal opens
163 /// in when it is presented as one: its floor, since the window grows
164 /// on either axis to fit its content (a dialog panel's padding around
165 /// content of this width, an expander opened). It is centred over the
166 /// window it blocks on the size it first grows to; an expander opened
167 /// later keeps its corner. Ignored in-tree, where the overlay takes its
168 /// content's size.
169 pub fn size(mut self, width: u32, height: u32) -> Self {
170 self.size = Some((width, height));
171 self
172 }
173
174 /// Where an in-tree presentation places the modal. See [`ModalPlacement`].
175 pub fn placement(mut self, placement: ModalPlacement) -> Self {
176 self.placement = placement;
177 self
178 }
179
180 /// Register a callback invoked when the modal is dismissed by any
181 /// path (Escape, close button, click-outside, explicit
182 /// `ctx.dismiss_modal()`), or closed with the window presenting it, in
183 /// the tree and as a native modal window alike, with the reason it
184 /// went.
185 pub fn on_dismiss(mut self, callback: OverlayDismissCallback) -> Self {
186 self.on_dismiss = Some(callback);
187 self
188 }
189
190 /// Direct initial focus to a specific widget inside the modal
191 /// content subtree. The id must resolve to a widget that exists
192 /// and is active at the time the modal is presented; if it does
193 /// not, the framework falls back to
194 /// `first_focusable_descendant(content_id)`.
195 pub fn focus_target(mut self, id: WidgetId) -> Self {
196 self.focus_target = Some(id);
197 self
198 }
199}
200
201impl std::fmt::Debug for ModalRequest {
202 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203 f.debug_struct("ModalRequest")
204 .field("content", &self.content)
205 .field("presentation", &self.presentation)
206 .field("close_behavior", &self.close_behavior)
207 .field("title", &self.title)
208 .field("size", &self.size)
209 .field("placement", &self.placement)
210 .field("focus_target", &self.focus_target)
211 .field(
212 "on_dismiss",
213 &self.on_dismiss.as_ref().map(|_| "<callback>"),
214 )
215 .finish()
216 }
217}
218
219/// A modal request drained from a widget tree with its originating widget.
220#[derive(Debug)]
221pub struct QueuedModalRequest {
222 pub source_widget: WidgetId,
223 pub request: ModalRequest,
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::test_widgets::FillWidget;
230
231 #[test]
232 fn in_tree_request_defaults_to_auto() {
233 let mut tree = crate::WidgetTree::new();
234 let content_id = tree.add(FillWidget::new());
235 let request = ModalRequest::in_tree(content_id);
236 assert_eq!(request.presentation, ModalPresentation::Auto);
237 assert_eq!(
238 request.close_behavior,
239 ModalCloseBehavior::EscapeOrClickOutside
240 );
241 match request.content {
242 ModalContent::ExistingWidget(id) => assert_eq!(id, content_id),
243 ModalContent::Deferred(_) => panic!("expected ExistingWidget content"),
244 }
245 }
246
247 #[test]
248 fn deferred_request_can_override_metadata() {
249 let request =
250 ModalRequest::deferred(|tree| tree.add(crate::test_widgets::FillWidget::new()))
251 .presentation(ModalPresentation::NativeWindow)
252 .close_behavior(ModalCloseBehavior::Manual)
253 .title("Preferences")
254 .size(640, 480);
255
256 assert_eq!(request.presentation, ModalPresentation::NativeWindow);
257 assert_eq!(request.close_behavior, ModalCloseBehavior::Manual);
258 assert_eq!(request.title.as_deref(), Some("Preferences"));
259 assert_eq!(request.size, Some((640, 480)));
260 assert!(matches!(request.content, ModalContent::Deferred(_)));
261 }
262
263 #[test]
264 fn focus_target_defaults_to_none() {
265 let mut tree = crate::WidgetTree::new();
266 let content_id = tree.add(FillWidget::new());
267 let in_tree = ModalRequest::in_tree(content_id);
268 assert!(in_tree.focus_target.is_none());
269
270 let deferred =
271 ModalRequest::deferred(|tree| tree.add(crate::test_widgets::FillWidget::new()));
272 assert!(deferred.focus_target.is_none());
273 }
274
275 #[test]
276 fn focus_target_builder_sets_field() {
277 let mut tree = crate::WidgetTree::new();
278 let content_id = tree.add(FillWidget::new());
279 let target_id = tree.add(FillWidget::new());
280 let request = ModalRequest::in_tree(content_id).focus_target(target_id);
281 assert_eq!(request.focus_target, Some(target_id));
282 }
283}