frus-widgets 0.4.0

frus — declarative widget tree
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
//! [`PopupMenuButton`]: a **floating** action menu — an anchor plus a list of items that opens
//! over it, through the overlay, and closes on an outside click.
//!
//! A row is a label and a message in the ordinary case, and [`MenuItem`] is what it is
//! when it is not: a picture in the leading column, a tick, the keys that work the action,
//! a row the caller drew, a rule between two groups, or a row that is unavailable while
//! the rest of the menu is not.
//!
//! **The leading column belongs to the menu.** If any row has a mark, every row keeps the
//! room for one, so the marks line up down a column and a tick that is off holds its place
//! rather than sliding its own label across. A menu with no marks at all is not indented at
//! all.

use std::rc::Rc;

use frus_core::{
    BorderRadius, Color, Insets, Point, Rect, ResolvedTextStyle, Scene, ShapeBorder, TextStyle,
};
use frus_layout::{Align, Dimension, FlexDirection, Style};

use crate::disabled::disabled_content;
use crate::divider::Divider;
use crate::flex::Flex;
use crate::icons::{IconData, Icons};
use crate::interaction::Status;
use crate::portal::Placement;
use crate::theme::Theme;
use crate::transparent::Shared;
use crate::widget::Widget;

const WIDTH: f32 = 220.0;
/// **One row's height** — the smallest box a control worked by a finger reserves for
/// it, which is what the reference gives a menu item (`popup_menu.dart:279`). It was 38,
/// ten pixels under the number the accessibility scanners on both mobile platforms check
/// for, on a widget whose entire purpose is to be tapped.
const ROW_H: f32 = crate::theme::MIN_TAP_TARGET;
/// The room either side of a row's label (`popup_menu.dart:1876`).
const PAD_X: f32 = 12.0;
/// The room above and below the rows, inside the panel (`popup_menu.dart:1872`). It is
/// twice the panel's own corner, which is why a row's highlight can never reach a curve
/// and the panel needs no clip.
const PAD_Y: f32 = 8.0;
/// How far off the page the panel sits: three, the reference's popup menu's. A menu
/// anchor's panel and a dropdown menu's stand at the same three: the reference's menu
/// defaults.
const ELEVATION: f32 = 3.0;
/// How far off the page a **dropdown button's** list sits: the reference's dropdown button
/// takes eight in its constructor, and its painter casts the shadow that height is given.
const DROPDOWN_ELEVATION: f32 = 8.0;
/// **The leading column**: the mark itself, and the room between it and the words.
/// Eighteen is the size of [`DropdownButton`](crate::DropdownButton)'s own tick — the
/// nearest control in the framework that puts a mark beside a label — and twelve is the
/// row's own padding again, so a marked row's words start two paddings in.
const LEAD: f32 = 18.0;
const LEAD_GAP: f32 = 12.0;
/// The least room between a label and the keys shown on its right. A shortcut close
/// enough to be read as the end of the label is worse than no shortcut.
const SHORTCUT_GAP: f32 = 24.0;

/// The panel's surface: the caller's word, then the theme's, then the reference's
/// — `surface_container`, a menu being a **distinct area within** the surface rather
/// than something floating above it in a colour of its own (`popup_menu.dart:1858`).
fn panel_background(own: Option<Color>, theme: &Theme, kind: PanelKind) -> Color {
    own.or(menu_theme(theme, kind).background)
        .unwrap_or(theme.scheme.surface_container)
}

/// **The menu theme a panel of `kind` answers to**: the application's [`MenuTheme`] for a
/// popup menu; for a menu bar's menus, the bar's own, then — on a desktop theme — a
/// desktop's menus, then the application's (milestone 636).
///
/// [`MenuTheme`]: crate::MenuTheme
pub(crate) fn menu_theme(theme: &Theme, kind: PanelKind) -> crate::MenuTheme {
    match kind {
        PanelKind::Bar => {
            let desktop = if theme.platform.is_desktop() {
                crate::MenuTheme::desktop(theme)
            } else {
                crate::MenuTheme::default()
            };
            theme
                .widgets
                .menu_bar
                .menus
                .or(desktop)
                .or(theme.widgets.menu)
        }
        _ => theme.widgets.menu,
    }
}

/// **Which theme a panel answers to** once its caller has said nothing.
///
/// Four widgets float a list on a panel, and they do not all read the same theme — nor
/// do the reference's. A popup menu, a menu anchor and a dropdown menu all resolve
/// through the menu theme to the menu defaults; a dropdown button reads neither and
/// stands higher.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PanelKind {
    /// [`PopupMenuButton`], [`MenuAnchor`](crate::MenuAnchor) and
    /// [`DropdownMenu`](crate::DropdownMenu): [`MenuTheme`](crate::MenuTheme), then three.
    Menu,
    /// [`DropdownButton`](crate::DropdownButton): the `menu_*` fields of
    /// [`DropdownTheme`](crate::DropdownTheme), then eight.
    Dropdown,
    /// A [`MenuBar`](crate::MenuBar)'s menus: the bar's menus, then a desktop's on a desktop
    /// theme, then [`MenuTheme`](crate::MenuTheme) — see [`menu_theme`].
    Bar,
}

/// **What a caller said about a menu's panel**, every word optional. The theme's, then
/// the framework's, fill in the rest — see [`PanelKind`] for whose.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct PanelStyle {
    pub(crate) background: Option<Color>,
    pub(crate) shape: Option<ShapeBorder>,
    pub(crate) elevation: Option<f32>,
    pub(crate) shadow_color: Option<Color>,
    pub(crate) padding: Option<Insets>,
    /// The panel's outline (milestone 636).
    pub(crate) border: Option<frus_core::BorderSide>,
}

impl PanelStyle {
    /// The surface: the caller's, the theme's, then `surface_container` for every kind.
    pub(crate) fn background(&self, kind: PanelKind, theme: &Theme) -> Color {
        match kind {
            PanelKind::Menu | PanelKind::Bar => panel_background(self.background, theme, kind),
            PanelKind::Dropdown => self
                .background
                .or(theme.widgets.dropdown.menu_background)
                .unwrap_or(theme.scheme.surface_container),
        }
    }

    /// The shape: the caller's, the theme's shape, the theme's radius, then the
    /// framework's one corner — see [`Panel::shape_of`] for why not the reference's.
    fn shape(&self, kind: PanelKind, theme: &Theme) -> ShapeBorder {
        let (shape, radius) = match kind {
            PanelKind::Menu | PanelKind::Bar => {
                let m = menu_theme(theme, kind);
                (m.shape, m.radius)
            }
            PanelKind::Dropdown => (
                theme.widgets.dropdown.menu_shape,
                theme.widgets.dropdown.menu_radius,
            ),
        };
        crate::resolve_shape(
            self.shape,
            shape,
            radius.map(BorderRadius::uniform),
            ShapeBorder::rounded(theme.radius),
        )
    }

    /// The height: the caller's, the theme's, then three for a menu and eight for a
    /// dropdown button.
    fn elevation(&self, kind: PanelKind, theme: &Theme) -> f32 {
        match kind {
            PanelKind::Menu | PanelKind::Bar => self
                .elevation
                .or(menu_theme(theme, kind).elevation)
                .unwrap_or(ELEVATION),
            PanelKind::Dropdown => self
                .elevation
                .or(theme.widgets.dropdown.menu_elevation)
                .unwrap_or(DROPDOWN_ELEVATION),
        }
    }

    /// The shadow's colour: the caller's, the theme's, then the scheme's shadow at 30 %,
    /// the framework's colour for a height. It is used **as given**, alpha and all, so a
    /// transparent one casts nothing (milestone 529).
    fn shadow_color(&self, kind: PanelKind, theme: &Theme) -> Color {
        let themed = match kind {
            PanelKind::Menu | PanelKind::Bar => menu_theme(theme, kind).shadow_color,
            PanelKind::Dropdown => theme.widgets.dropdown.menu_shadow_color,
        };
        self.shadow_color.or(themed).unwrap_or(theme.scheme.shadow)
    }

    /// The room above and below the rows: the caller's, the theme's, then eight — the
    /// reference's for a menu and for a dropdown button's list alike.
    fn padding(&self, kind: PanelKind, theme: Option<&Theme>) -> Insets {
        let themed = theme.and_then(|t| match kind {
            PanelKind::Menu | PanelKind::Bar => menu_theme(t, kind).padding,
            PanelKind::Dropdown => t.widgets.dropdown.menu_padding,
        });
        self.padding
            .or(themed)
            .unwrap_or(Insets::new(PAD_Y, 0.0, PAD_Y, 0.0))
    }
}

