git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

src/widget/container/dialog.rs (11.8K)

  1 //! `Dialog` — a modal plate around registered widgets (`docs/rfc-accessibility-locale.md`,
  2 //! phase 3).
  3 //!
  4 //! Like a [`Group`](super::Group) it OWNS nothing and lays nothing out: the host lays its
  5 //! members out where it wants them, and the dialog's plate is their padded hull with a title
  6 //! band above. What makes it a dialog is what [`Adapted<Dialog>::open`] does to the
  7 //! [`UiContext`]:
  8 //!
  9 //! - **Focus is trapped.** The Tab walk visits only the members (and their embedded
 10 //!   children), wrapping inside; focus moves to the first of them on open and goes back to
 11 //!   whatever had it on [`close`](Adapted::<Dialog>::close).
 12 //! - **What is behind takes nothing.** Every widget outside reads as covered
 13 //!   (`UiContext::is_coordinate_covered`, which every widget's hit test and hover ask), so
 14 //!   neither a press nor a hover reaches it; [`set_backdrop`](Adapted::<Dialog>::set_backdrop)
 15 //!   dims it.
 16 //! - **A screen reader is told.** The members are linked as the dialog's children, so the
 17 //!   accessibility tree nests them under a `Dialog` node marked modal, named by the title.
 18 //!
 19 //! **Paint the dialog, not its members**: it paints its plate and then each member, so they
 20 //! stand on it. Paint it after everything it covers. Escape is the host's to read: close the
 21 //! dialog on it as on Cancel.
 22 //!
 23 //! The plate is the menu's material (`Material::menu`, the `style.surface.menu` block): a
 24 //! dialog is a popover that holds controls instead of rows.
 25 
 26 use crate::scene::layout::Rect;
 27 use crate::scene::paint::PaintCtx;
 28 use crate::widget::{Adapted, Input, Layout, Paint, UiContext, WidgetHost, WidgetId};
 29 
 30 /// What dims the window behind an open dialog with a backdrop.
 31 const SCRIM: [f32; 4] = [0.0, 0.0, 0.0, 0.5];
 32 
 33 #[derive(Debug, Clone)]
 34 pub struct Dialog {
 35     members: Vec<WidgetId>,
 36     title: Option<String>,
 37     /// Plate inset around the members' hull.
 38     padding: f32,
 39     /// The window rect to dim behind the plate, while open.
 40     backdrop: Option<Rect>,
 41     open: bool,
 42 }
 43 
 44 impl Dialog {
 45     /// A closed dialog titled by its label (`with_label`), at the pane rung's padding.
 46     pub fn new() -> Adapted<Dialog> {
 47         Adapted::new(Dialog {
 48             members: Vec::new(),
 49             title: None,
 50             padding: crate::layout::plate_padding(),
 51             backdrop: None,
 52             open: false,
 53         })
 54     }
 55 
 56     pub fn is_open(&self) -> bool {
 57         self.open
 58     }
 59 
 60     pub fn members(&self) -> &[WidgetId] {
 61         &self.members
 62     }
 63 
 64     fn title_font(&self) -> (String, f32) {
 65         let (fam, size) = crate::layout::parse_font_string(&crate::layout::section_label_font());
 66         (fam, size.unwrap_or(14.0))
 67     }
 68 
 69     /// The title band's height — zero without a title.
 70     fn title_height(&self) -> f32 {
 71         match self.title.as_deref().filter(|t| !t.is_empty()) {
 72             Some(_) => self.title_font().1 + crate::layout::control_gap(),
 73             None => 0.0,
 74         }
 75     }
 76 
 77     /// The room the plate takes ABOVE its members: the padding and the title band. A host
 78     /// lays the first member out this far below where it wants the plate's top.
 79     pub fn headroom(&self) -> f32 {
 80         self.padding + self.title_height()
 81     }
 82 
 83     /// The padding between the members and the plate's edge.
 84     pub fn padding(&self) -> f32 {
 85         self.padding
 86     }
 87 
 88     /// The plate: the members' hull, padded, with the title band on top. `None` when no
 89     /// member is on screen.
 90     pub fn plate(&self, ui: &UiContext) -> Option<Rect> {
 91         let mut hull: Option<(f32, f32, f32, f32)> = None;
 92         for &id in &self.members {
 93             let Some(w) = ui.get_widget(id) else { continue };
 94             let (x, y, ww, hh) = w.rect();
 95             if !w.visible() || ww <= 0.0 || hh <= 0.0 || x < -9000.0 || y < -9000.0 {
 96                 continue;
 97             }
 98             let mut grow = |x: f32, y: f32, w: f32, h: f32| {
 99                 hull = Some(match hull {
100                     None => (x, y, x + w, y + h),
101                     Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h)),
102                 });
103             };
104             grow(x, y, ww, hh);
105             if let Some(l) = w.detached_label_rect() {
106                 grow(l.x, l.y, l.width, l.height);
107             }
108         }
109         let (x0, y0, x1, y1) = hull?;
110         let (p, top) = (self.padding, self.headroom());
111         Some(Rect { x: x0 - p, y: y0 - top, width: x1 - x0 + 2.0 * p, height: y1 - y0 + top + p })
112     }
113 }
114 
115 impl Adapted<Dialog> {
116     /// Open around `members` (registered widget ids, laid out by the host): link them as
117     /// this dialog's children, trap focus among them and cover everything else (see the
118     /// module doc). Opening an open dialog changes its members and keeps where focus goes
119     /// back to.
120     pub fn open(&mut self, ctx: &mut UiContext, members: Vec<WidgetId>) {
121         let id = self.base().id();
122         for &old in &self.members {
123             ctx.unlink_child(id, old);
124         }
125         for &m in &members {
126             ctx.link_ids(id, m);
127         }
128         self.members = members.clone();
129         self.open = true;
130         self.fit(ctx);
131         ctx.open_modal(id, members);
132     }
133 
134     /// Close: unlink the members, release the trap and give focus back.
135     pub fn close(&mut self, ctx: &mut UiContext) {
136         if !self.open {
137             return;
138         }
139         let id = self.base().id();
140         for &m in &self.members {
141             ctx.unlink_child(id, m);
142         }
143         self.open = false;
144         self.backdrop = None;
145         ctx.close_modal(id);
146     }
147 
148     /// Take the plate's rect as the widget's own (its hit area and its accessible bounds),
149     /// from where the members now are. Call after laying them out.
150     pub fn fit(&mut self, ctx: &UiContext) {
151         match self.inner().plate(ctx).filter(|_| self.open) {
152             Some(r) => self.set_rect(r.x, r.y, r.width, r.height),
153             None => self.set_rect(0.0, 0.0, 0.0, 0.0),
154         }
155     }
156 
157     /// Dim `window` (the window's rect) behind the plate while open; `None` for no dimming.
158     pub fn set_backdrop(&mut self, window: Option<Rect>) {
159         self.inner_mut().backdrop = window;
160     }
161 
162     pub fn with_padding(mut self, padding: f32) -> Self {
163         self.inner_mut().padding = padding;
164         self
165     }
166 }
167 
168 impl Layout for Dialog {
169     /// The title is the plate's own band, not a detached control label.
170     fn inline_label(&self) -> bool {
171         true
172     }
173 }
174 
175 impl Paint for Dialog {
176     fn color(&self) -> [f32; 4] {
177         [0.0; 4]
178     }
179 
180     fn sync_label(&mut self, label: &str) {
181         self.title = Some(label.to_string());
182     }
183 
184     /// It paints its members itself, standing on its plate; the walk must not paint them
185     /// again.
186     fn paints_own_subtree(&self) -> bool {
187         true
188     }
189 
190     /// Nothing without the context: the plate is where the members are.
191     fn paint(&self, _rect: Rect, _ctx: &mut PaintCtx) {}
192 
193     fn paint_ui(&self, ui: &UiContext, _rect: Rect, ctx: &mut PaintCtx) {
194         if !self.open {
195             return;
196         }
197         if let Some(b) = self.backdrop {
198             ctx.quad(b, SCRIM);
199         }
200         let Some(plate) = self.plate(ui) else { return };
201         let r = crate::layout::menu_corner_radius().min(plate.height * 0.5);
202         let depth = crate::layout::bevel_width().min(plate.height * 0.2);
203         if crate::color::menu_color()[3] > 0.001 {
204             ctx.plate(plate, (r, r, r, r), &crate::scene::material::Material::menu(), depth);
205         } else {
206             let (plateau, radii) = crate::layout::carve_inside(plate, (r, r, r, r), depth);
207             ctx.boss(plateau, radii, depth);
208         }
209         if let Some(title) = self.title.as_deref().filter(|t| !t.is_empty()) {
210             let (fam, size) = self.title_font();
211             let color = crate::color::control_label_color_for_state(false, false);
212             let (x, y) = (plate.x + self.padding, plate.y + self.padding);
213             ctx.text_with(title.to_string(), x, y, size, color, Some(format!("{fam} {size}")), None);
214         }
215         for &id in &self.members {
216             if let Some(w) = ui.get_widget(id) {
217                 crate::scene::painter::paint_root_into(ui, w, ctx);
218             }
219         }
220     }
221 }
222 
223 impl Input for Dialog {
224     /// The plate takes a press nothing on it took, so it never falls to what is behind.
225     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
226         self.open && x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height
227     }
228 
229     /// A press on the plate is not a drag of the window.
230     fn blocks_root_plate_drag(&self) -> bool {
231         self.open
232     }
233 
234     fn a11y_role(&self) -> Option<accesskit::Role> {
235         Some(accesskit::Role::Dialog)
236     }
237 }
238 
239 #[cfg(test)]
240 mod tests {
241     use super::*;
242     use crate::widget::{Button, TextBox};
243 
244     /// A dialog traps the Tab walk among its members, covers what is behind it, and gives
245     /// focus back on close.
246     #[test]
247     fn a_dialog_traps_focus_covers_the_window_and_gives_focus_back() {
248         let mut ctx = UiContext::new();
249         let behind = ctx.insert(Button::new(10.0, 10.0, 80.0, 24.0).with_label("Behind"));
250         let name = ctx.insert(TextBox::new(String::new()).with_label("Name"));
251         ctx[name].set_rect(120.0, 120.0, 200.0, 24.0);
252         let ok = ctx.insert(Button::new(120.0, 160.0, 80.0, 24.0).with_label("OK"));
253         let dialog = ctx.insert(Dialog::new().with_label("Rename"));
254         ctx.set_focused_id(behind.id());
255 
256         ctx.lend_h(dialog, |d, ctx| d.open(ctx, vec![name.id(), ok.id()]));
257         assert_eq!(ctx.modal_owner(), Some(dialog.id()));
258         assert_eq!(ctx.focused_widget, Some(name.id()), "focus moves in");
259         ctx.focus_step(false);
260         assert_eq!(ctx.focused_widget, Some(ok.id()));
261         ctx.focus_step(false);
262         assert_eq!(ctx.focused_widget, Some(name.id()), "the walk wraps inside, never to Behind");
263         assert!(!ctx[behind].hit_test(20.0, 20.0, &ctx), "behind the dialog nothing hits");
264         assert!(ctx[ok].hit_test(130.0, 170.0, &ctx), "inside it does");
265 
266         let plate = ctx[dialog].inner().plate(&ctx).expect("a plate round the members");
267         let (p, top) = (ctx[dialog].inner().padding(), ctx[dialog].inner().headroom());
268         assert_eq!((plate.x, plate.y), (120.0 - p, 120.0 - top), "the title band is above the members");
269         assert_eq!(ctx[dialog].rect(), (plate.x, plate.y, plate.width, plate.height), "opening fits it");
270         assert_eq!(ctx.tree.child_ids(dialog.id()), vec![name.id(), ok.id()]);
271 
272         ctx.lend_h(dialog, |w, ctx| w.close(ctx)).unwrap();
273         assert_eq!(ctx.modal_owner(), None);
274         assert_eq!(ctx.focused_widget, Some(behind.id()), "focus goes back");
275         assert!(ctx[behind].hit_test(20.0, 20.0, &ctx));
276         assert!(ctx.tree.child_ids(dialog.id()).is_empty(), "the members are unlinked");
277     }
278 
279     /// The dialog paints its plate, then its members on it.
280     #[test]
281     fn a_dialog_paints_its_plate_then_its_members() {
282         use crate::scene::paint::Prim;
283         let mut ctx = UiContext::new();
284         let ok = ctx.insert(Button::new(120.0, 160.0, 80.0, 24.0).with_label("OK"));
285         let dialog = ctx.insert(Dialog::new().with_label("Rename"));
286         let mut pc = PaintCtx::new();
287         crate::scene::painter::paint_root_into(&ctx, &ctx[dialog], &mut pc);
288         assert!(pc.finish().items.is_empty(), "closed, it paints nothing");
289 
290         ctx.lend_h(dialog, |d, ctx| d.open(ctx, vec![ok.id()]));
291         let mut pc = PaintCtx::new();
292         crate::scene::painter::paint_root_into(&ctx, &ctx[dialog], &mut pc);
293         let prims: Vec<Prim> = pc.finish().items.into_iter().map(|i| i.prim).collect();
294         let texts: Vec<&str> = prims
295             .iter()
296             .filter_map(|p| if let Prim::Text { text, .. } = p { Some(text.as_str()) } else { None })
297             .collect();
298         assert_eq!(texts, ["Rename", "OK"], "the title, then the member on the plate");
299     }
300 }