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 }