/// The style the items are drawn in: what the caller said, else what the theme says, else
/// the reference's — a popup menu's items are `labelLarge` (`popup_menu.dart:1849`).
///
/// **Resolved once**, so that the number the box is measured with is the number the glyphs
/// are drawn at. Resolving is the single place the reader's font setting is applied
/// (milestone 403); a size that never passes through it is a size the reader cannot change.
fn label_style(
    over: Option<TextStyle>,
    theme: Option<&Theme>,
    kind: PanelKind,
) -> ResolvedTextStyle {
    over.or(theme.and_then(|t| menu_theme(t, kind).text_style))
        .unwrap_or_else(|| crate::theme::type_scale(theme).label_large)
        .resolved()
}

/// What a row does with the keyboard in a menu bar (milestone 605): what the left and right
/// arrows send, and whether it takes the focus when its menu opens. A popup menu's rows do
/// none of it.
#[derive(Clone)]
pub(crate) struct RowKeys<Msg> {
    pub(crate) left: Option<Msg>,
    pub(crate) right: Option<Msg>,
    /// What Escape sends from the row: a menu bar's closes the row's own menu and no more.
    pub(crate) escape: Option<Msg>,
    pub(crate) autofocus: bool,
}

impl<Msg> Default for RowKeys<Msg> {
    fn default() -> Self {
        Self {
            left: None,
            right: None,
            escape: None,
            autofocus: false,
        }
    }
}

/// **What sits in a row's leading column**, when the row has anything to put there.
#[derive(Clone, Copy)]
enum Lead {
    /// A tick, drawn only when the row is on — and the column is kept either way, so
    /// turning something off does not slide its own label across.
    Check(bool),
    /// A picture of the caller's choosing.
    Icon(IconData),
}

/// What one row needs **across**, kept where the panel can reach it.
///
/// The panel is what decides how wide a menu is, so the panel has to be able to measure
/// the rows — and it cannot ask them. By the time it holds them they are `dyn Widget`,
/// and a widget cannot be asked how wide it would like to be
/// ([#52](https://github.com/KalybosPro/frus/issues/52)). So the words are copied to
/// where the measuring happens.
#[derive(Clone)]
struct Measure {
    /// The row's own words, or `None` for a rule and for a row the caller drew.
    label: Option<String>,
    shortcut: Option<String>,
    /// Whether the row opens a submenu, and so ends in a chevron (milestone 603).
    submenu: bool,
}

/// One menu action, a clickable row.
///
/// **Not a button.** It has no surface and no outline of its own: it is a strip of the
/// panel that lights up under a pointer, which is what a row in a list of actions is
/// everywhere it appears. It carries the panel's resolved colour down so its state layer
/// has the right thing to sit on.
struct Item<Msg> {
    /// The row's own words — `None` when the caller drew the row, in which case their
    /// widget is child 0.
    label: Option<String>,
    /// `[the caller's widget]`, or empty for a row of words.
    children: Vec<Box<dyn Widget<Msg>>>,
    /// This row's mark, if it has one.
    lead: Option<Lead>,
    /// Whether **the menu** keeps a leading column at all — a different question from
    /// whether this row has anything to put in it, and the whole reason the marks line up
    /// down a column instead of every row deciding for itself.
    lead_column: bool,
    /// The keys that work this action, shown on the right.
    shortcut: Option<String>,
    /// Whether this row opens a submenu: a chevron at the end (milestone 603).
    submenu: bool,
    /// Whether that submenu is open: the row keeps its highlight while the pointer is in
    /// the submenu, so the eye can find what the submenu belongs to (milestone 636).
    expanded: bool,
    /// What the left and right arrows send while this row has the focus, and whether it
    /// takes the focus when its menu opens (milestone 605).
    keys: RowKeys<Msg>,
    /// Whether this row can be used. The menu's own availability is folded in here, but
    /// only for tidiness: a disabled menu never opens, so there is no row to disable.
    enabled: bool,
    text_style: Option<TextStyle>,
    /// The caller's panel colour, so a row's highlight tints the surface it is drawn on
    /// and not the one the framework would have picked.
    background: Option<Color>,
    /// The caller's row padding and row height, if either was named.
    padding: Option<Insets>,
    height: Option<f32>,
    /// Whose theme the row answers to.
    kind: PanelKind,
    message: Msg,
}

impl<Msg> Item<Msg> {
    /// The room either side of the label: the caller's, then the theme's, then the
    /// reference's twelve.
    fn padding(&self, theme: Option<&Theme>) -> Insets {
        self.padding
            .or(theme.and_then(|t| menu_theme(t, self.kind).item_padding))
            .unwrap_or(Insets::new(0.0, PAD_X, 0.0, PAD_X))
    }

    /// What the leading column takes from the words — nought unless the menu keeps one.
    fn lead_room(&self) -> f32 {
        if self.lead_column {
            LEAD + LEAD_GAP
        } else {
            0.0
        }
    }

    /// The room kept clear on the right for the keys, their gap included.
    fn shortcut_room(&self, theme: Option<&Theme>) -> f32 {
        let keys = self.shortcut.as_deref().map_or(0.0, |keys| {
            SHORTCUT_GAP
                + frus_text::measure_resolved(keys, &label_style(self.text_style, theme, self.kind))
                    .width
        });
        keys + if self.submenu {
            SHORTCUT_GAP + LEAD
        } else {
            0.0
        }
    }

    fn sizing(&self, theme: Option<&Theme>) -> Style {
        let height = self
            .height
            .or(theme.and_then(|t| menu_theme(t, self.kind).item_height))
            .unwrap_or(ROW_H);
        // The row grows if the reader's type does not fit in it — the height is a
        // floor, not a promise.
        let line =
            frus_text::line_box(height, &label_style(self.text_style, theme, self.kind), 0.0);
        // **Across, a row says nothing.** The panel decides how wide the menu is and
        // stretches every row to it; a row that named its own width would leave the
        // highlights ragged the moment one label was longer than the others.
        if self.children.is_empty() {
            return Style {
                width: Dimension::Auto,
                height: Dimension::Length(line),
                ..Default::default()
            };
        }
        let pad = self.padding(theme);
        Style {
            width: Dimension::Auto,
            // A row the caller drew **grows**. A two-line item is half of why the form
            // exists at all, and a fixed height would cut it in two.
            height: Dimension::Auto,
            min_height: Dimension::Length(line),
            flex_direction: FlexDirection::Row,
            align: Align::Center,
            // The columns are kept clear by padding rather than by empty siblings, so a
            // caller's widget is laid out in exactly the room the words would have had.
            padding: Insets::new(
                pad.top,
                pad.right + self.shortcut_room(theme),
                pad.bottom,
                pad.left + self.lead_room(),
            ),
            ..Default::default()
        }
    }
}

impl<Msg: Clone> Widget<Msg> for Item<Msg> {
    fn style(&self) -> Style {
        self.sizing(None)
    }

