agg_gui/widgets/modal_sheet.rs
1//! ModalSheet — a centered modal panel over a dimming scrim, the
2//! SwiftUI-`.sheet` / desktop-dialog presentation.
3//!
4//! Unlike [`Window`](super::window::Window) the sheet is not draggable,
5//! resizable, or collapsible: it is a fixed-size surface centered over
6//! the app, with a translucent scrim blocking interaction with
7//! everything behind it. Place it as a late child of the root
8//! [`Stack`](super::primitives::Stack) so it paints above the app; while
9//! its visibility cell is `true`, `has_active_modal` routes all input to
10//! the sheet subtree (the scrim swallows what the panel doesn't use) and
11//! `Escape` closes it.
12//!
13//! # Default / cancel actions
14//!
15//! Return / Enter that reaches the sheet unconsumed (focus is not on a
16//! text input — those consume Enter) activates the sheet's **default
17//! action**: the first visible content widget whose `WidgetBase` has
18//! `default_action` set (a `Button::with_default_action()`), or the
19//! closure from [`with_default_action`](ModalSheet::with_default_action).
20//! Escape likewise runs the **cancel action** — a
21//! `Button::with_cancel_action()` in the content or a
22//! [`with_cancel_action`](ModalSheet::with_cancel_action) closure — and
23//! only falls back to the plain close when neither is present. Because
24//! keys are routed to the topmost active modal only, a stacked sheet's
25//! default button never fires the one beneath it.
26//!
27//! A showing sheet OWNS Enter / Escape for its whole scope: while any
28//! modal is active the [`App`](crate::App) skips the root-level
29//! default / cancel dispatch entirely, so a sheet without a default
30//! action still shadows the buttons behind it instead of firing them
31//! under the scrim. That applies to
32//! [`with_key_passthrough`](ModalSheet::with_key_passthrough) sheets too
33//! — passthrough forwards ordinary keys to the app behind, never the
34//! sheet's own Enter / Escape semantics.
35//!
36//! ```ignore
37//! let visible = Rc::new(Cell::new(false));
38//! let sheet = ModalSheet::new(Rc::clone(&visible), Box::new(content))
39//! .with_panel_size(Size::new(620.0, 420.0));
40//! Stack::new().add(Box::new(app)).add(Box::new(sheet))
41//! ```
42
43use std::cell::Cell;
44use std::rc::Rc;
45
46use crate::color::Color;
47use crate::draw_ctx::DrawCtx;
48use crate::event::{Event, EventResult, Key};
49use crate::geometry::{Rect, Size};
50use crate::layout_props::WidgetBase;
51use crate::theme::current_visuals;
52use crate::widget::{activate_action_at, cancel_action_path, default_action_path, Widget};
53use crate::widgets::window::chrome::{paint_chrome_shadow, ChromeStyle};
54
55/// Minimum gap kept between the panel and the host bounds when the
56/// desired panel size doesn't fit.
57const EDGE_MARGIN: f64 = 24.0;
58const CORNER_R: f64 = 10.0;
59
60/// A centered, fixed-size modal panel over a scrim. One content child.
61pub struct ModalSheet {
62 bounds: Rect,
63 children: Vec<Box<dyn Widget>>, // exactly 1: the content
64 base: WidgetBase,
65 visible: Rc<Cell<bool>>,
66 /// Desired panel size; clamped to the available bounds at layout.
67 panel_size: Size,
68 /// Panel rect in local coordinates, computed each layout.
69 panel: Rect,
70 /// Dismiss on `Escape` (default true; SwiftUI's `.cancelAction`).
71 escape_closes: bool,
72 /// Let unhandled keys fall through to the app behind the sheet
73 /// (default false). For sheets that measure live input — a latency
74 /// calibration tapping along on the app's instrument keys.
75 key_passthrough: bool,
76 /// Invoked whenever the sheet closes itself (Escape). External
77 /// closes (a Done button clearing the cell) don't route through
78 /// this — wire those callbacks at the button.
79 on_close: Option<Box<dyn Fn()>>,
80 /// Closure-form default action (Enter); see the module docs. A
81 /// `Button::with_default_action()` in the content takes precedence.
82 default_action: Option<Box<dyn Fn()>>,
83 /// Closure-form cancel action (Escape). When set, Escape runs it
84 /// INSTEAD of closing the sheet — the action owns dismissal, exactly
85 /// like the Cancel button it mirrors.
86 cancel_action: Option<Box<dyn Fn()>>,
87}
88
89impl ModalSheet {
90 pub fn new(visible: Rc<Cell<bool>>, content: Box<dyn Widget>) -> Self {
91 Self {
92 bounds: Rect::default(),
93 children: vec![content],
94 base: WidgetBase::new(),
95 visible,
96 panel_size: Size::new(480.0, 360.0),
97 panel: Rect::default(),
98 escape_closes: true,
99 key_passthrough: false,
100 on_close: None,
101 default_action: None,
102 cancel_action: None,
103 }
104 }
105
106 /// Let unhandled keys reach the app behind the sheet (see
107 /// `key_passthrough`). Enter / Escape stay the sheet's own: the
108 /// root-level default / cancel action is suppressed while any modal
109 /// is showing (see the module docs).
110 pub fn with_key_passthrough(mut self, passthrough: bool) -> Self {
111 self.key_passthrough = passthrough;
112 self
113 }
114
115 /// Desired panel size (clamped to the host bounds minus a margin).
116 pub fn with_panel_size(mut self, size: Size) -> Self {
117 self.panel_size = size;
118 self
119 }
120
121 /// Disable the default Escape-to-close behaviour.
122 pub fn with_escape_closes(mut self, close: bool) -> Self {
123 self.escape_closes = close;
124 self
125 }
126
127 /// Run a callback when the sheet dismisses itself via Escape.
128 pub fn with_on_close(mut self, on_close: impl Fn() + 'static) -> Self {
129 self.on_close = Some(Box::new(on_close));
130 self
131 }
132
133 /// Run `action` when Return / Enter reaches the sheet unconsumed and
134 /// no content widget is marked `with_default_action()`. Mirrors
135 /// SwiftUI's `.keyboardShortcut(.defaultAction)` on the sheet's
136 /// primary button; the closure owns whatever dismissal it wants.
137 pub fn with_default_action(mut self, action: impl Fn() + 'static) -> Self {
138 self.default_action = Some(Box::new(action));
139 self
140 }
141
142 /// Run `action` on Escape instead of the built-in close. The closure
143 /// owns dismissal (typically it clears the visibility cell, exactly
144 /// as the Cancel button's `on_click` does). A content widget marked
145 /// `with_cancel_action()` is tried first; `with_on_close` is NOT
146 /// invoked on this path because the sheet did not close itself.
147 pub fn with_cancel_action(mut self, action: impl Fn() + 'static) -> Self {
148 self.cancel_action = Some(Box::new(action));
149 self
150 }
151
152 pub fn visibility_cell(&self) -> Rc<Cell<bool>> {
153 Rc::clone(&self.visible)
154 }
155
156 fn close(&self) {
157 self.visible.set(false);
158 if let Some(on_close) = &self.on_close {
159 on_close();
160 }
161 crate::animation::request_draw();
162 }
163
164 /// Activate the content widget at `path` (found by one of the
165 /// `*_action_path` searches). Dispatches into the content child only,
166 /// never back to the sheet, so a declining target can't re-enter
167 /// this handler.
168 fn activate_content(&mut self, path: &[usize]) -> bool {
169 let Some((&first, rest)) = path.split_first() else {
170 return false;
171 };
172 let Some(child) = self.children.get_mut(first) else {
173 return false;
174 };
175 activate_action_at(child.as_mut(), rest).is_consumed()
176 }
177
178 /// Enter reached the sheet: fire the default action if there is one.
179 fn handle_enter(&mut self) -> bool {
180 if let Some(path) = default_action_path(self, false) {
181 if self.activate_content(&path) {
182 return true;
183 }
184 }
185 if let Some(action) = &self.default_action {
186 action();
187 crate::animation::request_draw();
188 return true;
189 }
190 false
191 }
192
193 /// Escape reached the sheet: cancel-action widget, then closure, then
194 /// the plain close.
195 fn handle_escape(&mut self) -> bool {
196 if let Some(path) = cancel_action_path(self, false) {
197 if self.activate_content(&path) {
198 return true;
199 }
200 }
201 if let Some(action) = &self.cancel_action {
202 action();
203 crate::animation::request_draw();
204 return true;
205 }
206 if self.escape_closes {
207 self.close();
208 return true;
209 }
210 false
211 }
212}
213
214impl Widget for ModalSheet {
215 fn type_name(&self) -> &'static str {
216 "ModalSheet"
217 }
218
219 fn bounds(&self) -> Rect {
220 self.bounds
221 }
222
223 fn set_bounds(&mut self, b: Rect) {
224 self.bounds = b;
225 }
226
227 fn children(&self) -> &[Box<dyn Widget>] {
228 &self.children
229 }
230
231 fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
232 &mut self.children
233 }
234
235 fn widget_base(&self) -> Option<&WidgetBase> {
236 Some(&self.base)
237 }
238
239 fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
240 Some(&mut self.base)
241 }
242
243 fn is_visible(&self) -> bool {
244 self.visible.get()
245 }
246
247 fn has_active_modal(&self) -> bool {
248 self.visible.get()
249 }
250
251 fn layout(&mut self, available: Size) -> Size {
252 if !self.visible.get() {
253 self.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
254 return Size::new(0.0, 0.0);
255 }
256 let w = self
257 .panel_size
258 .width
259 .min(available.width - 2.0 * EDGE_MARGIN)
260 .max(0.0);
261 let h = self
262 .panel_size
263 .height
264 .min(available.height - 2.0 * EDGE_MARGIN)
265 .max(0.0);
266 // Centered, snapped to whole pixels for crisp chrome.
267 let x = ((available.width - w) / 2.0).round();
268 let y = ((available.height - h) / 2.0).round();
269 self.panel = Rect::new(x, y, w, h);
270 if let Some(child) = self.children.first_mut() {
271 child.layout(Size::new(w, h));
272 child.set_bounds(self.panel);
273 }
274 self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
275 available
276 }
277
278 fn paint(&mut self, ctx: &mut dyn DrawCtx) {
279 if !self.visible.get() {
280 return;
281 }
282 let v = current_visuals();
283
284 // Scrim: dim everything behind the sheet.
285 ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.20));
286 ctx.begin_path();
287 ctx.rect(0.0, 0.0, self.bounds.width, self.bounds.height);
288 ctx.fill();
289
290 // Panel chrome: shadow + rounded body + hairline border.
291 let style = ChromeStyle {
292 corner_radius: CORNER_R,
293 ..ChromeStyle::from_visuals(&v)
294 };
295 ctx.save();
296 ctx.translate(self.panel.x, self.panel.y);
297 paint_chrome_shadow(ctx, self.panel.width, self.panel.height, &style);
298 ctx.set_fill_color(v.window_fill);
299 ctx.begin_path();
300 ctx.rounded_rect(0.0, 0.0, self.panel.width, self.panel.height, CORNER_R);
301 ctx.fill();
302 ctx.set_stroke_color(v.window_stroke);
303 ctx.set_line_width(1.0);
304 ctx.begin_path();
305 ctx.rounded_rect(
306 0.5,
307 0.5,
308 self.panel.width - 1.0,
309 self.panel.height - 1.0,
310 CORNER_R,
311 );
312 ctx.stroke();
313 ctx.restore();
314 }
315
316 fn on_event(&mut self, event: &Event) -> EventResult {
317 if !self.visible.get() {
318 return EventResult::Ignored;
319 }
320 match event {
321 // Scrim: swallow pointer input the panel content didn't take,
322 // so nothing behind the sheet reacts (no click-outside close —
323 // matching macOS sheets).
324 Event::MouseDown { .. } | Event::MouseUp { .. } | Event::MouseWheel { .. } => {
325 EventResult::Consumed
326 }
327 Event::KeyDown {
328 key: Key::Escape, ..
329 } if self.handle_escape() => EventResult::Consumed,
330 Event::KeyDown {
331 key: Key::Enter, ..
332 } if self.handle_enter() => EventResult::Consumed,
333 // Swallow every other key the panel content didn't take:
334 // keystrokes must not fall through to shortcut handlers
335 // behind the sheet (a piano app's key-to-note routing, say).
336 Event::KeyDown { .. } | Event::KeyUp { .. } if !self.key_passthrough => {
337 EventResult::Consumed
338 }
339 _ => EventResult::Ignored,
340 }
341 }
342}