Skip to main content

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}