    fn style_themed(&self, theme: &Theme) -> Style {
        self.sizing(Some(theme))
    }

    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
        &self.children
    }

    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
        let o = status.opacity;
        // A row draws **nothing at rest**. The panel behind it is the surface; all this
        // adds is the state layer that says a pointer is over it. No state layer while
        // disabled: a hover tint promises that a press would do something.
        //
        // It used to draw a filled, outlined, rounded rectangle per row — which made a
        // menu a stack of buttons with two-pixel gutters showing the page through, where
        // the reference has one panel with rows inside it.
        let base = panel_background(self.background, theme, self.kind);
        if self.enabled {
            let mut status = status;
            if self.expanded {
                status.hover_progress = 1.0;
            }
            let tinted = theme.state_layer(base, theme.on_surface, &status);
            if tinted != base {
                scene.fill_rect(bounds, tinted.fade(o));
            }
        }
        let ink = if self.enabled {
            theme.on_surface
        } else {
            disabled_content(theme)
        };
        let style = label_style(self.text_style, Some(theme), self.kind);
        let pad = self.padding(Some(theme));
        // The mark, centred in its column. A tick that is off draws nothing and still
        // holds its place.
        let mark = match self.lead {
            Some(Lead::Check(on)) => on.then_some(Icons::CHECK),
            Some(Lead::Icon(icon)) => Some(icon),
            None => None,
        };
        if let Some(icon) = mark {
            let y = bounds.y + (bounds.height - LEAD) * 0.5;
            let path = icon.placed(LEAD, bounds.x + pad.left, y, theme.direction);
            scene.fill_path(&path, ink.fade(o));
        }
        let ty = bounds.y + (bounds.height - style.line_height()) * 0.5;
        if let Some(label) = &self.label {
            scene.text(
                Point::new(bounds.x + pad.left + self.lead_room(), ty),
                label.clone(),
                &style,
                ink.fade(o),
            );
        }
        // A row that opens a submenu ends in a chevron, pointing where it opens: the end
        // side, so the left in right-to-left (milestone 603).
        let chevron_room = if self.submenu {
            let x = bounds.x + bounds.width - pad.right - LEAD;
            let y = bounds.y + (bounds.height - LEAD) * 0.5;
            let path = Icons::CHEVRON_RIGHT.placed(LEAD, x, y, theme.direction);
            scene.fill_path(&path, ink.fade(o));
            SHORTCUT_GAP + LEAD
        } else {
            0.0
        };
        if let Some(keys) = &self.shortcut {
            let width = frus_text::measure_resolved(keys, &style).width + chevron_room;
            // **Muted, never the label's ink.** The keys are a reminder of another way
            // in, not a second thing to read.
            let tint = if self.enabled {
                theme.muted
            } else {
                disabled_content(theme)
            };
            scene.text(
                Point::new(bounds.x + bounds.width - pad.right - width, ty),
                keys.clone(),
                &style,
                tint.fade(o),
            );
        }
    }

    fn on_click(&self) -> Option<Msg> {
        if !self.enabled {
            return None;
        }
        Some(self.message.clone())
    }

    fn focusable(&self) -> bool {
        self.enabled
    }

    fn autofocus(&self) -> bool {
        self.enabled && self.keys.autofocus
    }

    fn on_key(&self, key: &crate::interaction::Key) -> crate::interaction::KeyResponse<Msg> {
        use crate::interaction::{Key, KeyResponse};
        // A row that cannot be used answers no key, as it answers no press.
        if !self.enabled {
            return KeyResponse::Ignored;
        }
        let sent = match key {
            Key::Left { .. } => self.keys.left.clone(),
            Key::Right { .. } => self.keys.right.clone(),
            Key::Escape => self.keys.escape.clone(),
            _ => None,
        };
        match sent {
            Some(message) => KeyResponse::Handled(Some(message)),
            None => KeyResponse::Ignored,
        }
    }

    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
        // A menu row said nothing to a reader before this.
        //
        // A row that is on or off is announced as a **checkbox**, with its state: there
        // is no `menuitemcheckbox` in this framework's vocabulary, and a checkbox is the
        // role that carries the one thing such a row has to say. A row the caller drew
        // announces nothing here and leaves that to whatever they drew.
        let (role, toggled) = match self.lead {
            Some(Lead::Check(on)) => (frus_core::Role::CheckBox, Some(on)),
            _ => (frus_core::Role::Button, None),
        };
        let mut spoken = self.label.clone().unwrap_or_default();
        if let Some(keys) = &self.shortcut {
            // The keys are on the row for someone who can see them, and the announcement
            // is the only other way anybody hears about them.
            if !spoken.is_empty() {
                spoken.push_str(", ");
            }
            spoken.push_str(keys);
        }
        let mut semantics = frus_core::SemanticsProperties::new(role).maybe_toggled(toggled);
        if !spoken.is_empty() {
            semantics = semantics.label(spoken);
        }
        Some(if self.enabled {
            semantics.clickable()
        } else {
            semantics.disabled(true)
        })
    }
}

/// A rule between a menu's rows: the application's divider, as tall as the menu's theme
/// says (milestone 636). Its own widget, not a divider built from the theme, because a
/// built child sits in a box of its own and a rule's width is the column's stretch.
struct Rule {
    kind: PanelKind,
    /// The caller's height, before the theme's.
    height: Option<f32>,
}

impl Rule {
    fn divider(&self, theme: &Theme) -> Divider {
        match self.height.or(menu_theme(theme, self.kind).divider_height) {
            Some(h) => Divider::new().height(h),
            None => Divider::new(),
        }
    }
}

impl<Msg> Widget<Msg> for Rule {
    fn style(&self) -> Style {
        Widget::<Msg>::style(&Divider::new())
    }

    fn style_themed(&self, theme: &Theme) -> Style {
        Widget::<Msg>::style_themed(&self.divider(theme), theme)
    }

    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
        &[]
    }

    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
        Widget::<Msg>::paint(&self.divider(theme), bounds, status, theme, scene);
    }

    fn on_click(&self) -> Option<Msg> {
        None
    }

    fn debug_name(&self) -> &'static str {
        "MenuRule"
    }
}

/// **The thing a menu actually is**: one surface, off the page, with the rows inside it.
///
/// This did not exist. `rebuild` handed the overlay a bare column of rows, so a menu was
/// a stack of outlined buttons with the page showing through two-pixel gutters — which
/// is not what a menu looks like anywhere, and is not what the reference draws
/// (`popup_menu.dart:1837`).
///
/// **Shared by every list that floats.** [`PopupMenuButton`] built it first;
/// [`MenuAnchor`](crate::MenuAnchor), [`DropdownButton`](crate::DropdownButton) and
/// [`DropdownMenu`](crate::DropdownMenu) float on it too since milestone 532, where each
/// had floated bare content or a column of separately outlined rows with nothing behind
/// them — and so nothing a shadow could be the shadow of. A third copy of the surface
/// would have been three panels drifting apart; this is one, told by [`PanelKind`] whose
/// theme it answers to.
///
/// **It does not clip.** A clip takes the clipping widget's own paint with it, and the
/// shadow is that paint. The room above and below the rows keeps a row's highlight off
/// the corners instead, and a list long enough to scroll clips in its own viewport,
/// inside that room.
pub(crate) struct Panel<Msg> {
    children: Vec<Box<dyn Widget<Msg>>>,
    kind: PanelKind,
    look: PanelStyle,
    /// The popup menu's rows, measured because the popup menu's panel is what decides
    /// its width. `None` for a panel as wide as what is inside it.
    measure: Option<RowMeasure>,
}

/// What a popup menu's panel measures its rows by — see [`Panel::row_width`].
struct RowMeasure {
    /// What each row needs across, in order — see [`Measure`]. The panel is what decides
    /// the menu's width, so this is where the words have to be.
    rows: Vec<Measure>,
    /// Whether any row has a mark, and so whether every row keeps room for one.
    lead_column: bool,
    text_style: Option<TextStyle>,
    item_padding: Option<Insets>,
}

impl<Msg> Panel<Msg> {
    /// A panel **as wide as what is inside it**, in the look `kind` resolves `look` to.
    pub(crate) fn new(
        kind: PanelKind,
        look: PanelStyle,
        children: Vec<Box<dyn Widget<Msg>>>,
    ) -> Self {
        Self {
            children,
            kind,
            look,
            measure: None,
        }
    }

    /// What shape the panel is: the caller's word, then the theme's shape, then the
    /// theme's plain radius, then the framework's own corner.
    ///
    /// The framework's is `theme.radius` and **not** the reference's four. The reference
    /// gives every component its own corner — four for a menu, twelve for a card,
    /// twenty-eight for a sheet — where this framework collapses them into one number an
    /// application sets once. Reaching into that collapse for one widget would make the
    /// menu the only thing on screen that ignores it; `MenuTheme::radius` is there for an
    /// application that wants the reference's number.
    fn shape_of(&self, theme: &Theme) -> ShapeBorder {
        self.look.shape(self.kind, theme)
    }

    /// The box: a column, the room above and below the rows, and — for a popup menu —
    /// the width its rows measure to.
    fn sizing(&self, theme: Option<&Theme>) -> Style {
        let padding = self.look.padding(self.kind, theme);
        Style {
            // The rows are the width they measure to; the panel is that plus whatever
            // room it was told to keep at its own edges.
            width: self.row_width(theme).map_or(Dimension::Auto, |rows| {
                Dimension::Length(rows + padding.left + padding.right)
            }),
            flex_direction: FlexDirection::Column,
            padding,
            ..Default::default()
        }
    }

