frus_widgets/menu.rs
1//! [`PopupMenuButton`]: a **floating** action menu — an anchor plus a list of items that opens
2//! over it, through the overlay, and closes on an outside click.
3//!
4//! A row is a label and a message in the ordinary case, and [`MenuItem`] is what it is
5//! when it is not: a picture in the leading column, a tick, the keys that work the action,
6//! a row the caller drew, a rule between two groups, or a row that is unavailable while
7//! the rest of the menu is not.
8//!
9//! **The leading column belongs to the menu.** If any row has a mark, every row keeps the
10//! room for one, so the marks line up down a column and a tick that is off holds its place
11//! rather than sliding its own label across. A menu with no marks at all is not indented at
12//! all.
13
14use std::rc::Rc;
15
16use frus_core::{
17 BorderRadius, Color, Insets, Point, Rect, ResolvedTextStyle, Scene, ShapeBorder, TextStyle,
18};
19use frus_layout::{Align, Dimension, FlexDirection, Style};
20
21use crate::disabled::disabled_content;
22use crate::divider::Divider;
23use crate::flex::Flex;
24use crate::icons::{IconData, Icons};
25use crate::interaction::Status;
26use crate::portal::Placement;
27use crate::theme::Theme;
28use crate::transparent::Shared;
29use crate::widget::Widget;
30
31const WIDTH: f32 = 220.0;
32/// **One row's height** — the smallest box a control worked by a finger reserves for
33/// it, which is what the reference gives a menu item (`popup_menu.dart:279`). It was 38,
34/// ten pixels under the number the accessibility scanners on both mobile platforms check
35/// for, on a widget whose entire purpose is to be tapped.
36const ROW_H: f32 = crate::theme::MIN_TAP_TARGET;
37/// The room either side of a row's label (`popup_menu.dart:1876`).
38const PAD_X: f32 = 12.0;
39/// The room above and below the rows, inside the panel (`popup_menu.dart:1872`). It is
40/// twice the panel's own corner, which is why a row's highlight can never reach a curve
41/// and the panel needs no clip.
42const PAD_Y: f32 = 8.0;
43/// How far off the page the panel sits: three, the reference's popup menu's. A menu
44/// anchor's panel and a dropdown menu's stand at the same three: the reference's menu
45/// defaults.
46const ELEVATION: f32 = 3.0;
47/// How far off the page a **dropdown button's** list sits: the reference's dropdown button
48/// takes eight in its constructor, and its painter casts the shadow that height is given.
49const DROPDOWN_ELEVATION: f32 = 8.0;
50/// **The leading column**: the mark itself, and the room between it and the words.
51/// Eighteen is the size of [`DropdownButton`](crate::DropdownButton)'s own tick — the
52/// nearest control in the framework that puts a mark beside a label — and twelve is the
53/// row's own padding again, so a marked row's words start two paddings in.
54const LEAD: f32 = 18.0;
55const LEAD_GAP: f32 = 12.0;
56/// The least room between a label and the keys shown on its right. A shortcut close
57/// enough to be read as the end of the label is worse than no shortcut.
58const SHORTCUT_GAP: f32 = 24.0;
59
60/// The panel's surface: the caller's word, then the theme's, then the reference's
61/// — `surface_container`, a menu being a **distinct area within** the surface rather
62/// than something floating above it in a colour of its own (`popup_menu.dart:1858`).
63fn panel_background(own: Option<Color>, theme: &Theme, kind: PanelKind) -> Color {
64 own.or(menu_theme(theme, kind).background)
65 .unwrap_or(theme.scheme.surface_container)
66}
67
68/// **The menu theme a panel of `kind` answers to**: the application's [`MenuTheme`] for a
69/// popup menu; for a menu bar's menus, the bar's own, then — on a desktop theme — a
70/// desktop's menus, then the application's (milestone 636).
71///
72/// [`MenuTheme`]: crate::MenuTheme
73pub(crate) fn menu_theme(theme: &Theme, kind: PanelKind) -> crate::MenuTheme {
74 match kind {
75 PanelKind::Bar => {
76 let desktop = if theme.platform.is_desktop() {
77 crate::MenuTheme::desktop(theme)
78 } else {
79 crate::MenuTheme::default()
80 };
81 theme
82 .widgets
83 .menu_bar
84 .menus
85 .or(desktop)
86 .or(theme.widgets.menu)
87 }
88 _ => theme.widgets.menu,
89 }
90}
91
92/// **Which theme a panel answers to** once its caller has said nothing.
93///
94/// Four widgets float a list on a panel, and they do not all read the same theme — nor
95/// do the reference's. A popup menu, a menu anchor and a dropdown menu all resolve
96/// through the menu theme to the menu defaults; a dropdown button reads neither and
97/// stands higher.
98#[derive(Clone, Copy, Debug, PartialEq, Eq)]
99pub(crate) enum PanelKind {
100 /// [`PopupMenuButton`], [`MenuAnchor`](crate::MenuAnchor) and
101 /// [`DropdownMenu`](crate::DropdownMenu): [`MenuTheme`](crate::MenuTheme), then three.
102 Menu,
103 /// [`DropdownButton`](crate::DropdownButton): the `menu_*` fields of
104 /// [`DropdownTheme`](crate::DropdownTheme), then eight.
105 Dropdown,
106 /// A [`MenuBar`](crate::MenuBar)'s menus: the bar's menus, then a desktop's on a desktop
107 /// theme, then [`MenuTheme`](crate::MenuTheme) — see [`menu_theme`].
108 Bar,
109}
110
111/// **What a caller said about a menu's panel**, every word optional. The theme's, then
112/// the framework's, fill in the rest — see [`PanelKind`] for whose.
113#[derive(Clone, Copy, Debug, Default, PartialEq)]
114pub(crate) struct PanelStyle {
115 pub(crate) background: Option<Color>,
116 pub(crate) shape: Option<ShapeBorder>,
117 pub(crate) elevation: Option<f32>,
118 pub(crate) shadow_color: Option<Color>,
119 pub(crate) padding: Option<Insets>,
120 /// The panel's outline (milestone 636).
121 pub(crate) border: Option<frus_core::BorderSide>,
122}
123
124impl PanelStyle {
125 /// The surface: the caller's, the theme's, then `surface_container` for every kind.
126 pub(crate) fn background(&self, kind: PanelKind, theme: &Theme) -> Color {
127 match kind {
128 PanelKind::Menu | PanelKind::Bar => panel_background(self.background, theme, kind),
129 PanelKind::Dropdown => self
130 .background
131 .or(theme.widgets.dropdown.menu_background)
132 .unwrap_or(theme.scheme.surface_container),
133 }
134 }
135
136 /// The shape: the caller's, the theme's shape, the theme's radius, then the
137 /// framework's one corner — see [`Panel::shape_of`] for why not the reference's.
138 fn shape(&self, kind: PanelKind, theme: &Theme) -> ShapeBorder {
139 let (shape, radius) = match kind {
140 PanelKind::Menu | PanelKind::Bar => {
141 let m = menu_theme(theme, kind);
142 (m.shape, m.radius)
143 }
144 PanelKind::Dropdown => (
145 theme.widgets.dropdown.menu_shape,
146 theme.widgets.dropdown.menu_radius,
147 ),
148 };
149 crate::resolve_shape(
150 self.shape,
151 shape,
152 radius.map(BorderRadius::uniform),
153 ShapeBorder::rounded(theme.radius),
154 )
155 }
156
157 /// The height: the caller's, the theme's, then three for a menu and eight for a
158 /// dropdown button.
159 fn elevation(&self, kind: PanelKind, theme: &Theme) -> f32 {
160 match kind {
161 PanelKind::Menu | PanelKind::Bar => self
162 .elevation
163 .or(menu_theme(theme, kind).elevation)
164 .unwrap_or(ELEVATION),
165 PanelKind::Dropdown => self
166 .elevation
167 .or(theme.widgets.dropdown.menu_elevation)
168 .unwrap_or(DROPDOWN_ELEVATION),
169 }
170 }
171
172 /// The shadow's colour: the caller's, the theme's, then the scheme's shadow at 30 %,
173 /// the framework's colour for a height. It is used **as given**, alpha and all, so a
174 /// transparent one casts nothing (milestone 529).
175 fn shadow_color(&self, kind: PanelKind, theme: &Theme) -> Color {
176 let themed = match kind {
177 PanelKind::Menu | PanelKind::Bar => menu_theme(theme, kind).shadow_color,
178 PanelKind::Dropdown => theme.widgets.dropdown.menu_shadow_color,
179 };
180 self.shadow_color.or(themed).unwrap_or(theme.scheme.shadow)
181 }
182
183 /// The room above and below the rows: the caller's, the theme's, then eight — the
184 /// reference's for a menu and for a dropdown button's list alike.
185 fn padding(&self, kind: PanelKind, theme: Option<&Theme>) -> Insets {
186 let themed = theme.and_then(|t| match kind {
187 PanelKind::Menu | PanelKind::Bar => menu_theme(t, kind).padding,
188 PanelKind::Dropdown => t.widgets.dropdown.menu_padding,
189 });
190 self.padding
191 .or(themed)
192 .unwrap_or(Insets::new(PAD_Y, 0.0, PAD_Y, 0.0))
193 }
194}
195
196/// The style the items are drawn in: what the caller said, else what the theme says, else
197/// the reference's — a popup menu's items are `labelLarge` (`popup_menu.dart:1849`).
198///
199/// **Resolved once**, so that the number the box is measured with is the number the glyphs
200/// are drawn at. Resolving is the single place the reader's font setting is applied
201/// (milestone 403); a size that never passes through it is a size the reader cannot change.
202fn label_style(
203 over: Option<TextStyle>,
204 theme: Option<&Theme>,
205 kind: PanelKind,
206) -> ResolvedTextStyle {
207 over.or(theme.and_then(|t| menu_theme(t, kind).text_style))
208 .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
209 .resolved()
210}
211
212/// What a row does with the keyboard in a menu bar (milestone 605): what the left and right
213/// arrows send, and whether it takes the focus when its menu opens. A popup menu's rows do
214/// none of it.
215#[derive(Clone)]
216pub(crate) struct RowKeys<Msg> {
217 pub(crate) left: Option<Msg>,
218 pub(crate) right: Option<Msg>,
219 /// What Escape sends from the row: a menu bar's closes the row's own menu and no more.
220 pub(crate) escape: Option<Msg>,
221 pub(crate) autofocus: bool,
222}
223
224impl<Msg> Default for RowKeys<Msg> {
225 fn default() -> Self {
226 Self {
227 left: None,
228 right: None,
229 escape: None,
230 autofocus: false,
231 }
232 }
233}
234
235/// **What sits in a row's leading column**, when the row has anything to put there.
236#[derive(Clone, Copy)]
237enum Lead {
238 /// A tick, drawn only when the row is on — and the column is kept either way, so
239 /// turning something off does not slide its own label across.
240 Check(bool),
241 /// A picture of the caller's choosing.
242 Icon(IconData),
243}
244
245/// What one row needs **across**, kept where the panel can reach it.
246///
247/// The panel is what decides how wide a menu is, so the panel has to be able to measure
248/// the rows — and it cannot ask them. By the time it holds them they are `dyn Widget`,
249/// and a widget cannot be asked how wide it would like to be
250/// ([#52](https://github.com/KalybosPro/frus/issues/52)). So the words are copied to
251/// where the measuring happens.
252#[derive(Clone)]
253struct Measure {
254 /// The row's own words, or `None` for a rule and for a row the caller drew.
255 label: Option<String>,
256 shortcut: Option<String>,
257 /// Whether the row opens a submenu, and so ends in a chevron (milestone 603).
258 submenu: bool,
259}
260
261/// One menu action, a clickable row.
262///
263/// **Not a button.** It has no surface and no outline of its own: it is a strip of the
264/// panel that lights up under a pointer, which is what a row in a list of actions is
265/// everywhere it appears. It carries the panel's resolved colour down so its state layer
266/// has the right thing to sit on.
267struct Item<Msg> {
268 /// The row's own words — `None` when the caller drew the row, in which case their
269 /// widget is child 0.
270 label: Option<String>,
271 /// `[the caller's widget]`, or empty for a row of words.
272 children: Vec<Box<dyn Widget<Msg>>>,
273 /// This row's mark, if it has one.
274 lead: Option<Lead>,
275 /// Whether **the menu** keeps a leading column at all — a different question from
276 /// whether this row has anything to put in it, and the whole reason the marks line up
277 /// down a column instead of every row deciding for itself.
278 lead_column: bool,
279 /// The keys that work this action, shown on the right.
280 shortcut: Option<String>,
281 /// Whether this row opens a submenu: a chevron at the end (milestone 603).
282 submenu: bool,
283 /// Whether that submenu is open: the row keeps its highlight while the pointer is in
284 /// the submenu, so the eye can find what the submenu belongs to (milestone 636).
285 expanded: bool,
286 /// What the left and right arrows send while this row has the focus, and whether it
287 /// takes the focus when its menu opens (milestone 605).
288 keys: RowKeys<Msg>,
289 /// Whether this row can be used. The menu's own availability is folded in here, but
290 /// only for tidiness: a disabled menu never opens, so there is no row to disable.
291 enabled: bool,
292 text_style: Option<TextStyle>,
293 /// The caller's panel colour, so a row's highlight tints the surface it is drawn on
294 /// and not the one the framework would have picked.
295 background: Option<Color>,
296 /// The caller's row padding and row height, if either was named.
297 padding: Option<Insets>,
298 height: Option<f32>,
299 /// Whose theme the row answers to.
300 kind: PanelKind,
301 message: Msg,
302}
303
304impl<Msg> Item<Msg> {
305 /// The room either side of the label: the caller's, then the theme's, then the
306 /// reference's twelve.
307 fn padding(&self, theme: Option<&Theme>) -> Insets {
308 self.padding
309 .or(theme.and_then(|t| menu_theme(t, self.kind).item_padding))
310 .unwrap_or(Insets::new(0.0, PAD_X, 0.0, PAD_X))
311 }
312
313 /// What the leading column takes from the words — nought unless the menu keeps one.
314 fn lead_room(&self) -> f32 {
315 if self.lead_column {
316 LEAD + LEAD_GAP
317 } else {
318 0.0
319 }
320 }
321
322 /// The room kept clear on the right for the keys, their gap included.
323 fn shortcut_room(&self, theme: Option<&Theme>) -> f32 {
324 let keys = self.shortcut.as_deref().map_or(0.0, |keys| {
325 SHORTCUT_GAP
326 + frus_text::measure_resolved(keys, &label_style(self.text_style, theme, self.kind))
327 .width
328 });
329 keys + if self.submenu {
330 SHORTCUT_GAP + LEAD
331 } else {
332 0.0
333 }
334 }
335
336 fn sizing(&self, theme: Option<&Theme>) -> Style {
337 let height = self
338 .height
339 .or(theme.and_then(|t| menu_theme(t, self.kind).item_height))
340 .unwrap_or(ROW_H);
341 // The row grows if the reader's type does not fit in it — the height is a
342 // floor, not a promise.
343 let line =
344 frus_text::line_box(height, &label_style(self.text_style, theme, self.kind), 0.0);
345 // **Across, a row says nothing.** The panel decides how wide the menu is and
346 // stretches every row to it; a row that named its own width would leave the
347 // highlights ragged the moment one label was longer than the others.
348 if self.children.is_empty() {
349 return Style {
350 width: Dimension::Auto,
351 height: Dimension::Length(line),
352 ..Default::default()
353 };
354 }
355 let pad = self.padding(theme);
356 Style {
357 width: Dimension::Auto,
358 // A row the caller drew **grows**. A two-line item is half of why the form
359 // exists at all, and a fixed height would cut it in two.
360 height: Dimension::Auto,
361 min_height: Dimension::Length(line),
362 flex_direction: FlexDirection::Row,
363 align: Align::Center,
364 // The columns are kept clear by padding rather than by empty siblings, so a
365 // caller's widget is laid out in exactly the room the words would have had.
366 padding: Insets::new(
367 pad.top,
368 pad.right + self.shortcut_room(theme),
369 pad.bottom,
370 pad.left + self.lead_room(),
371 ),
372 ..Default::default()
373 }
374 }
375}
376
377impl<Msg: Clone> Widget<Msg> for Item<Msg> {
378 fn style(&self) -> Style {
379 self.sizing(None)
380 }
381
382 fn style_themed(&self, theme: &Theme) -> Style {
383 self.sizing(Some(theme))
384 }
385
386 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
387 &self.children
388 }
389
390 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
391 let o = status.opacity;
392 // A row draws **nothing at rest**. The panel behind it is the surface; all this
393 // adds is the state layer that says a pointer is over it. No state layer while
394 // disabled: a hover tint promises that a press would do something.
395 //
396 // It used to draw a filled, outlined, rounded rectangle per row — which made a
397 // menu a stack of buttons with two-pixel gutters showing the page through, where
398 // the reference has one panel with rows inside it.
399 let base = panel_background(self.background, theme, self.kind);
400 if self.enabled {
401 let mut status = status;
402 if self.expanded {
403 status.hover_progress = 1.0;
404 }
405 let tinted = theme.state_layer(base, theme.on_surface, &status);
406 if tinted != base {
407 scene.fill_rect(bounds, tinted.fade(o));
408 }
409 }
410 let ink = if self.enabled {
411 theme.on_surface
412 } else {
413 disabled_content(theme)
414 };
415 let style = label_style(self.text_style, Some(theme), self.kind);
416 let pad = self.padding(Some(theme));
417 // The mark, centred in its column. A tick that is off draws nothing and still
418 // holds its place.
419 let mark = match self.lead {
420 Some(Lead::Check(on)) => on.then_some(Icons::CHECK),
421 Some(Lead::Icon(icon)) => Some(icon),
422 None => None,
423 };
424 if let Some(icon) = mark {
425 let y = bounds.y + (bounds.height - LEAD) * 0.5;
426 let path = icon.placed(LEAD, bounds.x + pad.left, y, theme.direction);
427 scene.fill_path(&path, ink.fade(o));
428 }
429 let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
430 if let Some(label) = &self.label {
431 scene.text(
432 Point::new(bounds.x + pad.left + self.lead_room(), ty),
433 label.clone(),
434 &style,
435 ink.fade(o),
436 );
437 }
438 // A row that opens a submenu ends in a chevron, pointing where it opens: the end
439 // side, so the left in right-to-left (milestone 603).
440 let chevron_room = if self.submenu {
441 let x = bounds.x + bounds.width - pad.right - LEAD;
442 let y = bounds.y + (bounds.height - LEAD) * 0.5;
443 let path = Icons::CHEVRON_RIGHT.placed(LEAD, x, y, theme.direction);
444 scene.fill_path(&path, ink.fade(o));
445 SHORTCUT_GAP + LEAD
446 } else {
447 0.0
448 };
449 if let Some(keys) = &self.shortcut {
450 let width = frus_text::measure_resolved(keys, &style).width + chevron_room;
451 // **Muted, never the label's ink.** The keys are a reminder of another way
452 // in, not a second thing to read.
453 let tint = if self.enabled {
454 theme.muted
455 } else {
456 disabled_content(theme)
457 };
458 scene.text(
459 Point::new(bounds.x + bounds.width - pad.right - width, ty),
460 keys.clone(),
461 &style,
462 tint.fade(o),
463 );
464 }
465 }
466
467 fn on_click(&self) -> Option<Msg> {
468 if !self.enabled {
469 return None;
470 }
471 Some(self.message.clone())
472 }
473
474 fn focusable(&self) -> bool {
475 self.enabled
476 }
477
478 fn autofocus(&self) -> bool {
479 self.enabled && self.keys.autofocus
480 }
481
482 fn on_key(&self, key: &crate::interaction::Key) -> crate::interaction::KeyResponse<Msg> {
483 use crate::interaction::{Key, KeyResponse};
484 // A row that cannot be used answers no key, as it answers no press.
485 if !self.enabled {
486 return KeyResponse::Ignored;
487 }
488 let sent = match key {
489 Key::Left { .. } => self.keys.left.clone(),
490 Key::Right { .. } => self.keys.right.clone(),
491 Key::Escape => self.keys.escape.clone(),
492 _ => None,
493 };
494 match sent {
495 Some(message) => KeyResponse::Handled(Some(message)),
496 None => KeyResponse::Ignored,
497 }
498 }
499
500 fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
501 // A menu row said nothing to a reader before this.
502 //
503 // A row that is on or off is announced as a **checkbox**, with its state: there
504 // is no `menuitemcheckbox` in this framework's vocabulary, and a checkbox is the
505 // role that carries the one thing such a row has to say. A row the caller drew
506 // announces nothing here and leaves that to whatever they drew.
507 let (role, toggled) = match self.lead {
508 Some(Lead::Check(on)) => (frus_core::Role::CheckBox, Some(on)),
509 _ => (frus_core::Role::Button, None),
510 };
511 let mut spoken = self.label.clone().unwrap_or_default();
512 if let Some(keys) = &self.shortcut {
513 // The keys are on the row for someone who can see them, and the announcement
514 // is the only other way anybody hears about them.
515 if !spoken.is_empty() {
516 spoken.push_str(", ");
517 }
518 spoken.push_str(keys);
519 }
520 let mut semantics = frus_core::SemanticsProperties::new(role).maybe_toggled(toggled);
521 if !spoken.is_empty() {
522 semantics = semantics.label(spoken);
523 }
524 Some(if self.enabled {
525 semantics.clickable()
526 } else {
527 semantics.disabled(true)
528 })
529 }
530}
531
532/// A rule between a menu's rows: the application's divider, as tall as the menu's theme
533/// says (milestone 636). Its own widget, not a divider built from the theme, because a
534/// built child sits in a box of its own and a rule's width is the column's stretch.
535struct Rule {
536 kind: PanelKind,
537 /// The caller's height, before the theme's.
538 height: Option<f32>,
539}
540
541impl Rule {
542 fn divider(&self, theme: &Theme) -> Divider {
543 match self.height.or(menu_theme(theme, self.kind).divider_height) {
544 Some(h) => Divider::new().height(h),
545 None => Divider::new(),
546 }
547 }
548}
549
550impl<Msg> Widget<Msg> for Rule {
551 fn style(&self) -> Style {
552 Widget::<Msg>::style(&Divider::new())
553 }
554
555 fn style_themed(&self, theme: &Theme) -> Style {
556 Widget::<Msg>::style_themed(&self.divider(theme), theme)
557 }
558
559 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
560 &[]
561 }
562
563 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
564 Widget::<Msg>::paint(&self.divider(theme), bounds, status, theme, scene);
565 }
566
567 fn on_click(&self) -> Option<Msg> {
568 None
569 }
570
571 fn debug_name(&self) -> &'static str {
572 "MenuRule"
573 }
574}
575
576/// **The thing a menu actually is**: one surface, off the page, with the rows inside it.
577///
578/// This did not exist. `rebuild` handed the overlay a bare column of rows, so a menu was
579/// a stack of outlined buttons with the page showing through two-pixel gutters — which
580/// is not what a menu looks like anywhere, and is not what the reference draws
581/// (`popup_menu.dart:1837`).
582///
583/// **Shared by every list that floats.** [`PopupMenuButton`] built it first;
584/// [`MenuAnchor`](crate::MenuAnchor), [`DropdownButton`](crate::DropdownButton) and
585/// [`DropdownMenu`](crate::DropdownMenu) float on it too since milestone 532, where each
586/// had floated bare content or a column of separately outlined rows with nothing behind
587/// them — and so nothing a shadow could be the shadow of. A third copy of the surface
588/// would have been three panels drifting apart; this is one, told by [`PanelKind`] whose
589/// theme it answers to.
590///
591/// **It does not clip.** A clip takes the clipping widget's own paint with it, and the
592/// shadow is that paint. The room above and below the rows keeps a row's highlight off
593/// the corners instead, and a list long enough to scroll clips in its own viewport,
594/// inside that room.
595pub(crate) struct Panel<Msg> {
596 children: Vec<Box<dyn Widget<Msg>>>,
597 kind: PanelKind,
598 look: PanelStyle,
599 /// The popup menu's rows, measured because the popup menu's panel is what decides
600 /// its width. `None` for a panel as wide as what is inside it.
601 measure: Option<RowMeasure>,
602}
603
604/// What a popup menu's panel measures its rows by — see [`Panel::row_width`].
605struct RowMeasure {
606 /// What each row needs across, in order — see [`Measure`]. The panel is what decides
607 /// the menu's width, so this is where the words have to be.
608 rows: Vec<Measure>,
609 /// Whether any row has a mark, and so whether every row keeps room for one.
610 lead_column: bool,
611 text_style: Option<TextStyle>,
612 item_padding: Option<Insets>,
613}
614
615impl<Msg> Panel<Msg> {
616 /// A panel **as wide as what is inside it**, in the look `kind` resolves `look` to.
617 pub(crate) fn new(
618 kind: PanelKind,
619 look: PanelStyle,
620 children: Vec<Box<dyn Widget<Msg>>>,
621 ) -> Self {
622 Self {
623 children,
624 kind,
625 look,
626 measure: None,
627 }
628 }
629
630 /// What shape the panel is: the caller's word, then the theme's shape, then the
631 /// theme's plain radius, then the framework's own corner.
632 ///
633 /// The framework's is `theme.radius` and **not** the reference's four. The reference
634 /// gives every component its own corner — four for a menu, twelve for a card,
635 /// twenty-eight for a sheet — where this framework collapses them into one number an
636 /// application sets once. Reaching into that collapse for one widget would make the
637 /// menu the only thing on screen that ignores it; `MenuTheme::radius` is there for an
638 /// application that wants the reference's number.
639 fn shape_of(&self, theme: &Theme) -> ShapeBorder {
640 self.look.shape(self.kind, theme)
641 }
642
643 /// The box: a column, the room above and below the rows, and — for a popup menu —
644 /// the width its rows measure to.
645 fn sizing(&self, theme: Option<&Theme>) -> Style {
646 let padding = self.look.padding(self.kind, theme);
647 Style {
648 // The rows are the width they measure to; the panel is that plus whatever
649 // room it was told to keep at its own edges.
650 width: self.row_width(theme).map_or(Dimension::Auto, |rows| {
651 Dimension::Length(rows + padding.left + padding.right)
652 }),
653 flex_direction: FlexDirection::Column,
654 padding,
655 ..Default::default()
656 }
657 }
658
659 /// **How wide a row is**: two hundred and twenty, or the widest row when a row wants
660 /// more than that.
661 ///
662 /// It was a bare constant. That is fine for a list of one-word actions and wrong the
663 /// moment a row carries a mark, a label and the keys that work it — and the way it
664 /// fails is by overlapping, which no assertion about the tree catches and only a
665 /// picture shows. The width is a **floor** so that no menu already drawn moves: a
666 /// hundred per cent of the menus in this repository still measure under it.
667 ///
668 /// A row the caller drew contributes nothing, because it cannot be asked what it
669 /// wants ([#52](https://github.com/KalybosPro/frus/issues/52)). It takes whatever
670 /// the words decided, and that is the single place in the menu where that gap shows.
671 ///
672 /// `None` for a panel that measures no rows — it is as wide as what is inside it.
673 fn row_width(&self, theme: Option<&Theme>) -> Option<f32> {
674 let measure = self.measure.as_ref()?;
675 let style = label_style(measure.text_style, theme, self.kind);
676 let pad = measure
677 .item_padding
678 .or(theme.and_then(|t| menu_theme(t, self.kind).item_padding))
679 .unwrap_or(Insets::new(0.0, PAD_X, 0.0, PAD_X));
680 let lead = if measure.lead_column {
681 LEAD + LEAD_GAP
682 } else {
683 0.0
684 };
685 let widest = measure.rows.iter().fold(0.0f32, |wide, row| {
686 let label = row
687 .label
688 .as_deref()
689 .map_or(0.0, |t| frus_text::measure_resolved(t, &style).width);
690 let keys = row.shortcut.as_deref().map_or(0.0, |t| {
691 SHORTCUT_GAP + frus_text::measure_resolved(t, &style).width
692 });
693 let chevron = if row.submenu {
694 SHORTCUT_GAP + LEAD
695 } else {
696 0.0
697 };
698 wide.max(pad.left + lead + label + keys + chevron + pad.right)
699 });
700 // Rounded up: half a pixel of a glyph past the edge is the whole of the bug.
701 Some(widest.max(WIDTH).ceil())
702 }
703}
704
705impl<Msg: Clone> Widget<Msg> for Panel<Msg> {
706 fn style(&self) -> Style {
707 self.sizing(None)
708 }
709
710 fn style_themed(&self, theme: &Theme) -> Style {
711 self.sizing(Some(theme))
712 }
713
714 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
715 &self.children
716 }
717
718 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
719 let o = status.opacity;
720 let shape = self.shape_of(theme);
721 let radius = shape
722 .as_rounded(bounds)
723 .map(|(_, r)| r)
724 .unwrap_or(BorderRadius::ZERO);
725 let depth = self.look.elevation(self.kind, theme);
726 let shadow = self.look.shadow_color(self.kind, theme);
727 // A height casts only in a colour that shows (milestone 529): a transparent
728 // shadow colour is the caller saying there is none, whatever the height.
729 if depth > 0.0 && shadow.a > 0.0 {
730 // The reference's shadows for this height (milestone 606).
731 frus_core::paint_elevation(scene, bounds, radius, depth, shadow.fade(o));
732 }
733 // Opaque, and no outline unless the theme names one: a panel that is off the page
734 // says so with its shadow. A desktop's menus draw a hairline as well
735 // (milestone 636).
736 let border = match self.kind {
737 PanelKind::Menu | PanelKind::Bar => {
738 self.look.border.or(menu_theme(theme, self.kind).border)
739 }
740 PanelKind::Dropdown => None,
741 };
742 let shape = match border {
743 Some(side) => {
744 shape.with_side(frus_core::BorderSide::new(side.color.fade(o), side.width))
745 }
746 None => shape,
747 };
748 scene.draw_shape(
749 bounds,
750 shape,
751 self.look.background(self.kind, theme).fade(o),
752 );
753 }
754
755 /// The panel itself answers nothing: the rows do.
756 fn on_click(&self) -> Option<Msg> {
757 None
758 }
759
760 /// It **traps** the press all the same. A menu is a surface, and a press on the room
761 /// above the first row, on the gap a rule leaves, or on a row that said it was
762 /// unavailable, must not reach the page behind and dismiss the menu.
763 ///
764 /// The comment above this used to claim `on_click` did that. It did not: a widget
765 /// with no message is not a target at all, so every one of those presses fell
766 /// through, and the menu closed. Per-row availability is what found it — before
767 /// that, every row in an open menu had a message and there was nothing to fall
768 /// through.
769 fn opaque(&self) -> bool {
770 true
771 }
772}
773
774/// **One entry of a [`PopupMenuButton`]'s list**: an action, or the rule between two
775/// groups of them.
776///
777/// The shorthands on the button itself — [`item`](PopupMenuButton::item),
778/// [`icon_item`](PopupMenuButton::icon_item),
779/// [`checked_item`](PopupMenuButton::checked_item),
780/// [`item_widget`](PopupMenuButton::item_widget) and
781/// [`divider`](PopupMenuButton::divider) — each build one of these, and are what most
782/// call sites should write. This type is the door to the two things a shorthand cannot
783/// carry: a row that is **not available** while the rest of the menu is, and the **keys**
784/// that work an action.
785///
786/// ```
787/// use frus_widgets::{Container, Icons, MenuItem, PopupMenuButton};
788///
789/// #[derive(Clone)]
790/// enum Msg {
791/// Close,
792/// Cut,
793/// Paste,
794/// Wrap,
795/// }
796///
797/// let menu = PopupMenuButton::new(Container::<Msg>::new(), true, Msg::Close)
798/// .entry(MenuItem::icon(Icons::CONTENT_CUT, "Cut", Msg::Cut).shortcut("Ctrl+X"))
799/// .entry(MenuItem::new("Paste", Msg::Paste).enabled(false))
800/// .divider()
801/// .checked_item("Word wrap", true, Msg::Wrap);
802/// ```
803pub struct MenuItem<Msg = crate::callback::Callback> {
804 label: Option<String>,
805 /// The caller's own drawing, shared so the panel can be built again.
806 child: Option<Rc<dyn Widget<Msg>>>,
807 lead: Option<Lead>,
808 shortcut: Option<String>,
809 enabled: bool,
810 /// **`None` is the rule** between two groups: the one entry that is not an action,
811 /// and so the one entry with nothing to send.
812 message: Option<Msg>,
813 /// Whether the row opens a submenu (milestone 603).
814 pub(crate) submenu: bool,
815 /// Whether that submenu is open (milestone 636).
816 pub(crate) expanded: bool,
817 /// The row's keyboard, in a menu bar (milestone 605).
818 pub(crate) keys: RowKeys<Msg>,
819}
820
821impl<Msg> MenuItem<Msg> {
822 /// An action with nothing on it yet.
823 fn action(message: Msg) -> Self {
824 Self {
825 label: None,
826 child: None,
827 lead: None,
828 shortcut: None,
829 enabled: true,
830 message: Some(message),
831 submenu: false,
832 expanded: false,
833 keys: RowKeys::default(),
834 }
835 }
836
837 /// A row of words: the ordinary item, and what
838 /// [`PopupMenuButton::item`] builds.
839 pub fn new(label: impl Into<String>, message: Msg) -> Self {
840 Self {
841 label: Some(label.into()),
842 ..Self::action(message)
843 }
844 }
845
846 /// A row **the caller draws**: two lines, a colour swatch, a badge, rich text —
847 /// whatever the menu is for.
848 ///
849 /// It is laid out in exactly the room a label would have had, columns and all, and it
850 /// **grows**: a row of words is a tap target tall, a row of anything else is at least
851 /// that and as much more as it needs.
852 pub fn widget(child: impl Widget<Msg> + 'static, message: Msg) -> Self {
853 Self {
854 child: Some(Rc::new(child)),
855 ..Self::action(message)
856 }
857 }
858
859 /// A row with a picture in the leading column.
860 pub fn icon(icon: IconData, label: impl Into<String>, message: Msg) -> Self {
861 Self {
862 lead: Some(Lead::Icon(icon)),
863 ..Self::new(label, message)
864 }
865 }
866
867 /// A row that is **on or off**, ticked when it is on.
868 ///
869 /// It still takes a message, and turning it over is still the application's job: this
870 /// says what the state *is*, not what it will be.
871 pub fn checked(label: impl Into<String>, checked: bool, message: Msg) -> Self {
872 Self {
873 lead: Some(Lead::Check(checked)),
874 ..Self::new(label, message)
875 }
876 }
877
878 /// The **rule** between two groups of actions. It answers nothing, takes no focus,
879 /// and is not a row's height: sixteen, the room a separator needs to read as one.
880 pub fn divider() -> Self {
881 Self {
882 label: None,
883 child: None,
884 lead: None,
885 shortcut: None,
886 enabled: false,
887 message: None,
888 submenu: false,
889 expanded: false,
890 keys: RowKeys::default(),
891 }
892 }
893
894 /// Whether **this row** can be used, over the menu's own availability.
895 ///
896 /// A disabled row is still drawn and still read out — greyed, announced as disabled,
897 /// and returning no message. An action that is missing from the list altogether tells
898 /// a reader nothing about why.
899 #[must_use]
900 pub fn enabled(mut self, enabled: bool) -> Self {
901 self.enabled = enabled;
902 self
903 }
904
905 /// Whether the row is an action that can be used: not a rule, not disabled.
906 pub(crate) fn is_action(&self) -> bool {
907 self.message.is_some() && self.enabled
908 }
909
910 /// The same row again, for a menu built twice (milestone 603).
911 pub(crate) fn clone_row(&self) -> Self
912 where
913 Msg: Clone,
914 {
915 Self {
916 label: self.label.clone(),
917 child: self.child.clone(),
918 lead: self.lead,
919 shortcut: self.shortcut.clone(),
920 enabled: self.enabled,
921 message: self.message.clone(),
922 submenu: self.submenu,
923 expanded: self.expanded,
924 keys: self.keys.clone(),
925 }
926 }
927
928 /// The **keys** that work this action, shown muted on the right.
929 ///
930 /// The text is the caller's, not a key combination this framework parses: the
931 /// spelling differs by platform (`Ctrl+X`, `⌘X`) and nothing here knows which one an
932 /// application means. It is announced after the label, since a reader who cannot see
933 /// it has no other way to hear it.
934 #[must_use]
935 pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
936 self.shortcut = Some(keys.into());
937 self
938 }
939}
940
941/// A controlled action menu, opened and closed by the application.
942pub struct PopupMenuButton<Msg = crate::callback::Callback> {
943 open: bool,
944 enabled: bool,
945 /// `[anchor]`, or `[anchor, list]` when the menu is showing.
946 children: Vec<Box<dyn Widget<Msg>>>,
947 items: Vec<MenuItem<Msg>>,
948 text_style: Option<TextStyle>,
949 /// The panel's look, and the rows'. Every builder that writes one of these calls
950 /// [`rebuild`](Self::rebuild), so **the order they are written in does not matter**
951 /// — unlike [`BottomSheet`](crate::BottomSheet), where the panel is built by
952 /// `body` and anything said after it is dropped.
953 look: PanelStyle,
954 item_padding: Option<Insets>,
955 item_height: Option<f32>,
956 dismiss: Option<Msg>,
957 on_dismiss: Option<Msg>,
958}
959
960impl<Msg: Clone + 'static> PopupMenuButton<Msg> {
961 /// Creates a menu around an anchor. When `open`, the list floats above it;
962 /// `on_dismiss` is emitted on a click **outside** the menu.
963 pub fn new(anchor: impl Widget<Msg> + 'static, open: bool, on_dismiss: Msg) -> Self {
964 Self {
965 open,
966 enabled: true,
967 children: vec![Box::new(anchor)],
968 items: Vec::new(),
969 text_style: None,
970 look: PanelStyle::default(),
971 item_padding: None,
972 item_height: None,
973 dismiss: Some(on_dismiss.clone()),
974 on_dismiss: if open { Some(on_dismiss) } else { None },
975 }
976 }
977
978 /// The items' type, over the theme's and the reference's.
979 #[must_use]
980 pub fn text_style(mut self, style: TextStyle) -> Self {
981 self.text_style = Some(style);
982 self.rebuild();
983 self
984 }
985
986 /// **The panel's surface**, over the theme's and the reference's `surface_container`.
987 #[must_use]
988 pub fn background(mut self, color: Color) -> Self {
989 self.look.background = Some(color);
990 self.rebuild();
991 self
992 }
993
994 /// **What shape the panel is**, over the theme's and the framework's corner.
995 #[must_use]
996 pub fn shape(mut self, shape: ShapeBorder) -> Self {
997 self.look.shape = Some(shape);
998 self.rebuild();
999 self
1000 }
1001
1002 /// The shorthand for a rounded rectangle — `radius(4.0)` is the reference's own
1003 /// menu corner, which this framework does not use by default because it keeps **one**
1004 /// corner for the whole interface, in `Theme::radius`. Reaching past that for a single
1005 /// widget would make the menu the only thing on screen ignoring the number an
1006 /// application set.
1007 #[must_use]
1008 pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
1009 self.shape(ShapeBorder::rounded(radius.into()))
1010 }
1011
1012 /// **How far off the page the panel sits**, in pixels. Three by default.
1013 #[must_use]
1014 pub fn elevation(mut self, elevation: f32) -> Self {
1015 self.look.elevation = Some(elevation);
1016 self.rebuild();
1017 self
1018 }
1019
1020 /// The colour of the panel's shadow, over the theme's and the scheme's shadow at 30 %.
1021 /// It is used as given, alpha and all: [`Color::TRANSPARENT`] casts none.
1022 #[must_use]
1023 pub fn shadow_color(mut self, color: Color) -> Self {
1024 self.look.shadow_color = Some(color);
1025 self.rebuild();
1026 self
1027 }
1028
1029 /// The room kept **above and below** the rows, inside the panel.
1030 #[must_use]
1031 pub fn menu_padding(mut self, padding: Insets) -> Self {
1032 self.look.padding = Some(padding);
1033 self.rebuild();
1034 self
1035 }
1036
1037 /// The room kept either side of a row's label.
1038 #[must_use]
1039 pub fn item_padding(mut self, padding: Insets) -> Self {
1040 self.item_padding = Some(padding);
1041 self.rebuild();
1042 self
1043 }
1044
1045 /// **How tall one row is**, as a floor — a row still grows to fit the reader's
1046 /// type. The default is the smallest box a finger can be asked to hit.
1047 #[must_use]
1048 pub fn item_height(mut self, height: f32) -> Self {
1049 self.item_height = Some(height);
1050 self.rebuild();
1051 self
1052 }
1053
1054 /// Whether the menu can be used. Disabled it is **inert** and, like a disabled
1055 /// [`DropdownButton`](crate::DropdownButton), **never open**: a floating panel over an anchor that
1056 /// answers nothing traps a press and returns no message.
1057 ///
1058 /// See [`crate::disabled`] for the whole contract.
1059 pub fn enabled(mut self, enabled: bool) -> Self {
1060 self.enabled = enabled;
1061 self.on_dismiss = if self.open && enabled {
1062 self.dismiss.clone()
1063 } else {
1064 None
1065 };
1066 self.rebuild();
1067 self
1068 }
1069
1070 /// Adds an action: a label plus a message on click. Ignored when the menu is closed.
1071 pub fn item(self, label: impl Into<String>, message: Msg) -> Self {
1072 self.entry(MenuItem::new(label, message))
1073 }
1074
1075 /// Adds a row **the caller draws** — two lines, a swatch, a badge, rich text — plus
1076 /// the message it sends.
1077 pub fn item_widget(self, child: impl Widget<Msg> + 'static, message: Msg) -> Self {
1078 self.entry(MenuItem::widget(child, message))
1079 }
1080
1081 /// Adds an action with a picture in the leading column.
1082 pub fn icon_item(self, icon: IconData, label: impl Into<String>, message: Msg) -> Self {
1083 self.entry(MenuItem::icon(icon, label, message))
1084 }
1085
1086 /// Adds an action that is **on or off**, ticked when it is on.
1087 pub fn checked_item(self, label: impl Into<String>, checked: bool, message: Msg) -> Self {
1088 self.entry(MenuItem::checked(label, checked, message))
1089 }
1090
1091 /// Adds the **rule** between two groups of actions.
1092 pub fn divider(self) -> Self {
1093 self.entry(MenuItem::divider())
1094 }
1095
1096 /// Adds an entry however it was built — the door to [`MenuItem::enabled`] and
1097 /// [`MenuItem::shortcut`], which no shorthand carries. Ignored when the menu is
1098 /// closed.
1099 pub fn entry(mut self, item: MenuItem<Msg>) -> Self {
1100 if self.open {
1101 self.items.push(item);
1102 self.rebuild();
1103 }
1104 self
1105 }
1106
1107 /// (Re)builds the floating panel (child 1) from the items.
1108 ///
1109 /// A disabled menu keeps only its anchor, so there is no overlay to return and no
1110 /// panel to trap a press.
1111 fn rebuild(&mut self) {
1112 if !self.enabled {
1113 self.children.truncate(1);
1114 return;
1115 }
1116 let panel = menu_panel(
1117 &self.items,
1118 &RowLook {
1119 look: self.look,
1120 text_style: self.text_style,
1121 item_padding: self.item_padding,
1122 item_height: self.item_height,
1123 divider_height: None,
1124 enabled: self.enabled,
1125 kind: PanelKind::Menu,
1126 },
1127 &|_, row| row,
1128 );
1129 if self.children.len() > 1 {
1130 self.children[1] = panel;
1131 } else {
1132 self.children.push(panel);
1133 }
1134 }
1135}
1136
1137impl<Msg: Clone> Widget<Msg> for PopupMenuButton<Msg> {
1138 fn style(&self) -> Style {
1139 Style {
1140 flex_direction: FlexDirection::Column,
1141 ..Default::default()
1142 }
1143 }
1144
1145 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1146 &self.children
1147 }
1148
1149 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
1150
1151 fn on_click(&self) -> Option<Msg> {
1152 None
1153 }
1154
1155 fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
1156 self.children
1157 .get(1)
1158 .map(|panel| (panel.as_ref(), Placement::Below))
1159 }
1160
1161 fn overlay_dismiss(&self) -> Option<Msg> {
1162 self.on_dismiss.clone()
1163 }
1164
1165 fn overlay_traps_focus(&self) -> bool {
1166 // An open menu **traps** keyboard focus in its items — Escape or an outside
1167 // click closes it through `on_dismiss` — the keyboard pattern menus are expected
1168 // to follow.
1169 self.open
1170 }
1171}
1172
1173/// How the rows of a floating menu look, shared by everything that builds one.
1174#[derive(Clone, Copy)]
1175pub(crate) struct RowLook {
1176 pub(crate) look: PanelStyle,
1177 pub(crate) text_style: Option<TextStyle>,
1178 pub(crate) item_padding: Option<Insets>,
1179 pub(crate) item_height: Option<f32>,
1180 /// The caller's height for the rules between groups (milestone 636).
1181 pub(crate) divider_height: Option<f32>,
1182 pub(crate) enabled: bool,
1183 /// Whose theme the panel and its rows answer to.
1184 pub(crate) kind: PanelKind,
1185}
1186
1187/// The panel of a floating menu: its rows on one surface, as wide as its widest row.
1188/// `decorate` is handed each row with its entry's index, and may wrap it: a menu bar puts
1189/// the hover that opens a submenu there, and the submenu itself (milestone 603).
1190pub(crate) fn menu_panel<Msg: Clone + 'static>(
1191 items: &[MenuItem<Msg>],
1192 row: &RowLook,
1193 decorate: &dyn Fn(usize, Box<dyn Widget<Msg>>) -> Box<dyn Widget<Msg>>,
1194) -> Box<dyn Widget<Msg>> {
1195 // **Whether the menu keeps a leading column is decided once, for the menu.** The
1196 // marks line up down one column and a row with nothing to put there still keeps
1197 // the room — the alternative is labels that go ragged as things are turned on and
1198 // off, which is the version every desktop menu decided against.
1199 let lead_column = items.iter().any(|item| item.lead.is_some());
1200 // No gap. The rows are contiguous strips of one surface; the two-pixel gutter
1201 // this used to leave showed the page through the middle of the menu.
1202 let mut list = Flex::column();
1203 let mut rows = Vec::with_capacity(items.len());
1204 for (index, item) in items.iter().enumerate() {
1205 rows.push(Measure {
1206 label: item.label.clone(),
1207 shortcut: item.shortcut.clone(),
1208 submenu: item.submenu,
1209 });
1210 let Some(message) = &item.message else {
1211 // A rule, and not a row: it is not a tap target tall, it takes no focus
1212 // and it answers nothing. As tall as the theme says (milestone 636).
1213 list = list.child(Rule {
1214 kind: row.kind,
1215 height: row.divider_height,
1216 });
1217 continue;
1218 };
1219 let built: Box<dyn Widget<Msg>> = Box::new(Item {
1220 label: item.label.clone(),
1221 children: item
1222 .child
1223 .clone()
1224 .map(|inner| Box::new(Shared::new(inner)) as Box<dyn Widget<Msg>>)
1225 .into_iter()
1226 .collect(),
1227 lead: item.lead,
1228 lead_column,
1229 shortcut: item.shortcut.clone(),
1230 submenu: item.submenu,
1231 expanded: item.expanded,
1232 keys: item.keys.clone(),
1233 enabled: row.enabled && item.enabled,
1234 text_style: row.text_style,
1235 background: row.look.background,
1236 padding: row.item_padding,
1237 height: row.item_height,
1238 kind: row.kind,
1239 message: message.clone(),
1240 });
1241 list = list.child(decorate(index, built));
1242 }
1243 Box::new(Panel {
1244 measure: Some(RowMeasure {
1245 rows,
1246 lead_column,
1247 text_style: row.text_style,
1248 item_padding: row.item_padding,
1249 }),
1250 ..Panel::new(row.kind, row.look, vec![Box::new(list)])
1251 })
1252}
1253
1254/// Reading a menu's panel back out of a frame — shared by the four widgets that float on
1255/// one, so each asserts the same thing the same way.
1256#[cfg(test)]
1257pub(crate) mod probe {
1258 use frus_core::{BorderRadius, Color, Primitive, Rect, Scene};
1259
1260 /// One rectangle as it was painted.
1261 #[derive(Clone, Copy, Debug, PartialEq)]
1262 pub(crate) struct Painted {
1263 pub(crate) rect: Rect,
1264 pub(crate) color: Color,
1265 pub(crate) radius: BorderRadius,
1266 pub(crate) border_width: f32,
1267 pub(crate) blur: f32,
1268 /// The rectangle nothing of it is drawn outside — a scroll viewport's, inside one.
1269 pub(crate) clip: Rect,
1270 }
1271
1272 /// The height the panel's shadows are cast from (milestone 606).
1273 pub(crate) fn height_of(scene: &Scene) -> f32 {
1274 crate::shadowprobe::height(scene.primitives())
1275 }
1276
1277 /// The colour they are cast in, at full strength.
1278 pub(crate) fn colour_of(scene: &Scene) -> Option<Color> {
1279 crate::shadowprobe::colour(scene.primitives())
1280 }
1281
1282 /// Every rectangle in the frame, in paint order, innermost layers included.
1283 fn painted(scene: &Scene) -> Vec<Painted> {
1284 fn walk(primitives: &[Primitive], out: &mut Vec<Painted>) {
1285 for p in primitives {
1286 match p {
1287 Primitive::Rect {
1288 rect,
1289 color,
1290 radius,
1291 border_width,
1292 blur,
1293 clip,
1294 ..
1295 } => out.push(Painted {
1296 rect: *rect,
1297 color: *color,
1298 radius: *radius,
1299 border_width: *border_width,
1300 blur: *blur,
1301 clip: *clip,
1302 }),
1303 Primitive::Layer { primitives, .. } => walk(primitives, out),
1304 _ => {}
1305 }
1306 }
1307 }
1308 let mut out = Vec::new();
1309 walk(scene.primitives(), &mut out);
1310 out
1311 }
1312
1313 /// The crisp rectangles: surfaces, highlights, outlines.
1314 pub(crate) fn crisp(scene: &Scene) -> Vec<Painted> {
1315 painted(scene)
1316 .into_iter()
1317 .filter(|r| r.blur == 0.0)
1318 .collect()
1319 }
1320
1321 /// The blurred ones: shadows.
1322 pub(crate) fn blurred(scene: &Scene) -> Vec<Painted> {
1323 painted(scene)
1324 .into_iter()
1325 .filter(|r| r.blur > 0.0)
1326 .collect()
1327 }
1328}
1329
1330#[cfg(test)]
1331mod tests {
1332 use super::probe::{blurred, colour_of, height_of};
1333 use super::*;
1334 use crate::{build_ui, Container, Point as P, Runtime, Size};
1335
1336 #[derive(Clone, Debug, PartialEq)]
1337 enum Msg {
1338 Close,
1339 A,
1340 B,
1341 }
1342
1343 fn anchor() -> Container<Msg> {
1344 Container::<Msg>::new().width(40.0).height(30.0)
1345 }
1346
1347 /// Every filled rectangle in the frame, innermost layers included, as
1348 /// `(rect, colour, radius, border width)`.
1349 fn rects(scene: &frus_core::Scene) -> Vec<(Rect, frus_core::Color, BorderRadius, f32)> {
1350 fn walk(
1351 primitives: &[frus_core::Primitive],
1352 out: &mut Vec<(Rect, frus_core::Color, BorderRadius, f32)>,
1353 ) {
1354 for p in primitives {
1355 match p {
1356 frus_core::Primitive::Rect {
1357 rect,
1358 color,
1359 radius,
1360 border_width,
1361 ..
1362 } => out.push((*rect, *color, *radius, *border_width)),
1363 frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
1364 _ => {}
1365 }
1366 }
1367 }
1368 let mut out = Vec::new();
1369 walk(scene.primitives(), &mut out);
1370 out
1371 }
1372
1373 fn open_menu() -> PopupMenuButton<Msg> {
1374 PopupMenuButton::new(anchor(), true, Msg::Close)
1375 .item("A", Msg::A)
1376 .item("B", Msg::B)
1377 }
1378
1379 fn frame(menu: &PopupMenuButton<Msg>, theme: &Theme) -> crate::Ui<Msg> {
1380 build_ui(menu, Size::new(400.0, 400.0), &Runtime::default(), theme)
1381 }
1382
1383 /// **A menu is one panel, not a stack of buttons.**
1384 ///
1385 /// It used to draw a filled, outlined, rounded rectangle **per row**, with a
1386 /// two-pixel gutter between them showing the page through the middle of the menu.
1387 /// That is not what a menu looks like anywhere, and the reference draws one surface
1388 /// with rows inside it (`popup_menu.dart:1837`).
1389 ///
1390 /// So: one opaque rectangle the width of the menu, in `surface_container`, and
1391 /// **not one border anywhere** — a panel that is off the page says so with its
1392 /// shadow, and a shadow with a hairline is the mash-up milestone 279 took out of the
1393 /// card.
1394 #[test]
1395 fn a_menu_is_one_panel_and_not_a_stack_of_buttons() {
1396 let theme = Theme::default();
1397 let ui = frame(&open_menu(), &theme);
1398 let painted = rects(ui.scene());
1399
1400 let panels: Vec<_> = painted
1401 .iter()
1402 .filter(|(rect, color, ..)| {
1403 rect.width >= WIDTH && *color == theme.scheme.surface_container
1404 })
1405 .collect();
1406 assert_eq!(
1407 panels.len(),
1408 1,
1409 "one surface, not one per row: {painted:#?}"
1410 );
1411 assert!(
1412 panels[0].2 != BorderRadius::ZERO,
1413 "and it is rounded: {:?}",
1414 panels[0].2
1415 );
1416
1417 assert!(
1418 painted.iter().all(|(.., border)| *border == 0.0),
1419 "nothing in an open menu draws an outline: {painted:#?}"
1420 );
1421 }
1422
1423 /// **A row is at least a tap target.** It was 38 pixels tall — ten under the
1424 /// number the accessibility scanners on both mobile platforms check for, on a widget
1425 /// whose entire purpose is to be tapped. The reference gives a menu item
1426 /// `kMinInteractiveDimension` (`popup_menu.dart:279`).
1427 ///
1428 /// The panel is measured too: two rows of 48 plus 8 above and 8 below.
1429 #[test]
1430 fn a_row_is_at_least_a_tap_target() {
1431 let theme = Theme::default();
1432 let ui = frame(&open_menu(), &theme);
1433 let panel = ui
1434 .scene()
1435 .primitives()
1436 .iter()
1437 .find_map(|p| find_panel(p, &theme))
1438 .expect("a panel");
1439 assert!(
1440 panel.height >= 2.0 * crate::theme::MIN_TAP_TARGET + 2.0 * PAD_Y,
1441 "two rows of a tap target each, plus the panel's own room: {panel:?}"
1442 );
1443 }
1444
1445 fn find_panel(p: &frus_core::Primitive, theme: &Theme) -> Option<Rect> {
1446 match p {
1447 frus_core::Primitive::Rect { rect, color, .. }
1448 if rect.width >= WIDTH && *color == theme.scheme.surface_container =>
1449 {
1450 Some(*rect)
1451 }
1452 frus_core::Primitive::Layer { primitives, .. } => {
1453 primitives.iter().find_map(|p| find_panel(p, theme))
1454 }
1455 _ => None,
1456 }
1457 }
1458
1459 /// **The panel answers to a theme, and to its caller over the theme** — the
1460 /// surface, the corner, the room inside it and how tall a row is. None of these were
1461 /// reachable at all: a menu picked every one of them for itself.
1462 #[test]
1463 fn a_menu_answers_to_its_theme_and_to_its_caller() {
1464 let mut theme = Theme::default();
1465 theme.widgets.menu.background = Some(frus_core::Color::rgb(0.2, 0.4, 0.6));
1466 theme.widgets.menu.radius = Some(3.0);
1467 theme.widgets.menu.item_height = Some(60.0);
1468 theme.widgets.menu.padding = Some(Insets::new(20.0, 0.0, 20.0, 0.0));
1469
1470 let ui = frame(&open_menu(), &theme);
1471 let panel = rects(ui.scene())
1472 .into_iter()
1473 .find(|(rect, color, ..)| {
1474 rect.width >= WIDTH && *color == frus_core::Color::rgb(0.2, 0.4, 0.6)
1475 })
1476 .expect("the theme's surface");
1477 assert_eq!(panel.2, BorderRadius::uniform(3.0), "the theme's corner");
1478 assert_eq!(
1479 panel.0.height, 160.0,
1480 "two rows of 60 and 20 above and below"
1481 );
1482
1483 // The caller outranks it, all four.
1484 let told = open_menu()
1485 .background(frus_core::Color::rgb(0.9, 0.1, 0.1))
1486 .radius(9.0)
1487 .item_height(50.0)
1488 .menu_padding(Insets::ZERO);
1489 let ui = frame(&told, &theme);
1490 let panel = rects(ui.scene())
1491 .into_iter()
1492 .find(|(rect, color, ..)| {
1493 rect.width >= WIDTH && *color == frus_core::Color::rgb(0.9, 0.1, 0.1)
1494 })
1495 .expect("the caller's surface");
1496 assert_eq!(panel.2, BorderRadius::uniform(9.0));
1497 assert_eq!(panel.0.height, 100.0);
1498 }
1499
1500 /// **The order the builders are written in does not matter.** Every one of them
1501 /// rebuilds the panel, so `.item(..)` after `.background(..)` and `.background(..)`
1502 /// after `.item(..)` produce the same menu.
1503 ///
1504 /// This is the trap milestone 458 found in [`BottomSheet`](crate::BottomSheet), where
1505 /// the panel is built by `body` and anything said after it is silently dropped. It is
1506 /// worth a test here precisely because it is the kind of thing that is true when
1507 /// written and quietly stops being true later.
1508 #[test]
1509 fn the_builders_can_be_written_in_any_order() {
1510 let theme = Theme::default();
1511 let colour = frus_core::Color::rgb(0.9, 0.1, 0.1);
1512 let first = PopupMenuButton::new(anchor(), true, Msg::Close)
1513 .background(colour)
1514 .item("A", Msg::A)
1515 .item("B", Msg::B);
1516 let last = open_menu().background(colour);
1517 assert_eq!(
1518 rects(frame(&first, &theme).scene()),
1519 rects(frame(&last, &theme).scene())
1520 );
1521 }
1522
1523 /// Every run of text in the frame, as `(where it starts, what it says)`.
1524 fn texts(scene: &frus_core::Scene) -> Vec<(Point, String)> {
1525 fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<(Point, String)>) {
1526 for p in primitives {
1527 match p {
1528 frus_core::Primitive::Text { position, text, .. } => {
1529 out.push((*position, text.clone()))
1530 }
1531 frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
1532 _ => {}
1533 }
1534 }
1535 }
1536 let mut out = Vec::new();
1537 walk(scene.primitives(), &mut out);
1538 out
1539 }
1540
1541 /// Where a given run of text starts.
1542 fn text_at(ui: &crate::Ui<Msg>, words: &str) -> Point {
1543 texts(ui.scene())
1544 .into_iter()
1545 .find(|(_, text)| text == words)
1546 .unwrap_or_else(|| panic!("{words:?} is drawn"))
1547 .0
1548 }
1549
1550 /// The message a press at a point produces, if any.
1551 fn press(ui: &crate::Ui<Msg>, x: f32, y: f32) -> Option<Msg> {
1552 ui.hit(P::new(x, y)).and_then(|id| ui.msg_for(id))
1553 }
1554
1555 /// The middle of row `n`, in a menu whose anchor is 30 tall and whose rows are the
1556 /// default height.
1557 fn row_middle(n: f32) -> f32 {
1558 30.0 + PAD_Y + ROW_H * (n + 0.5)
1559 }
1560
1561 /// **The marks line up down one column**, and a row with nothing to put in it keeps
1562 /// the room all the same.
1563 ///
1564 /// The alternative — each row indenting itself only when it has a mark — is a menu
1565 /// whose labels move sideways as things are turned on and off, which is why no
1566 /// desktop menu has ever done it that way. It is decided once, for the menu, and a
1567 /// menu with no marks at all is not indented at all: every picture already in this
1568 /// repository is of one of those.
1569 #[test]
1570 fn the_marks_line_up_down_one_column() {
1571 let theme = Theme::default();
1572 let plain = frame(&open_menu(), &theme);
1573 let marked = frame(
1574 &PopupMenuButton::new(anchor(), true, Msg::Close)
1575 .checked_item("A", true, Msg::A)
1576 .item("B", Msg::B),
1577 &theme,
1578 );
1579
1580 let unmarked_row = text_at(&marked, "B").x;
1581 assert_eq!(
1582 text_at(&marked, "A").x,
1583 unmarked_row,
1584 "the ticked row and the row with nothing start in the same place"
1585 );
1586 assert_eq!(
1587 unmarked_row - text_at(&plain, "B").x,
1588 LEAD + LEAD_GAP,
1589 "and that place is the leading column further in than in a menu with no marks"
1590 );
1591 }
1592
1593 /// **A tick that is off still holds its place.** It draws nothing — there is one
1594 /// filled path in the menu when one of the two rows is on, and none when neither is
1595 /// — and the labels do not move between the two.
1596 #[test]
1597 fn a_tick_that_is_off_draws_nothing_and_moves_nothing() {
1598 let theme = Theme::default();
1599 let menu = |on: bool| {
1600 PopupMenuButton::new(anchor(), true, Msg::Close)
1601 .checked_item("A", on, Msg::A)
1602 .item("B", Msg::B)
1603 };
1604 let (off, on) = (frame(&menu(false), &theme), frame(&menu(true), &theme));
1605 assert_eq!(
1606 paths(off.scene()),
1607 0,
1608 "nothing is drawn for a tick that is off"
1609 );
1610 assert_eq!(paths(on.scene()), 1, "and one tick when it is on");
1611 assert_eq!(
1612 text_at(&off, "A").x,
1613 text_at(&on, "A").x,
1614 "the label does not move when the tick appears"
1615 );
1616 }
1617
1618 /// How many filled paths the frame holds — the marks, since a menu draws nothing else
1619 /// with one.
1620 fn paths(scene: &frus_core::Scene) -> usize {
1621 fn walk(primitives: &[frus_core::Primitive]) -> usize {
1622 primitives
1623 .iter()
1624 .map(|p| match p {
1625 frus_core::Primitive::Path { .. } => 1,
1626 frus_core::Primitive::Layer { primitives, .. } => walk(primitives),
1627 _ => 0,
1628 })
1629 .sum()
1630 }
1631 walk(scene.primitives())
1632 }
1633
1634 /// **A row can be a widget the caller drew**, laid out in exactly the room the words
1635 /// would have had — and it **grows**, which a row of words does not.
1636 #[test]
1637 fn a_row_can_be_a_widget_the_caller_drew() {
1638 let theme = Theme::default();
1639 let mark = frus_core::Color::rgb(0.9, 0.1, 0.1);
1640 let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1641 .item_widget(
1642 Container::<Msg>::new().width(60.0).height(80.0).color(mark),
1643 Msg::A,
1644 )
1645 .item("B", Msg::B);
1646 let ui = frame(&menu, &theme);
1647 let drawn = rects(ui.scene())
1648 .into_iter()
1649 .find(|(_, color, ..)| *color == mark)
1650 .expect("the caller's own widget is drawn");
1651 assert_eq!(
1652 drawn.0.x,
1653 text_at(&ui, "B").x,
1654 "it starts where a label would have started"
1655 );
1656 assert_eq!(
1657 drawn.0.height, 80.0,
1658 "and it is given the height it asked for"
1659 );
1660
1661 // The row grew with it: the panel is the tall row, the ordinary row, and its own
1662 // room above and below.
1663 let panel = ui
1664 .scene()
1665 .primitives()
1666 .iter()
1667 .find_map(|p| find_panel(p, &theme))
1668 .expect("a panel");
1669 assert_eq!(panel.height, 80.0 + ROW_H + 2.0 * PAD_Y);
1670 }
1671
1672 /// **A rule is not a row.** Sixteen tall rather than a tap target, no message, and no
1673 /// place in the tab order — a separator that could be focused is a separator that
1674 /// swallows a keystroke.
1675 #[test]
1676 fn a_rule_is_not_a_row() {
1677 let theme = Theme::default();
1678 let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1679 .item("A", Msg::A)
1680 .divider()
1681 .item("B", Msg::B);
1682 let ui = frame(&menu, &theme);
1683 let panel = ui
1684 .scene()
1685 .primitives()
1686 .iter()
1687 .find_map(|p| find_panel(p, &theme))
1688 .expect("a panel");
1689 assert_eq!(
1690 panel.height,
1691 2.0 * ROW_H + crate::divider::DIVIDER_SPACE + 2.0 * PAD_Y,
1692 "two rows, a rule of sixteen, and the panel's own room"
1693 );
1694 assert_eq!(
1695 press(&ui, 110.0, row_middle(0.0)),
1696 Some(Msg::A),
1697 "the row above it still answers"
1698 );
1699 }
1700
1701 /// **One row can be unavailable while the rest of the menu is not.** It was the whole
1702 /// menu or nothing, so an application with one action it could not offer had to leave
1703 /// it out — and an action that is missing tells a reader nothing about why.
1704 ///
1705 /// It is still drawn, in the greyed ink, and still announced.
1706 #[test]
1707 fn one_row_can_be_unavailable_while_the_rest_is_not() {
1708 let theme = Theme::default();
1709 let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1710 .item("A", Msg::A)
1711 .entry(MenuItem::new("B", Msg::B).enabled(false));
1712 let ui = frame(&menu, &theme);
1713 assert_eq!(press(&ui, 110.0, row_middle(0.0)), Some(Msg::A));
1714 assert_eq!(
1715 press(&ui, 110.0, row_middle(1.0)),
1716 None,
1717 "the row that said it was unavailable answers nothing"
1718 );
1719 let greyed = texts(ui.scene()).iter().any(|(_, text)| text == "B");
1720 assert!(greyed, "and is still drawn rather than left out");
1721 }
1722
1723 /// **The keys are drawn on the right, muted, and read out after the label.** A
1724 /// shortcut is the only thing on a row that a reader who cannot see it has no other
1725 /// way to learn.
1726 #[test]
1727 fn the_keys_are_drawn_on_the_right_and_announced() {
1728 let theme = Theme::default();
1729 let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1730 .entry(MenuItem::new("Paste", Msg::A).shortcut("Ctrl+V"));
1731 let ui = frame(&menu, &theme);
1732 let keys = text_at(&ui, "Ctrl+V");
1733 assert!(
1734 keys.x > text_at(&ui, "Paste").x,
1735 "the keys are past the label, not before it"
1736 );
1737
1738 let row = Item {
1739 label: Some("Paste".into()),
1740 children: Vec::new(),
1741 lead: None,
1742 lead_column: false,
1743 shortcut: Some("Ctrl+V".into()),
1744 submenu: false,
1745 expanded: false,
1746 keys: RowKeys::default(),
1747 enabled: true,
1748 text_style: None,
1749 background: None,
1750 padding: None,
1751 height: None,
1752 kind: PanelKind::Menu,
1753 message: Msg::A,
1754 };
1755 assert_eq!(
1756 Widget::<Msg>::semantics(&row)
1757 .expect("announced")
1758 .label
1759 .as_deref(),
1760 Some("Paste, Ctrl+V")
1761 );
1762 }
1763
1764 /// **A row that is on or off is announced as one**, with its state — there is no
1765 /// `menuitemcheckbox` in this framework's vocabulary and a checkbox is the role that
1766 /// carries the one thing such a row has to say.
1767 #[test]
1768 fn a_row_that_is_on_or_off_is_announced_as_one() {
1769 let row = |on: bool| Item {
1770 label: Some("Word wrap".into()),
1771 children: Vec::new(),
1772 lead: Some(Lead::Check(on)),
1773 lead_column: true,
1774 shortcut: None,
1775 submenu: false,
1776 expanded: false,
1777 keys: RowKeys::default(),
1778 enabled: true,
1779 text_style: None,
1780 background: None,
1781 padding: None,
1782 height: None,
1783 kind: PanelKind::Menu,
1784 message: Msg::A,
1785 };
1786 let on = Widget::<Msg>::semantics(&row(true)).expect("announced");
1787 assert_eq!(on.role, frus_core::Role::CheckBox);
1788 assert_eq!(on.toggled, frus_core::Toggled::True);
1789 assert!(on.clickable, "and it is still an action");
1790 assert_eq!(
1791 Widget::<Msg>::semantics(&row(false))
1792 .expect("announced")
1793 .toggled,
1794 frus_core::Toggled::False,
1795 "off is a state, not the absence of one"
1796 );
1797 }
1798
1799 /// **The menu grows to its widest row**, and does not shrink below the width it has
1800 /// always had.
1801 ///
1802 /// The width was a bare constant. That is fine for a list of one-word actions and
1803 /// wrong the moment a row carries a mark, a label and the keys that work it: what it
1804 /// does then is overlap, which no assertion about the tree catches. Two hundred and
1805 /// twenty is now a floor, which is why no picture in this repository moved.
1806 #[test]
1807 fn the_menu_grows_to_its_widest_row_and_never_shrinks() {
1808 let theme = Theme::default();
1809 let width = |menu: &PopupMenuButton<Msg>| {
1810 frame(menu, &theme)
1811 .scene()
1812 .primitives()
1813 .iter()
1814 .find_map(|p| find_panel(p, &theme))
1815 .expect("a panel")
1816 .width
1817 };
1818 assert_eq!(
1819 width(&open_menu()),
1820 WIDTH,
1821 "short labels keep the old width"
1822 );
1823 let long = PopupMenuButton::new(anchor(), true, Msg::Close)
1824 .item("Duplicate this record and everything under it", Msg::A);
1825 assert!(
1826 width(&long) > WIDTH,
1827 "a label that does not fit widens the menu instead of running off it"
1828 );
1829 }
1830
1831 /// **A press on the menu's own surface does not close it**, and outside it still
1832 /// does.
1833 ///
1834 /// The room above the first row, the gap a rule leaves, a row that said it was
1835 /// unavailable — none of those is a target, and each of them used to fall through to
1836 /// the window-wide region whose press dismisses the menu. The comment above
1837 /// `Panel::on_click` claimed otherwise for three hundred milestones: a widget with no
1838 /// message is not registered at all, so there was nothing there to stop anything.
1839 ///
1840 /// Verified twice over. Taking `Panel::opaque` back out fails this and the test for a
1841 /// row that is unavailable, and nothing else in 1 356; so does collapsing
1842 /// `WidgetId::barrier` back to the identity it used to borrow, since a target the
1843 /// round trip through `msg_for` cannot tell from the barrier is no target at all.
1844 #[test]
1845 fn a_press_on_the_menus_own_surface_does_not_close_it() {
1846 let theme = Theme::default();
1847 let ui = frame(&open_menu(), &theme);
1848 assert_eq!(
1849 press(&ui, 110.0, 30.0 + PAD_Y * 0.5),
1850 None,
1851 "the panel's own room above the first row swallows the press"
1852 );
1853 assert_eq!(
1854 press(&ui, 390.0, 290.0),
1855 Some(Msg::Close),
1856 "and the page beyond it still closes the menu"
1857 );
1858 }
1859
1860 /// **The shadow's colour is the caller's to say**, then the theme's, then the scheme's
1861 /// shadow — and a transparent one casts nothing (milestone 529). The panel drew its
1862 /// shadow from its height alone, in a colour nobody could change. The colour is the
1863 /// shadow's at full strength; the reference's three shadows take their share of it
1864 /// (milestone 606).
1865 #[test]
1866 fn the_shadow_is_cast_in_a_colour_that_can_be_told() {
1867 let theme = Theme::default();
1868 let cast = |menu: &PopupMenuButton<Msg>, theme: &Theme| blurred(frame(menu, theme).scene());
1869 let colour =
1870 |menu: &PopupMenuButton<Msg>, theme: &Theme| colour_of(frame(menu, theme).scene());
1871
1872 let shadows = cast(&open_menu(), &theme);
1873 assert_eq!(shadows.len(), 3, "{shadows:#?}");
1874 assert_eq!(
1875 height_of(frame(&open_menu(), &theme).scene()),
1876 3.0,
1877 "three high"
1878 );
1879 assert_eq!(colour(&open_menu(), &theme), Some(theme.scheme.shadow));
1880
1881 let shade = frus_core::Color::rgba(0.0, 0.2, 0.6, 0.5);
1882 assert_eq!(
1883 colour(&open_menu().shadow_color(shade), &theme),
1884 Some(shade)
1885 );
1886 let mut themed = Theme::default();
1887 themed.widgets.menu.shadow_color = Some(shade);
1888 assert_eq!(colour(&open_menu(), &themed), Some(shade), "the theme's");
1889 let own = frus_core::Color::rgba(0.6, 0.0, 0.0, 0.25);
1890 assert_eq!(
1891 colour(&open_menu().shadow_color(own), &themed),
1892 Some(own),
1893 "the caller's over the theme's"
1894 );
1895
1896 assert!(cast(
1897 &open_menu().shadow_color(frus_core::Color::TRANSPARENT),
1898 &theme
1899 )
1900 .is_empty());
1901 themed.widgets.menu.shadow_color = Some(frus_core::Color::TRANSPARENT);
1902 assert!(cast(&open_menu(), &themed).is_empty());
1903 }
1904
1905 #[test]
1906 fn closed_has_no_overlay() {
1907 let menu = PopupMenuButton::new(anchor(), false, Msg::Close).item("A", Msg::A);
1908 assert!(Widget::<Msg>::overlay(&menu).is_none());
1909 assert_eq!(Widget::<Msg>::children(&menu).len(), 1);
1910 }
1911
1912 #[test]
1913 fn open_floats_items_and_dismisses_outside() {
1914 let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
1915 .item("A", Msg::A)
1916 .item("B", Msg::B);
1917 assert!(Widget::<Msg>::overlay(&menu).is_some());
1918 assert_eq!(Widget::<Msg>::overlay_dismiss(&menu), Some(Msg::Close));
1919
1920 let ui = build_ui(
1921 &menu,
1922 Size::new(400.0, 300.0),
1923 &Runtime::default(),
1924 &Theme::default(),
1925 );
1926 // A click far from the anchor (the bottom-right corner) closes the menu.
1927 let corner = ui.hit(P::new(390.0, 290.0)).expect("hit de fermeture");
1928 assert_eq!(ui.msg_for(corner), Some(Msg::Close));
1929 }
1930}