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//! ```ignore
14//! let visible = Rc::new(Cell::new(false));
15//! let sheet = ModalSheet::new(Rc::clone(&visible), Box::new(content))
16//!     .with_panel_size(Size::new(620.0, 420.0));
17//! Stack::new().add(Box::new(app)).add(Box::new(sheet))
18//! ```
19
20use std::cell::Cell;
21use std::rc::Rc;
22
23use crate::color::Color;
24use crate::draw_ctx::DrawCtx;
25use crate::event::{Event, EventResult, Key};
26use crate::geometry::{Rect, Size};
27use crate::layout_props::WidgetBase;
28use crate::theme::current_visuals;
29use crate::widget::Widget;
30use crate::widgets::window::chrome::{paint_chrome_shadow, ChromeStyle};
31
32/// Minimum gap kept between the panel and the host bounds when the
33/// desired panel size doesn't fit.
34const EDGE_MARGIN: f64 = 24.0;
35const CORNER_R: f64 = 10.0;
36
37/// A centered, fixed-size modal panel over a scrim. One content child.
38pub struct ModalSheet {
39    bounds: Rect,
40    children: Vec<Box<dyn Widget>>, // exactly 1: the content
41    base: WidgetBase,
42    visible: Rc<Cell<bool>>,
43    /// Desired panel size; clamped to the available bounds at layout.
44    panel_size: Size,
45    /// Panel rect in local coordinates, computed each layout.
46    panel: Rect,
47    /// Dismiss on `Escape` (default true; SwiftUI's `.cancelAction`).
48    escape_closes: bool,
49    /// Let unhandled keys fall through to the app behind the sheet
50    /// (default false). For sheets that measure live input — a latency
51    /// calibration tapping along on the app's instrument keys.
52    key_passthrough: bool,
53    /// Invoked whenever the sheet closes itself (Escape). External
54    /// closes (a Done button clearing the cell) don't route through
55    /// this — wire those callbacks at the button.
56    on_close: Option<Box<dyn Fn()>>,
57}
58
59impl ModalSheet {
60    pub fn new(visible: Rc<Cell<bool>>, content: Box<dyn Widget>) -> Self {
61        Self {
62            bounds: Rect::default(),
63            children: vec![content],
64            base: WidgetBase::new(),
65            visible,
66            panel_size: Size::new(480.0, 360.0),
67            panel: Rect::default(),
68            escape_closes: true,
69            key_passthrough: false,
70            on_close: None,
71        }
72    }
73
74    /// Let unhandled keys reach the app behind the sheet (see
75    /// `key_passthrough`).
76    pub fn with_key_passthrough(mut self, passthrough: bool) -> Self {
77        self.key_passthrough = passthrough;
78        self
79    }
80
81    /// Desired panel size (clamped to the host bounds minus a margin).
82    pub fn with_panel_size(mut self, size: Size) -> Self {
83        self.panel_size = size;
84        self
85    }
86
87    /// Disable the default Escape-to-close behaviour.
88    pub fn with_escape_closes(mut self, close: bool) -> Self {
89        self.escape_closes = close;
90        self
91    }
92
93    /// Run a callback when the sheet dismisses itself via Escape.
94    pub fn with_on_close(mut self, on_close: impl Fn() + 'static) -> Self {
95        self.on_close = Some(Box::new(on_close));
96        self
97    }
98
99    pub fn visibility_cell(&self) -> Rc<Cell<bool>> {
100        Rc::clone(&self.visible)
101    }
102
103    fn close(&self) {
104        self.visible.set(false);
105        if let Some(on_close) = &self.on_close {
106            on_close();
107        }
108        crate::animation::request_draw();
109    }
110}
111
112impl Widget for ModalSheet {
113    fn type_name(&self) -> &'static str {
114        "ModalSheet"
115    }
116
117    fn bounds(&self) -> Rect {
118        self.bounds
119    }
120
121    fn set_bounds(&mut self, b: Rect) {
122        self.bounds = b;
123    }
124
125    fn children(&self) -> &[Box<dyn Widget>] {
126        &self.children
127    }
128
129    fn children_mut(&mut self) -> &mut Vec<Box<dyn Widget>> {
130        &mut self.children
131    }
132
133    fn widget_base(&self) -> Option<&WidgetBase> {
134        Some(&self.base)
135    }
136
137    fn widget_base_mut(&mut self) -> Option<&mut WidgetBase> {
138        Some(&mut self.base)
139    }
140
141    fn is_visible(&self) -> bool {
142        self.visible.get()
143    }
144
145    fn has_active_modal(&self) -> bool {
146        self.visible.get()
147    }
148
149    fn layout(&mut self, available: Size) -> Size {
150        if !self.visible.get() {
151            self.bounds = Rect::new(0.0, 0.0, 0.0, 0.0);
152            return Size::new(0.0, 0.0);
153        }
154        let w = self
155            .panel_size
156            .width
157            .min(available.width - 2.0 * EDGE_MARGIN)
158            .max(0.0);
159        let h = self
160            .panel_size
161            .height
162            .min(available.height - 2.0 * EDGE_MARGIN)
163            .max(0.0);
164        // Centered, snapped to whole pixels for crisp chrome.
165        let x = ((available.width - w) / 2.0).round();
166        let y = ((available.height - h) / 2.0).round();
167        self.panel = Rect::new(x, y, w, h);
168        if let Some(child) = self.children.first_mut() {
169            child.layout(Size::new(w, h));
170            child.set_bounds(self.panel);
171        }
172        self.bounds = Rect::new(0.0, 0.0, available.width, available.height);
173        available
174    }
175
176    fn paint(&mut self, ctx: &mut dyn DrawCtx) {
177        if !self.visible.get() {
178            return;
179        }
180        let v = current_visuals();
181
182        // Scrim: dim everything behind the sheet.
183        ctx.set_fill_color(Color::rgba(0.0, 0.0, 0.0, 0.20));
184        ctx.begin_path();
185        ctx.rect(0.0, 0.0, self.bounds.width, self.bounds.height);
186        ctx.fill();
187
188        // Panel chrome: shadow + rounded body + hairline border.
189        let style = ChromeStyle {
190            corner_radius: CORNER_R,
191            ..ChromeStyle::from_visuals(&v)
192        };
193        ctx.save();
194        ctx.translate(self.panel.x, self.panel.y);
195        paint_chrome_shadow(ctx, self.panel.width, self.panel.height, &style);
196        ctx.set_fill_color(v.window_fill);
197        ctx.begin_path();
198        ctx.rounded_rect(0.0, 0.0, self.panel.width, self.panel.height, CORNER_R);
199        ctx.fill();
200        ctx.set_stroke_color(v.window_stroke);
201        ctx.set_line_width(1.0);
202        ctx.begin_path();
203        ctx.rounded_rect(
204            0.5,
205            0.5,
206            self.panel.width - 1.0,
207            self.panel.height - 1.0,
208            CORNER_R,
209        );
210        ctx.stroke();
211        ctx.restore();
212    }
213
214    fn on_event(&mut self, event: &Event) -> EventResult {
215        if !self.visible.get() {
216            return EventResult::Ignored;
217        }
218        match event {
219            // Scrim: swallow pointer input the panel content didn't take,
220            // so nothing behind the sheet reacts (no click-outside close —
221            // matching macOS sheets).
222            Event::MouseDown { .. } | Event::MouseUp { .. } | Event::MouseWheel { .. } => {
223                EventResult::Consumed
224            }
225            Event::KeyDown {
226                key: Key::Escape, ..
227            } if self.escape_closes => {
228                self.close();
229                EventResult::Consumed
230            }
231            // Swallow every other key the panel content didn't take:
232            // keystrokes must not fall through to shortcut handlers
233            // behind the sheet (a piano app's key-to-note routing, say).
234            Event::KeyDown { .. } | Event::KeyUp { .. } if !self.key_passthrough => {
235                EventResult::Consumed
236            }
237            _ => EventResult::Ignored,
238        }
239    }
240}