    /// **How wide a row is**: two hundred and twenty, or the widest row when a row wants
    /// more than that.
    ///
    /// It was a bare constant. That is fine for a list of one-word actions and wrong the
    /// moment a row carries a mark, a label and the keys that work it — and the way it
    /// fails is by overlapping, which no assertion about the tree catches and only a
    /// picture shows. The width is a **floor** so that no menu already drawn moves: a
    /// hundred per cent of the menus in this repository still measure under it.
    ///
    /// A row the caller drew contributes nothing, because it cannot be asked what it
    /// wants ([#52](https://github.com/KalybosPro/frus/issues/52)). It takes whatever
    /// the words decided, and that is the single place in the menu where that gap shows.
    ///
    /// `None` for a panel that measures no rows — it is as wide as what is inside it.
    fn row_width(&self, theme: Option<&Theme>) -> Option<f32> {
        let measure = self.measure.as_ref()?;
        let style = label_style(measure.text_style, theme, self.kind);
        let pad = measure
            .item_padding
            .or(theme.and_then(|t| menu_theme(t, self.kind).item_padding))
            .unwrap_or(Insets::new(0.0, PAD_X, 0.0, PAD_X));
        let lead = if measure.lead_column {
            LEAD + LEAD_GAP
        } else {
            0.0
        };
        let widest = measure.rows.iter().fold(0.0f32, |wide, row| {
            let label = row
                .label
                .as_deref()
                .map_or(0.0, |t| frus_text::measure_resolved(t, &style).width);
            let keys = row.shortcut.as_deref().map_or(0.0, |t| {
                SHORTCUT_GAP + frus_text::measure_resolved(t, &style).width
            });
            let chevron = if row.submenu {
                SHORTCUT_GAP + LEAD
            } else {
                0.0
            };
            wide.max(pad.left + lead + label + keys + chevron + pad.right)
        });
        // Rounded up: half a pixel of a glyph past the edge is the whole of the bug.
        Some(widest.max(WIDTH).ceil())
    }
}

impl<Msg: Clone> Widget<Msg> for Panel<Msg> {
    fn style(&self) -> Style {
        self.sizing(None)
    }

    fn style_themed(&self, theme: &Theme) -> Style {
        self.sizing(Some(theme))
    }

    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
        &self.children
    }

    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
        let o = status.opacity;
        let shape = self.shape_of(theme);
        let radius = shape
            .as_rounded(bounds)
            .map(|(_, r)| r)
            .unwrap_or(BorderRadius::ZERO);
        let depth = self.look.elevation(self.kind, theme);
        let shadow = self.look.shadow_color(self.kind, theme);
        // A height casts only in a colour that shows (milestone 529): a transparent
        // shadow colour is the caller saying there is none, whatever the height.
        if depth > 0.0 && shadow.a > 0.0 {
            // The reference's shadows for this height (milestone 606).
            frus_core::paint_elevation(scene, bounds, radius, depth, shadow.fade(o));
        }
        // Opaque, and no outline unless the theme names one: a panel that is off the page
        // says so with its shadow. A desktop's menus draw a hairline as well
        // (milestone 636).
        let border = match self.kind {
            PanelKind::Menu | PanelKind::Bar => {
                self.look.border.or(menu_theme(theme, self.kind).border)
            }
            PanelKind::Dropdown => None,
        };
        let shape = match border {
            Some(side) => {
                shape.with_side(frus_core::BorderSide::new(side.color.fade(o), side.width))
            }
            None => shape,
        };
        scene.draw_shape(
            bounds,
            shape,
            self.look.background(self.kind, theme).fade(o),
        );
    }

    /// The panel itself answers nothing: the rows do.
    fn on_click(&self) -> Option<Msg> {
        None
    }

    /// It **traps** the press all the same. A menu is a surface, and a press on the room
    /// above the first row, on the gap a rule leaves, or on a row that said it was
    /// unavailable, must not reach the page behind and dismiss the menu.
    ///
    /// The comment above this used to claim `on_click` did that. It did not: a widget
    /// with no message is not a target at all, so every one of those presses fell
    /// through, and the menu closed. Per-row availability is what found it — before
    /// that, every row in an open menu had a message and there was nothing to fall
    /// through.
    fn opaque(&self) -> bool {
        true
    }
}

/// **One entry of a [`PopupMenuButton`]'s list**: an action, or the rule between two
/// groups of them.
///
/// The shorthands on the button itself — [`item`](PopupMenuButton::item),
/// [`icon_item`](PopupMenuButton::icon_item),
/// [`checked_item`](PopupMenuButton::checked_item),
/// [`item_widget`](PopupMenuButton::item_widget) and
/// [`divider`](PopupMenuButton::divider) — each build one of these, and are what most
/// call sites should write. This type is the door to the two things a shorthand cannot
/// carry: a row that is **not available** while the rest of the menu is, and the **keys**
/// that work an action.
///
/// ```
/// use frus_widgets::{Container, Icons, MenuItem, PopupMenuButton};
///
/// #[derive(Clone)]
/// enum Msg {
///     Close,
///     Cut,
///     Paste,
///     Wrap,
/// }
///
/// let menu = PopupMenuButton::new(Container::<Msg>::new(), true, Msg::Close)
///     .entry(MenuItem::icon(Icons::CONTENT_CUT, "Cut", Msg::Cut).shortcut("Ctrl+X"))
///     .entry(MenuItem::new("Paste", Msg::Paste).enabled(false))
///     .divider()
///     .checked_item("Word wrap", true, Msg::Wrap);
/// ```
pub struct MenuItem<Msg = crate::callback::Callback> {
    label: Option<String>,
    /// The caller's own drawing, shared so the panel can be built again.
    child: Option<Rc<dyn Widget<Msg>>>,
    lead: Option<Lead>,
    shortcut: Option<String>,
    enabled: bool,
    /// **`None` is the rule** between two groups: the one entry that is not an action,
    /// and so the one entry with nothing to send.
    message: Option<Msg>,
    /// Whether the row opens a submenu (milestone 603).
    pub(crate) submenu: bool,
    /// Whether that submenu is open (milestone 636).
    pub(crate) expanded: bool,
    /// The row's keyboard, in a menu bar (milestone 605).
    pub(crate) keys: RowKeys<Msg>,
}

impl<Msg> MenuItem<Msg> {
    /// An action with nothing on it yet.
    fn action(message: Msg) -> Self {
        Self {
            label: None,
            child: None,
            lead: None,
            shortcut: None,
            enabled: true,
            message: Some(message),
            submenu: false,
            expanded: false,
            keys: RowKeys::default(),
        }
    }

    /// A row of words: the ordinary item, and what
    /// [`PopupMenuButton::item`] builds.
    pub fn new(label: impl Into<String>, message: Msg) -> Self {
        Self {
            label: Some(label.into()),
            ..Self::action(message)
        }
    }

    /// A row **the caller draws**: two lines, a colour swatch, a badge, rich text —
    /// whatever the menu is for.
    ///
    /// It is laid out in exactly the room a label would have had, columns and all, and it
    /// **grows**: a row of words is a tap target tall, a row of anything else is at least
    /// that and as much more as it needs.
    pub fn widget(child: impl Widget<Msg> + 'static, message: Msg) -> Self {
        Self {
            child: Some(Rc::new(child)),
            ..Self::action(message)
        }
    }

    /// A row with a picture in the leading column.
    pub fn icon(icon: IconData, label: impl Into<String>, message: Msg) -> Self {
        Self {
            lead: Some(Lead::Icon(icon)),
            ..Self::new(label, message)
        }
    }

    /// A row that is **on or off**, ticked when it is on.
    ///
    /// It still takes a message, and turning it over is still the application's job: this
    /// says what the state *is*, not what it will be.
    pub fn checked(label: impl Into<String>, checked: bool, message: Msg) -> Self {
        Self {
            lead: Some(Lead::Check(checked)),
            ..Self::new(label, message)
        }
    }

    /// The **rule** between two groups of actions. It answers nothing, takes no focus,
    /// and is not a row's height: sixteen, the room a separator needs to read as one.
    pub fn divider() -> Self {
        Self {
            label: None,
            child: None,
            lead: None,
            shortcut: None,
            enabled: false,
            message: None,
            submenu: false,
            expanded: false,
            keys: RowKeys::default(),
        }
    }

    /// Whether **this row** can be used, over the menu's own availability.
    ///
    /// A disabled row is still drawn and still read out — greyed, announced as disabled,
    /// and returning no message. An action that is missing from the list altogether tells
    /// a reader nothing about why.
    #[must_use]
    pub fn enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self
    }

    /// Whether the row is an action that can be used: not a rule, not disabled.
    pub(crate) fn is_action(&self) -> bool {
        self.message.is_some() && self.enabled
    }

    /// The same row again, for a menu built twice (milestone 603).
    pub(crate) fn clone_row(&self) -> Self
    where
        Msg: Clone,
    {
        Self {
            label: self.label.clone(),
            child: self.child.clone(),
            lead: self.lead,
            shortcut: self.shortcut.clone(),
            enabled: self.enabled,
            message: self.message.clone(),
            submenu: self.submenu,
            expanded: self.expanded,
            keys: self.keys.clone(),
        }
    }

    /// The **keys** that work this action, shown muted on the right.
    ///
    /// The text is the caller's, not a key combination this framework parses: the
    /// spelling differs by platform (`Ctrl+X`, `⌘X`) and nothing here knows which one an
    /// application means. It is announced after the label, since a reader who cannot see
    /// it has no other way to hear it.
    #[must_use]
    pub fn shortcut(mut self, keys: impl Into<String>) -> Self {
        self.shortcut = Some(keys.into());
        self
    }
}

