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