/// A controlled action menu, opened and closed by the application.
pub struct PopupMenuButton<Msg = crate::callback::Callback> {
    open: bool,
    enabled: bool,
    /// `[anchor]`, or `[anchor, list]` when the menu is showing.
    children: Vec<Box<dyn Widget<Msg>>>,
    items: Vec<MenuItem<Msg>>,
    text_style: Option<TextStyle>,
    /// The panel's look, and the rows'. Every builder that writes one of these calls
    /// [`rebuild`](Self::rebuild), so **the order they are written in does not matter**
    /// — unlike [`BottomSheet`](crate::BottomSheet), where the panel is built by
    /// `body` and anything said after it is dropped.
    look: PanelStyle,
    item_padding: Option<Insets>,
    item_height: Option<f32>,
    dismiss: Option<Msg>,
    on_dismiss: Option<Msg>,
}

impl<Msg: Clone + 'static> PopupMenuButton<Msg> {
    /// Creates a menu around an anchor. When `open`, the list floats above it;
    /// `on_dismiss` is emitted on a click **outside** the menu.
    pub fn new(anchor: impl Widget<Msg> + 'static, open: bool, on_dismiss: Msg) -> Self {
        Self {
            open,
            enabled: true,
            children: vec![Box::new(anchor)],
            items: Vec::new(),
            text_style: None,
            look: PanelStyle::default(),
            item_padding: None,
            item_height: None,
            dismiss: Some(on_dismiss.clone()),
            on_dismiss: if open { Some(on_dismiss) } else { None },
        }
    }

    /// The items' type, over the theme's and the reference's.
    #[must_use]
    pub fn text_style(mut self, style: TextStyle) -> Self {
        self.text_style = Some(style);
        self.rebuild();
        self
    }

    /// **The panel's surface**, over the theme's and the reference's `surface_container`.
    #[must_use]
    pub fn background(mut self, color: Color) -> Self {
        self.look.background = Some(color);
        self.rebuild();
        self
    }

    /// **What shape the panel is**, over the theme's and the framework's corner.
    #[must_use]
    pub fn shape(mut self, shape: ShapeBorder) -> Self {
        self.look.shape = Some(shape);
        self.rebuild();
        self
    }

    /// The shorthand for a rounded rectangle — `radius(4.0)` is the reference's own
    /// menu corner, which this framework does not use by default because it keeps **one**
    /// corner for the whole interface, in `Theme::radius`. Reaching past that for a single
    /// widget would make the menu the only thing on screen ignoring the number an
    /// application set.
    #[must_use]
    pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
        self.shape(ShapeBorder::rounded(radius.into()))
    }

    /// **How far off the page the panel sits**, in pixels. Three by default.
    #[must_use]
    pub fn elevation(mut self, elevation: f32) -> Self {
        self.look.elevation = Some(elevation);
        self.rebuild();
        self
    }

    /// The colour of the panel's shadow, over the theme's and the scheme's shadow at 30 %.
    /// It is used as given, alpha and all: [`Color::TRANSPARENT`] casts none.
    #[must_use]
    pub fn shadow_color(mut self, color: Color) -> Self {
        self.look.shadow_color = Some(color);
        self.rebuild();
        self
    }

    /// The room kept **above and below** the rows, inside the panel.
    #[must_use]
    pub fn menu_padding(mut self, padding: Insets) -> Self {
        self.look.padding = Some(padding);
        self.rebuild();
        self
    }

    /// The room kept either side of a row's label.
    #[must_use]
    pub fn item_padding(mut self, padding: Insets) -> Self {
        self.item_padding = Some(padding);
        self.rebuild();
        self
    }

    /// **How tall one row is**, as a floor — a row still grows to fit the reader's
    /// type. The default is the smallest box a finger can be asked to hit.
    #[must_use]
    pub fn item_height(mut self, height: f32) -> Self {
        self.item_height = Some(height);
        self.rebuild();
        self
    }

    /// Whether the menu can be used. Disabled it is **inert** and, like a disabled
    /// [`DropdownButton`](crate::DropdownButton), **never open**: a floating panel over an anchor that
    /// answers nothing traps a press and returns no message.
    ///
    /// See [`crate::disabled`] for the whole contract.
    pub fn enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self.on_dismiss = if self.open && enabled {
            self.dismiss.clone()
        } else {
            None
        };
        self.rebuild();
        self
    }

    /// Adds an action: a label plus a message on click. Ignored when the menu is closed.
    pub fn item(self, label: impl Into<String>, message: Msg) -> Self {
        self.entry(MenuItem::new(label, message))
    }

    /// Adds a row **the caller draws** — two lines, a swatch, a badge, rich text — plus
    /// the message it sends.
    pub fn item_widget(self, child: impl Widget<Msg> + 'static, message: Msg) -> Self {
        self.entry(MenuItem::widget(child, message))
    }

    /// Adds an action with a picture in the leading column.
    pub fn icon_item(self, icon: IconData, label: impl Into<String>, message: Msg) -> Self {
        self.entry(MenuItem::icon(icon, label, message))
    }

    /// Adds an action that is **on or off**, ticked when it is on.
    pub fn checked_item(self, label: impl Into<String>, checked: bool, message: Msg) -> Self {
        self.entry(MenuItem::checked(label, checked, message))
    }

    /// Adds the **rule** between two groups of actions.
    pub fn divider(self) -> Self {
        self.entry(MenuItem::divider())
    }

    /// Adds an entry however it was built — the door to [`MenuItem::enabled`] and
    /// [`MenuItem::shortcut`], which no shorthand carries. Ignored when the menu is
    /// closed.
    pub fn entry(mut self, item: MenuItem<Msg>) -> Self {
        if self.open {
            self.items.push(item);
            self.rebuild();
        }
        self
    }

    /// (Re)builds the floating panel (child 1) from the items.
    ///
    /// A disabled menu keeps only its anchor, so there is no overlay to return and no
    /// panel to trap a press.
    fn rebuild(&mut self) {
        if !self.enabled {
            self.children.truncate(1);
            return;
        }
        let panel = menu_panel(
            &self.items,
            &RowLook {
                look: self.look,
                text_style: self.text_style,
                item_padding: self.item_padding,
                item_height: self.item_height,
                divider_height: None,
                enabled: self.enabled,
                kind: PanelKind::Menu,
            },
            &|_, row| row,
        );
        if self.children.len() > 1 {
            self.children[1] = panel;
        } else {
            self.children.push(panel);
        }
    }
}

impl<Msg: Clone> Widget<Msg> for PopupMenuButton<Msg> {
    fn style(&self) -> Style {
        Style {
            flex_direction: FlexDirection::Column,
            ..Default::default()
        }
    }

    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
        &self.children
    }

    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}

    fn on_click(&self) -> Option<Msg> {
        None
    }

    fn overlay(&self) -> Option<(&dyn Widget<Msg>, Placement)> {
        self.children
            .get(1)
            .map(|panel| (panel.as_ref(), Placement::Below))
    }

    fn overlay_dismiss(&self) -> Option<Msg> {
        self.on_dismiss.clone()
    }

    fn overlay_traps_focus(&self) -> bool {
        // An open menu **traps** keyboard focus in its items — Escape or an outside
        // click closes it through `on_dismiss` — the keyboard pattern menus are expected
        // to follow.
        self.open
    }
}

/// How the rows of a floating menu look, shared by everything that builds one.
#[derive(Clone, Copy)]
pub(crate) struct RowLook {
    pub(crate) look: PanelStyle,
    pub(crate) text_style: Option<TextStyle>,
    pub(crate) item_padding: Option<Insets>,
    pub(crate) item_height: Option<f32>,
    /// The caller's height for the rules between groups (milestone 636).
    pub(crate) divider_height: Option<f32>,
    pub(crate) enabled: bool,
    /// Whose theme the panel and its rows answer to.
    pub(crate) kind: PanelKind,
}

/// The panel of a floating menu: its rows on one surface, as wide as its widest row.
/// `decorate` is handed each row with its entry's index, and may wrap it: a menu bar puts
/// the hover that opens a submenu there, and the submenu itself (milestone 603).
pub(crate) fn menu_panel<Msg: Clone + 'static>(
    items: &[MenuItem<Msg>],
    row: &RowLook,
    decorate: &dyn Fn(usize, Box<dyn Widget<Msg>>) -> Box<dyn Widget<Msg>>,
) -> Box<dyn Widget<Msg>> {
    // **Whether the menu keeps a leading column is decided once, for the menu.** The
    // marks line up down one column and a row with nothing to put there still keeps
    // the room — the alternative is labels that go ragged as things are turned on and
    // off, which is the version every desktop menu decided against.
    let lead_column = items.iter().any(|item| item.lead.is_some());
    // No gap. The rows are contiguous strips of one surface; the two-pixel gutter
    // this used to leave showed the page through the middle of the menu.
    let mut list = Flex::column();
    let mut rows = Vec::with_capacity(items.len());
    for (index, item) in items.iter().enumerate() {
        rows.push(Measure {
            label: item.label.clone(),
            shortcut: item.shortcut.clone(),
            submenu: item.submenu,
        });
        let Some(message) = &item.message else {
            // A rule, and not a row: it is not a tap target tall, it takes no focus
            // and it answers nothing. As tall as the theme says (milestone 636).
            list = list.child(Rule {
                kind: row.kind,
                height: row.divider_height,
            });
            continue;
        };
        let built: Box<dyn Widget<Msg>> = Box::new(Item {
            label: item.label.clone(),
            children: item
                .child
                .clone()
                .map(|inner| Box::new(Shared::new(inner)) as Box<dyn Widget<Msg>>)
                .into_iter()
                .collect(),
            lead: item.lead,
            lead_column,
            shortcut: item.shortcut.clone(),
            submenu: item.submenu,
            expanded: item.expanded,
            keys: item.keys.clone(),
            enabled: row.enabled && item.enabled,
            text_style: row.text_style,
            background: row.look.background,
            padding: row.item_padding,
            height: row.item_height,
            kind: row.kind,
            message: message.clone(),
        });
        list = list.child(decorate(index, built));
    }
    Box::new(Panel {
        measure: Some(RowMeasure {
            rows,
            lead_column,
            text_style: row.text_style,
            item_padding: row.item_padding,
        }),
        ..Panel::new(row.kind, row.look, vec![Box::new(list)])
    })
}

/// Reading a menu's panel back out of a frame — shared by the four widgets that float on
/// one, so each asserts the same thing the same way.
#[cfg(test)]
pub(crate) mod probe {
    use frus_core::{BorderRadius, Color, Primitive, Rect, Scene};

    /// One rectangle as it was painted.
    #[derive(Clone, Copy, Debug, PartialEq)]
    pub(crate) struct Painted {
        pub(crate) rect: Rect,
        pub(crate) color: Color,
        pub(crate) radius: BorderRadius,
        pub(crate) border_width: f32,
        pub(crate) blur: f32,
        /// The rectangle nothing of it is drawn outside — a scroll viewport's, inside one.
        pub(crate) clip: Rect,
    }

    /// The height the panel's shadows are cast from (milestone 606).
    pub(crate) fn height_of(scene: &Scene) -> f32 {
        crate::shadowprobe::height(scene.primitives())
    }

    /// The colour they are cast in, at full strength.
    pub(crate) fn colour_of(scene: &Scene) -> Option<Color> {
        crate::shadowprobe::colour(scene.primitives())
    }

    /// Every rectangle in the frame, in paint order, innermost layers included.
    fn painted(scene: &Scene) -> Vec<Painted> {
        fn walk(primitives: &[Primitive], out: &mut Vec<Painted>) {
            for p in primitives {
                match p {
                    Primitive::Rect {
                        rect,
                        color,
                        radius,
                        border_width,
                        blur,
                        clip,
                        ..
                    } => out.push(Painted {
                        rect: *rect,
                        color: *color,
                        radius: *radius,
                        border_width: *border_width,
                        blur: *blur,
                        clip: *clip,
                    }),
                    Primitive::Layer { primitives, .. } => walk(primitives, out),
                    _ => {}
                }
            }
        }
        let mut out = Vec::new();
        walk(scene.primitives(), &mut out);
        out
    }

    /// The crisp rectangles: surfaces, highlights, outlines.
    pub(crate) fn crisp(scene: &Scene) -> Vec<Painted> {
        painted(scene)
            .into_iter()
            .filter(|r| r.blur == 0.0)
            .collect()
    }

    /// The blurred ones: shadows.
    pub(crate) fn blurred(scene: &Scene) -> Vec<Painted> {
        painted(scene)
            .into_iter()
            .filter(|r| r.blur > 0.0)
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::probe::{blurred, colour_of, height_of};
    use super::*;
    use crate::{build_ui, Container, Point as P, Runtime, Size};

    #[derive(Clone, Debug, PartialEq)]
    enum Msg {
        Close,
        A,
        B,
    }

    fn anchor() -> Container<Msg> {
        Container::<Msg>::new().width(40.0).height(30.0)
    }

    /// Every filled rectangle in the frame, innermost layers included, as
    /// `(rect, colour, radius, border width)`.
    fn rects(scene: &frus_core::Scene) -> Vec<(Rect, frus_core::Color, BorderRadius, f32)> {
        fn walk(
            primitives: &[frus_core::Primitive],
            out: &mut Vec<(Rect, frus_core::Color, BorderRadius, f32)>,
        ) {
            for p in primitives {
                match p {
                    frus_core::Primitive::Rect {
                        rect,
                        color,
                        radius,
                        border_width,
                        ..
                    } => out.push((*rect, *color, *radius, *border_width)),
                    frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
                    _ => {}
                }
            }
        }
        let mut out = Vec::new();
        walk(scene.primitives(), &mut out);
        out
    }

    fn open_menu() -> PopupMenuButton<Msg> {
        PopupMenuButton::new(anchor(), true, Msg::Close)
            .item("A", Msg::A)
            .item("B", Msg::B)
    }

    fn frame(menu: &PopupMenuButton<Msg>, theme: &Theme) -> crate::Ui<Msg> {
        build_ui(menu, Size::new(400.0, 400.0), &Runtime::default(), theme)
    }

    /// **A menu is one panel, not a stack of buttons.**
    ///
    /// It used to draw a filled, outlined, rounded rectangle **per row**, with a
    /// two-pixel gutter between them showing the page through the middle of the menu.
    /// That is not what a menu looks like anywhere, and the reference draws one surface
    /// with rows inside it (`popup_menu.dart:1837`).
    ///
    /// So: one opaque rectangle the width of the menu, in `surface_container`, and
    /// **not one border anywhere** — a panel that is off the page says so with its
    /// shadow, and a shadow with a hairline is the mash-up milestone 279 took out of the
    /// card.
    #[test]
    fn a_menu_is_one_panel_and_not_a_stack_of_buttons() {
        let theme = Theme::default();
        let ui = frame(&open_menu(), &theme);
        let painted = rects(ui.scene());

        let panels: Vec<_> = painted
            .iter()
            .filter(|(rect, color, ..)| {
                rect.width >= WIDTH && *color == theme.scheme.surface_container
            })
            .collect();
        assert_eq!(
            panels.len(),
            1,
            "one surface, not one per row: {painted:#?}"
        );
        assert!(
            panels[0].2 != BorderRadius::ZERO,
            "and it is rounded: {:?}",
            panels[0].2
        );

        assert!(
            painted.iter().all(|(.., border)| *border == 0.0),
            "nothing in an open menu draws an outline: {painted:#?}"
        );
    }

    /// **A row is at least a tap target.** It was 38 pixels tall — ten under the
    /// number the accessibility scanners on both mobile platforms check for, on a widget
    /// whose entire purpose is to be tapped. The reference gives a menu item
    /// `kMinInteractiveDimension` (`popup_menu.dart:279`).
    ///
    /// The panel is measured too: two rows of 48 plus 8 above and 8 below.
    #[test]
    fn a_row_is_at_least_a_tap_target() {
        let theme = Theme::default();
        let ui = frame(&open_menu(), &theme);
        let panel = ui
            .scene()
            .primitives()
            .iter()
            .find_map(|p| find_panel(p, &theme))
            .expect("a panel");
        assert!(
            panel.height >= 2.0 * crate::theme::MIN_TAP_TARGET + 2.0 * PAD_Y,
            "two rows of a tap target each, plus the panel's own room: {panel:?}"
        );
    }

    fn find_panel(p: &frus_core::Primitive, theme: &Theme) -> Option<Rect> {
        match p {
            frus_core::Primitive::Rect { rect, color, .. }
                if rect.width >= WIDTH && *color == theme.scheme.surface_container =>
            {
                Some(*rect)
            }
            frus_core::Primitive::Layer { primitives, .. } => {
                primitives.iter().find_map(|p| find_panel(p, theme))
            }
            _ => None,
        }
    }

    /// **The panel answers to a theme, and to its caller over the theme** — the
    /// surface, the corner, the room inside it and how tall a row is. None of these were
    /// reachable at all: a menu picked every one of them for itself.
    #[test]
    fn a_menu_answers_to_its_theme_and_to_its_caller() {
        let mut theme = Theme::default();
        theme.widgets.menu.background = Some(frus_core::Color::rgb(0.2, 0.4, 0.6));
        theme.widgets.menu.radius = Some(3.0);
        theme.widgets.menu.item_height = Some(60.0);
        theme.widgets.menu.padding = Some(Insets::new(20.0, 0.0, 20.0, 0.0));

        let ui = frame(&open_menu(), &theme);
        let panel = rects(ui.scene())
            .into_iter()
            .find(|(rect, color, ..)| {
                rect.width >= WIDTH && *color == frus_core::Color::rgb(0.2, 0.4, 0.6)
            })
            .expect("the theme's surface");
        assert_eq!(panel.2, BorderRadius::uniform(3.0), "the theme's corner");
        assert_eq!(
            panel.0.height, 160.0,
            "two rows of 60 and 20 above and below"
        );

        // The caller outranks it, all four.
        let told = open_menu()
            .background(frus_core::Color::rgb(0.9, 0.1, 0.1))
            .radius(9.0)
            .item_height(50.0)
            .menu_padding(Insets::ZERO);
        let ui = frame(&told, &theme);
        let panel = rects(ui.scene())
            .into_iter()
            .find(|(rect, color, ..)| {
                rect.width >= WIDTH && *color == frus_core::Color::rgb(0.9, 0.1, 0.1)
            })
            .expect("the caller's surface");
        assert_eq!(panel.2, BorderRadius::uniform(9.0));
        assert_eq!(panel.0.height, 100.0);
    }

    /// **The order the builders are written in does not matter.** Every one of them
    /// rebuilds the panel, so `.item(..)` after `.background(..)` and `.background(..)`
    /// after `.item(..)` produce the same menu.
    ///
    /// This is the trap milestone 458 found in [`BottomSheet`](crate::BottomSheet), where
    /// the panel is built by `body` and anything said after it is silently dropped. It is
    /// worth a test here precisely because it is the kind of thing that is true when
    /// written and quietly stops being true later.
    #[test]
    fn the_builders_can_be_written_in_any_order() {
        let theme = Theme::default();
        let colour = frus_core::Color::rgb(0.9, 0.1, 0.1);
        let first = PopupMenuButton::new(anchor(), true, Msg::Close)
            .background(colour)
            .item("A", Msg::A)
            .item("B", Msg::B);
        let last = open_menu().background(colour);
        assert_eq!(
            rects(frame(&first, &theme).scene()),
            rects(frame(&last, &theme).scene())
        );
    }

    /// Every run of text in the frame, as `(where it starts, what it says)`.
    fn texts(scene: &frus_core::Scene) -> Vec<(Point, String)> {
        fn walk(primitives: &[frus_core::Primitive], out: &mut Vec<(Point, String)>) {
            for p in primitives {
                match p {
                    frus_core::Primitive::Text { position, text, .. } => {
                        out.push((*position, text.clone()))
                    }
                    frus_core::Primitive::Layer { primitives, .. } => walk(primitives, out),
                    _ => {}
                }
            }
        }
        let mut out = Vec::new();
        walk(scene.primitives(), &mut out);
        out
    }

    /// Where a given run of text starts.
    fn text_at(ui: &crate::Ui<Msg>, words: &str) -> Point {
        texts(ui.scene())
            .into_iter()
            .find(|(_, text)| text == words)
            .unwrap_or_else(|| panic!("{words:?} is drawn"))
            .0
    }

    /// The message a press at a point produces, if any.
    fn press(ui: &crate::Ui<Msg>, x: f32, y: f32) -> Option<Msg> {
        ui.hit(P::new(x, y)).and_then(|id| ui.msg_for(id))
    }

    /// The middle of row `n`, in a menu whose anchor is 30 tall and whose rows are the
    /// default height.
    fn row_middle(n: f32) -> f32 {
        30.0 + PAD_Y + ROW_H * (n + 0.5)
    }

    /// **The marks line up down one column**, and a row with nothing to put in it keeps
    /// the room all the same.
    ///
    /// The alternative — each row indenting itself only when it has a mark — is a menu
    /// whose labels move sideways as things are turned on and off, which is why no
    /// desktop menu has ever done it that way. It is decided once, for the menu, and a
    /// menu with no marks at all is not indented at all: every picture already in this
    /// repository is of one of those.
    #[test]
    fn the_marks_line_up_down_one_column() {
        let theme = Theme::default();
        let plain = frame(&open_menu(), &theme);
        let marked = frame(
            &PopupMenuButton::new(anchor(), true, Msg::Close)
                .checked_item("A", true, Msg::A)
                .item("B", Msg::B),
            &theme,
        );

        let unmarked_row = text_at(&marked, "B").x;
        assert_eq!(
            text_at(&marked, "A").x,
            unmarked_row,
            "the ticked row and the row with nothing start in the same place"
        );
        assert_eq!(
            unmarked_row - text_at(&plain, "B").x,
            LEAD + LEAD_GAP,
            "and that place is the leading column further in than in a menu with no marks"
        );
    }

    /// **A tick that is off still holds its place.** It draws nothing — there is one
    /// filled path in the menu when one of the two rows is on, and none when neither is
    /// — and the labels do not move between the two.
    #[test]
    fn a_tick_that_is_off_draws_nothing_and_moves_nothing() {
        let theme = Theme::default();
        let menu = |on: bool| {
            PopupMenuButton::new(anchor(), true, Msg::Close)
                .checked_item("A", on, Msg::A)
                .item("B", Msg::B)
        };
        let (off, on) = (frame(&menu(false), &theme), frame(&menu(true), &theme));
        assert_eq!(
            paths(off.scene()),
            0,
            "nothing is drawn for a tick that is off"
        );
        assert_eq!(paths(on.scene()), 1, "and one tick when it is on");
        assert_eq!(
            text_at(&off, "A").x,
            text_at(&on, "A").x,
            "the label does not move when the tick appears"
        );
    }

    /// How many filled paths the frame holds — the marks, since a menu draws nothing else
    /// with one.
    fn paths(scene: &frus_core::Scene) -> usize {
        fn walk(primitives: &[frus_core::Primitive]) -> usize {
            primitives
                .iter()
                .map(|p| match p {
                    frus_core::Primitive::Path { .. } => 1,
                    frus_core::Primitive::Layer { primitives, .. } => walk(primitives),
                    _ => 0,
                })
                .sum()
        }
        walk(scene.primitives())
    }

    /// **A row can be a widget the caller drew**, laid out in exactly the room the words
    /// would have had — and it **grows**, which a row of words does not.
    #[test]
    fn a_row_can_be_a_widget_the_caller_drew() {
        let theme = Theme::default();
        let mark = frus_core::Color::rgb(0.9, 0.1, 0.1);
        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
            .item_widget(
                Container::<Msg>::new().width(60.0).height(80.0).color(mark),
                Msg::A,
            )
            .item("B", Msg::B);
        let ui = frame(&menu, &theme);
        let drawn = rects(ui.scene())
            .into_iter()
            .find(|(_, color, ..)| *color == mark)
            .expect("the caller's own widget is drawn");
        assert_eq!(
            drawn.0.x,
            text_at(&ui, "B").x,
            "it starts where a label would have started"
        );
        assert_eq!(
            drawn.0.height, 80.0,
            "and it is given the height it asked for"
        );

        // The row grew with it: the panel is the tall row, the ordinary row, and its own
        // room above and below.
        let panel = ui
            .scene()
            .primitives()
            .iter()
            .find_map(|p| find_panel(p, &theme))
            .expect("a panel");
        assert_eq!(panel.height, 80.0 + ROW_H + 2.0 * PAD_Y);
    }

    /// **A rule is not a row.** Sixteen tall rather than a tap target, no message, and no
    /// place in the tab order — a separator that could be focused is a separator that
    /// swallows a keystroke.
    #[test]
    fn a_rule_is_not_a_row() {
        let theme = Theme::default();
        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
            .item("A", Msg::A)
            .divider()
            .item("B", Msg::B);
        let ui = frame(&menu, &theme);
        let panel = ui
            .scene()
            .primitives()
            .iter()
            .find_map(|p| find_panel(p, &theme))
            .expect("a panel");
        assert_eq!(
            panel.height,
            2.0 * ROW_H + crate::divider::DIVIDER_SPACE + 2.0 * PAD_Y,
            "two rows, a rule of sixteen, and the panel's own room"
        );
        assert_eq!(
            press(&ui, 110.0, row_middle(0.0)),
            Some(Msg::A),
            "the row above it still answers"
        );
    }

    /// **One row can be unavailable while the rest of the menu is not.** It was the whole
    /// menu or nothing, so an application with one action it could not offer had to leave
    /// it out — and an action that is missing tells a reader nothing about why.
    ///
    /// It is still drawn, in the greyed ink, and still announced.
    #[test]
    fn one_row_can_be_unavailable_while_the_rest_is_not() {
        let theme = Theme::default();
        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
            .item("A", Msg::A)
            .entry(MenuItem::new("B", Msg::B).enabled(false));
        let ui = frame(&menu, &theme);
        assert_eq!(press(&ui, 110.0, row_middle(0.0)), Some(Msg::A));
        assert_eq!(
            press(&ui, 110.0, row_middle(1.0)),
            None,
            "the row that said it was unavailable answers nothing"
        );
        let greyed = texts(ui.scene()).iter().any(|(_, text)| text == "B");
        assert!(greyed, "and is still drawn rather than left out");
    }

    /// **The keys are drawn on the right, muted, and read out after the label.** A
    /// shortcut is the only thing on a row that a reader who cannot see it has no other
    /// way to learn.
    #[test]
    fn the_keys_are_drawn_on_the_right_and_announced() {
        let theme = Theme::default();
        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
            .entry(MenuItem::new("Paste", Msg::A).shortcut("Ctrl+V"));
        let ui = frame(&menu, &theme);
        let keys = text_at(&ui, "Ctrl+V");
        assert!(
            keys.x > text_at(&ui, "Paste").x,
            "the keys are past the label, not before it"
        );

        let row = Item {
            label: Some("Paste".into()),
            children: Vec::new(),
            lead: None,
            lead_column: false,
            shortcut: Some("Ctrl+V".into()),
            submenu: false,
            expanded: false,
            keys: RowKeys::default(),
            enabled: true,
            text_style: None,
            background: None,
            padding: None,
            height: None,
            kind: PanelKind::Menu,
            message: Msg::A,
        };
        assert_eq!(
            Widget::<Msg>::semantics(&row)
                .expect("announced")
                .label
                .as_deref(),
            Some("Paste, Ctrl+V")
        );
    }

    /// **A row that is on or off is announced as one**, with its state — there is no
    /// `menuitemcheckbox` in this framework's vocabulary and a checkbox is the role that
    /// carries the one thing such a row has to say.
    #[test]
    fn a_row_that_is_on_or_off_is_announced_as_one() {
        let row = |on: bool| Item {
            label: Some("Word wrap".into()),
            children: Vec::new(),
            lead: Some(Lead::Check(on)),
            lead_column: true,
            shortcut: None,
            submenu: false,
            expanded: false,
            keys: RowKeys::default(),
            enabled: true,
            text_style: None,
            background: None,
            padding: None,
            height: None,
            kind: PanelKind::Menu,
            message: Msg::A,
        };
        let on = Widget::<Msg>::semantics(&row(true)).expect("announced");
        assert_eq!(on.role, frus_core::Role::CheckBox);
        assert_eq!(on.toggled, frus_core::Toggled::True);
        assert!(on.clickable, "and it is still an action");
        assert_eq!(
            Widget::<Msg>::semantics(&row(false))
                .expect("announced")
                .toggled,
            frus_core::Toggled::False,
            "off is a state, not the absence of one"
        );
    }

    /// **The menu grows to its widest row**, and does not shrink below the width it has
    /// always had.
    ///
    /// The width was a bare constant. That is fine for a list of one-word actions and
    /// wrong the moment a row carries a mark, a label and the keys that work it: what it
    /// does then is overlap, which no assertion about the tree catches. Two hundred and
    /// twenty is now a floor, which is why no picture in this repository moved.
    #[test]
    fn the_menu_grows_to_its_widest_row_and_never_shrinks() {
        let theme = Theme::default();
        let width = |menu: &PopupMenuButton<Msg>| {
            frame(menu, &theme)
                .scene()
                .primitives()
                .iter()
                .find_map(|p| find_panel(p, &theme))
                .expect("a panel")
                .width
        };
        assert_eq!(
            width(&open_menu()),
            WIDTH,
            "short labels keep the old width"
        );
        let long = PopupMenuButton::new(anchor(), true, Msg::Close)
            .item("Duplicate this record and everything under it", Msg::A);
        assert!(
            width(&long) > WIDTH,
            "a label that does not fit widens the menu instead of running off it"
        );
    }

    /// **A press on the menu's own surface does not close it**, and outside it still
    /// does.
    ///
    /// The room above the first row, the gap a rule leaves, a row that said it was
    /// unavailable — none of those is a target, and each of them used to fall through to
    /// the window-wide region whose press dismisses the menu. The comment above
    /// `Panel::on_click` claimed otherwise for three hundred milestones: a widget with no
    /// message is not registered at all, so there was nothing there to stop anything.
    ///
    /// Verified twice over. Taking `Panel::opaque` back out fails this and the test for a
    /// row that is unavailable, and nothing else in 1 356; so does collapsing
    /// `WidgetId::barrier` back to the identity it used to borrow, since a target the
    /// round trip through `msg_for` cannot tell from the barrier is no target at all.
    #[test]
    fn a_press_on_the_menus_own_surface_does_not_close_it() {
        let theme = Theme::default();
        let ui = frame(&open_menu(), &theme);
        assert_eq!(
            press(&ui, 110.0, 30.0 + PAD_Y * 0.5),
            None,
            "the panel's own room above the first row swallows the press"
        );
        assert_eq!(
            press(&ui, 390.0, 290.0),
            Some(Msg::Close),
            "and the page beyond it still closes the menu"
        );
    }

    /// **The shadow's colour is the caller's to say**, then the theme's, then the scheme's
    /// shadow — and a transparent one casts nothing (milestone 529). The panel drew its
    /// shadow from its height alone, in a colour nobody could change. The colour is the
    /// shadow's at full strength; the reference's three shadows take their share of it
    /// (milestone 606).
    #[test]
    fn the_shadow_is_cast_in_a_colour_that_can_be_told() {
        let theme = Theme::default();
        let cast = |menu: &PopupMenuButton<Msg>, theme: &Theme| blurred(frame(menu, theme).scene());
        let colour =
            |menu: &PopupMenuButton<Msg>, theme: &Theme| colour_of(frame(menu, theme).scene());

        let shadows = cast(&open_menu(), &theme);
        assert_eq!(shadows.len(), 3, "{shadows:#?}");
        assert_eq!(
            height_of(frame(&open_menu(), &theme).scene()),
            3.0,
            "three high"
        );
        assert_eq!(colour(&open_menu(), &theme), Some(theme.scheme.shadow));

        let shade = frus_core::Color::rgba(0.0, 0.2, 0.6, 0.5);
        assert_eq!(
            colour(&open_menu().shadow_color(shade), &theme),
            Some(shade)
        );
        let mut themed = Theme::default();
        themed.widgets.menu.shadow_color = Some(shade);
        assert_eq!(colour(&open_menu(), &themed), Some(shade), "the theme's");
        let own = frus_core::Color::rgba(0.6, 0.0, 0.0, 0.25);
        assert_eq!(
            colour(&open_menu().shadow_color(own), &themed),
            Some(own),
            "the caller's over the theme's"
        );

        assert!(cast(
            &open_menu().shadow_color(frus_core::Color::TRANSPARENT),
            &theme
        )
        .is_empty());
        themed.widgets.menu.shadow_color = Some(frus_core::Color::TRANSPARENT);
        assert!(cast(&open_menu(), &themed).is_empty());
    }

    #[test]
    fn closed_has_no_overlay() {
        let menu = PopupMenuButton::new(anchor(), false, Msg::Close).item("A", Msg::A);
        assert!(Widget::<Msg>::overlay(&menu).is_none());
        assert_eq!(Widget::<Msg>::children(&menu).len(), 1);
    }

    #[test]
    fn open_floats_items_and_dismisses_outside() {
        let menu = PopupMenuButton::new(anchor(), true, Msg::Close)
            .item("A", Msg::A)
            .item("B", Msg::B);
        assert!(Widget::<Msg>::overlay(&menu).is_some());
        assert_eq!(Widget::<Msg>::overlay_dismiss(&menu), Some(Msg::Close));

        let ui = build_ui(
            &menu,
            Size::new(400.0, 300.0),
            &Runtime::default(),
            &Theme::default(),
        );
        // A click far from the anchor (the bottom-right corner) closes the menu.
        let corner = ui.hit(P::new(390.0, 290.0)).expect("hit de fermeture");
        assert_eq!(ui.msg_for(corner), Some(Msg::Close));
    }
}