kui-core 0.1.0-alpha.45

kui contract: flat per-frame tree, clay-style flex layout, text stack, events as data, quad display list
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
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
//! Stock widgets built from the primitives: buttons, toggles, text input,
//! select, slider, splitter, tooltips, menus, a titlebar and virtual lists.
//!
//! Every widget here is a plain function over a [`Ui`] that opens ordinary
//! nodes with ordinary [`NodeSpec`]s; there is no widget trait and no
//! retained object. State lives in the core by key (focus, hover, an edit
//! buffer, a scroll offset), and the app's model is the only other state.
//! A custom widget follows the same pattern, and the `*_spec` functions
//! ([`button_spec`], [`toggle_spec`], [`slider_spec`], [`menu_panel_spec`])
//! are the starting points for one that should look like the stock set.
//!
//! ```rust
//! use kui_core::{Core, NodeSpec, Size, Value, widgets};
//!
//! let mut core = Core::new();
//! let mut ui = core.frame(Size::new(400.0, 300.0), 1.0);
//! ui.configure_root(NodeSpec::column().fill().pad(12.0).gap(8.0));
//!
//! widgets::label(&mut ui, "Settings");
//! let name = widgets::text_input(&mut ui, "name", "Ada");
//! widgets::checkbox(&mut ui, "Dark mode", true, "toggle-dark");
//! widgets::slider(&mut ui, "volume", 40.0, 0.0, 100.0, 1.0, "volume");
//! widgets::button(&mut ui, "Save", Value::str("save"));
//!
//! assert_eq!(ui.edit_text(name).as_deref(), Some("Ada"));
//! ui.finish();
//! ```
//!
//! Each control posts the payload it was given as a
//! [`UiEvent`](crate::input::UiEvent) when it is used, and the view redraws
//! from its model; a checkbox does not flip itself.

use crate::access::Role;
use crate::color::Color;
use crate::cursor::CursorShape;
use crate::edit::EditOptions;
use crate::geom::{Edges, Vec2};
use crate::key::Key;
use crate::menu::{MenuBar, MenuItem, MenuRole};
use crate::metrics::Metrics;
use crate::spec::{Align, FloatConfig, NodeSpec, Sizing, TextStyle};
use crate::stats::{FrameSample, STATS_CAPACITY};
use crate::theme::Theme;
use crate::tree::OriginId;
use crate::ui::Ui;
use crate::value::Value;
use crate::window::WindowButton;

/// Floating latency HUD: `latency_graph` in a translucent panel pinned to a
/// viewport corner, above all content and out of layout flow. Call anywhere
/// in the view; pick the corner with `latency_hud_at`.
pub fn latency_hud(ui: &mut Ui<'_>) {
    latency_hud_at(ui, Align::End, Align::End);
}

pub fn latency_hud_at(ui: &mut Ui<'_>, x: Align, y: Align) {
    // Under custom chrome the top of the viewport is the app's titlebar;
    // keep the HUD below it.
    let top_inset = if ui.env().window.custom_chrome {
        titlebar_height(ui)
    } else {
        0.0
    };
    // The same attach points a float takes, which place the spreads and
    // `Baseline` as the start or the centre (`layout::align_factor`).
    let dx = match x {
        Align::Start | Align::SpaceBetween | Align::Baseline => 12.0,
        Align::Center | Align::SpaceAround | Align::SpaceEvenly => 0.0,
        Align::End => -12.0,
    };
    let dy = match y {
        Align::Start | Align::SpaceBetween | Align::Baseline => 12.0 + top_inset,
        Align::Center | Align::SpaceAround | Align::SpaceEvenly => 0.0,
        Align::End => -12.0,
    };
    // Translucent over whatever the app is painting, so the panel takes
    // the theme's backmost surface and its strong border at the alphas
    // the HUD has always used.
    let t = ui.theme();
    ui.with(
        NodeSpec::column()
            .float(
                crate::spec::FloatConfig::viewport()
                    .inside(x, y)
                    .offset(dx, dy),
            )
            .pad(10.0)
            .bg(t.bg.with_alpha(0.71))
            .radius(8.0)
            .border(1.0, t.border_strong.with_alpha(0.5)),
        latency_graph,
    );
}

/// Frame-latency graph: the last ~120 frames as stacked per-phase bars
/// (input / view / layout / render, bottom to top) against the display's
/// frame budget (`env.refresh_hz`, 120 Hz fallback) — a bar that blows the
/// budget turns red. Feed `core.stats` (and `core.env`) from your frame
/// driver (the built-in runner does this automatically).
pub fn latency_graph(ui: &mut Ui<'_>) {
    const GRAPH_H: f32 = 34.0;
    const INPUT: Color = Color {
        r: 0.45,
        g: 0.85,
        b: 0.55,
        a: 1.0,
    };
    const VIEW: Color = Color {
        r: 0.28,
        g: 0.42,
        b: 0.88,
        a: 1.0,
    };
    const LAYOUT: Color = Color {
        r: 0.60,
        g: 0.42,
        b: 0.88,
        a: 1.0,
    };
    const RENDER: Color = Color {
        r: 0.94,
        g: 0.72,
        b: 0.35,
        a: 1.0,
    };
    const WAIT: Color = Color {
        r: 0.42,
        g: 0.45,
        b: 0.52,
        a: 0.7,
    };
    const OVER: Color = Color {
        r: 0.91,
        g: 0.36,
        b: 0.36,
        a: 1.0,
    };

    let theme = ui.theme();
    let budget_ms = ui.env().frame_budget_ms(); // full graph height
    let stats = &ui.core().stats;
    let samples: Vec<FrameSample> = stats.iter().collect();
    let (avg_work, max_work) = (stats.avg_work(), stats.max_work());
    let avg_wait = if samples.is_empty() {
        0.0
    } else {
        samples.iter().map(|s| s.wait_ms).sum::<f32>() / samples.len() as f32
    };

    // A development overlay, not app content: kept out of the access tree
    // so a screen reader does not read frame timings between the controls.
    ui.with(
        NodeSpec::column()
            .gap(3.0)
            .cross_align(Align::End)
            .role(crate::access::Role::None),
        |ui| {
            let mut label = format!("work {avg_work:.2}ms avg · {max_work:.2}ms max");
            if avg_wait > 0.05 {
                label.push_str(&format!(" · +{avg_wait:.2}ms vsync"));
            }
            ui.with(NodeSpec::row().gap(6.0).cross_align(Align::Center), |ui| {
                ui.text(&label, TextStyle::new(10.0).color(theme.muted));
                // "?" badge: hover for the color legend. Also the dynamic-float
                // showcase — in the default bottom-right HUD the tooltip has no
                // room below or to the right, so it flips above and slides left.
                let badge = ui.child_key("kui:latency-legend");
                let badge_bg =
                    theme
                        .muted
                        .with_alpha(if ui.is_hovered(badge) { 0.31 } else { 0.16 });
                ui.with_keyed(
                    "kui:latency-legend",
                    NodeSpec::column()
                        .size(13.0, 13.0)
                        .center()
                        .bg(badge_bg)
                        .radius(6.5)
                        .hoverable(),
                    |ui| {
                        ui.text("?", TextStyle::new(9.0).color(theme.fg));
                        if ui.is_hovered(badge) {
                            tooltip_with(ui, |ui| {
                                ui.with(NodeSpec::column().gap(5.0), |ui| {
                                    for (color, name) in [
                                        (INPUT, "input — events & edits"),
                                        (VIEW, "view — rebuilding the tree"),
                                        (LAYOUT, "layout — sizing & positions"),
                                        (RENDER, "render — encode + submit"),
                                        (WAIT, "vsync wait (not work)"),
                                        (OVER, "cap: work over frame budget"),
                                    ] {
                                        ui.with(
                                            NodeSpec::row().gap(7.0).cross_align(Align::Center),
                                            |ui| {
                                                ui.leaf(
                                                    NodeSpec::column()
                                                        .size(9.0, 9.0)
                                                        .bg(color)
                                                        .radius(2.0),
                                                );
                                                ui.text(name, TextStyle::new(11.0).color(theme.fg));
                                            },
                                        );
                                    }
                                });
                            });
                        }
                    },
                );
            });
            ui.with(
                NodeSpec::row()
                    .size(STATS_CAPACITY as f32 * 2.0, GRAPH_H)
                    .gap(1.0)
                    .main_align(Align::End)
                    .cross_align(Align::End)
                    .bg(theme.sunken.with_alpha(0.6))
                    .radius(3.0)
                    .clip(),
                |ui| {
                    let px_per_ms = GRAPH_H / budget_ms;
                    for s in &samples {
                        // Phases keep their colors even over budget — a spike
                        // you can't attribute is a spike you can't fix. Work
                        // (not vsync pacing) over budget gets a red cap.
                        let over = s.work() > budget_ms;
                        ui.with(
                            NodeSpec::column()
                                .width(1.0)
                                .main_align(Align::End)
                                .max_height(GRAPH_H),
                            |ui| {
                                if over {
                                    ui.leaf(NodeSpec::column().size(1.0, 3.0).bg(OVER));
                                }
                                // Column children run top->bottom; push in
                                // reverse so input sits at the bottom.
                                for (ms, color) in [
                                    (s.wait_ms, WAIT),
                                    (s.render_ms, RENDER),
                                    (s.layout_ms, LAYOUT),
                                    (s.view_ms, VIEW),
                                    (s.input_ms, INPUT),
                                ] {
                                    if ms <= 0.0 {
                                        continue;
                                    }
                                    let h = (ms * px_per_ms).max(1.0);
                                    ui.leaf(NodeSpec::column().size(1.0, h).bg(color));
                                }
                            },
                        );
                    }
                },
            );
        },
    );
}

/// Small floating label hanging below the node it's declared inside. The
/// placement is dynamic (`FloatConfig::fit`): it flips above when the
/// viewport bottom is too close and slides sideways off window edges.
/// Typical use: `if ui.is_hovered(key) { widgets::tooltip(ui, "..."); }`
pub fn tooltip(ui: &mut Ui<'_>, text: &str) {
    let size = ui.metrics().hint_text;
    tooltip_with(ui, |ui| {
        ui.text(text, TextStyle::new(size));
    });
}

/// [`tooltip`] chrome around arbitrary content (legends, shortcut hints, …).
pub fn tooltip_with(ui: &mut Ui<'_>, content: impl FnOnce(&mut Ui<'_>)) {
    let spec = tooltip_spec(ui);
    ui.with(spec, content);
}

/// The hint the `tooltip` prop floats under a hovered node, and the stock
/// button under a hovered button: [`tooltip`]'s chrome and text, kept out
/// of the access tree (`Role::None`). The prop has already set the same
/// string as the node's description, which is where a reader hears it;
/// as content it would be read twice under a group and, under a control
/// named from its content, become part of the *name* whenever the pointer
/// crossed it. The `tooltip` element keeps its text, since
/// it is drawn with no description behind it.
pub(crate) fn hover_hint(ui: &mut Ui<'_>, text: &str) {
    let size = ui.metrics().hint_text;
    let spec = tooltip_spec(ui).role(crate::access::Role::None);
    ui.text_in(spec, text, TextStyle::new(size));
}

/// [`hover_hint`] for a leaf — a `line`, a `polygon`, a `path`, a `cells`
/// grid, an image, an editor — which holds no children for the hint to
/// float as the last of (backlog RG113). The hint is opened beside the
/// leaf, in the leaf's parent, and anchored to the leaf by key
/// (`FloatAnchor::Node`), so it is laid out against the leaf's box once
/// that is placed and lands below it as a box's lands below the box. Its
/// key is the leaf's own child key: an auto-keyed one would take the
/// parent's next sibling slot while the pointer is over the leaf and
/// shift the key of every sibling declared after it.
pub(crate) fn leaf_hint(ui: &mut Ui<'_>, leaf: Key, text: &str) {
    let size = ui.metrics().hint_text;
    let mut spec = tooltip_spec(ui).role(crate::access::Role::None);
    if let Some(float) = spec.layout.float.as_mut() {
        float.anchor = crate::spec::FloatAnchor::Node(leaf);
    }
    ui.core().open_key(leaf.str("tooltip"), spec);
    ui.text(text, TextStyle::new(size));
    ui.close();
}

fn tooltip_spec(ui: &Ui<'_>) -> NodeSpec {
    let t = ui.theme();
    let m = ui.metrics();
    NodeSpec::column()
        .float(crate::spec::FloatConfig::below().fit())
        .pad_xy(m.hint_pad_x, m.hint_pad_y)
        .bg(t.raised)
        .radius(m.radius)
        .border(1.0, t.border_strong)
}

/// A line of text in the default style: `ui.text(text, TextStyle::default())`.
pub fn label(ui: &mut Ui<'_>, text: &str) {
    ui.text(text, TextStyle::default());
}

/// Single-line text input with chrome (background, focus ring).
/// Read the value with `ui.edit_text(key)`; "changed"/"submit" events arrive
/// in `on_event` with this key. The `label` is the key and the accessible
/// name both (`"search"`, `"name"`), so a screen reader has something to
/// announce; use `ui.text_edit` with `NodeSpec::label` when they differ.
pub fn text_input(ui: &mut Ui<'_>, label: &str, initial: &str) -> Key {
    let key = ui.child_key(label);
    let t = ui.theme();
    let m = ui.metrics();
    let border = if ui.is_focused(key) {
        t.accent
    } else {
        t.border
    };
    ui.text_edit(
        label,
        initial,
        &EditOptions {
            multiline: false,
            ..Default::default()
        },
        NodeSpec::column()
            .grow_width()
            .pad_xy(m.field_pad_x, m.field_pad_y)
            .bg(t.sunken)
            .radius(m.radius)
            .border(1.0, border)
            .clip()
            .label(label),
    )
}

/// What a select's trigger posts when it is clicked; the core takes it
/// back and opens the menu (`Core::consume_select_events`).
pub(crate) fn select_tag() -> Value {
    Value::map([("select", Value::Bool(true))])
}

/// A choice among a few named options: a field that shows the one in
/// force and, clicked, drops a menu of them all with the current one
/// checked. `options` are the labels, `current` the index in force (or
/// none). Keyed by `label`, which is the accessible name too.
///
/// The menu is the core's own — the same one a right-click opens
/// (`Core::open_menu`): drawn in the frame, or the platform's where the
/// host shows menus itself, dismissed by Escape or a press outside, its
/// rows walked by the arrows. So the app holds no open state; what it
/// hears is the choice, as the `menu` event a menu row posts, on this
/// key: `{kind: "menu", role: "custom", item: <the option>}`. A view
/// that then draws the select with the new `current` is the whole loop.
///
/// [`select_items`] is the same field over [`MenuItem`]s, for an option
/// that posts an `id` of its own rather than its label.
pub fn select(ui: &mut Ui<'_>, label: &str, options: &[&str], current: Option<usize>) -> Key {
    let items: Vec<MenuItem> = options.iter().map(|o| MenuItem::new(*o)).collect();
    select_items(ui, label, &items, current)
}

/// [`select`] over items the caller built: their labels are the rows,
/// their `id`s what a choice posts, and the `current`th is drawn checked
/// whatever the item said. A separator is a separator here too.
pub fn select_items(
    ui: &mut Ui<'_>,
    label: &str,
    items: &[MenuItem],
    current: Option<usize>,
) -> Key {
    let t = ui.theme();
    let m = ui.metrics();
    select_with(
        ui,
        label,
        items,
        current,
        select_spec(&t, &m),
        TextStyle::new(m.chrome_text),
    )
}

/// The stock select field's spec: a sunken field with the stock radius
/// and padding, as [`button_spec`] is the stock button's. What
/// [`select_with`] is handed by [`select_items`]; a caller with a spec
/// of its own starts here and adds to it.
pub fn select_spec(theme: &Theme, m: &Metrics) -> NodeSpec {
    NodeSpec::row()
        .pad_xy(m.field_pad_x, m.field_pad_y)
        .gap(8.0)
        .cross_align(Align::Center)
        .bg(theme.sunken)
        .hover_bg(theme.hover)
        .radius(m.radius)
}

/// [`select_items`] with its spec and text style in the caller's hands —
/// a compact field in a dense panel — the way [`button_with`] takes the
/// button's. The border, the click, the role and the disclosure are
/// added here whatever `spec` said.
///
/// A `current` that names no option (past the end, or a separator) is
/// none, with a `select-current-ignored` warning on the field: the field
/// is described by nothing and no row is checked.
pub fn select_with(
    ui: &mut Ui<'_>,
    label: &str,
    items: &[MenuItem],
    current: Option<usize>,
    spec: NodeSpec,
    text: TextStyle,
) -> Key {
    let key = ui.child_key(label);
    let current = current.filter(|&i| {
        let separator = items
            .get(i)
            .is_some_and(|it| it.role == MenuRole::Separator);
        let names_one = i < items.len() && !separator;
        if !names_one {
            ui.core().warn(crate::diag::select_current_ignored(
                key,
                label,
                i,
                items.len(),
                separator,
            ));
        }
        names_one
    });
    let t = ui.theme();
    let shown = current
        .and_then(|i| items.get(i))
        .map_or("", |i| i.text())
        .to_string();
    let open = ui.core().menu().is_some_and(|menu| menu.target == key);
    let border = if open || ui.is_focused(key) {
        t.accent
    } else {
        t.border
    };
    // An option is chosen, never opened: rows it was handed with a submenu
    // (a C `KuiMenuItem`'s `submenu`, a data option's `items`) are dropped,
    // so `current` always names a row of this one menu (backlog RG150).
    let menu: Vec<MenuItem> = items
        .iter()
        .enumerate()
        .map(|(i, item)| {
            let mut row = item.clone().checked(current == Some(i));
            row.submenu = Vec::new();
            row
        })
        .collect();
    ui.core().declare_select(key, menu);
    ui.with_keyed(
        label,
        spec.border(1.0, border)
            .cursor(CursorShape::Pointer)
            .on_click(select_tag())
            // A button named by the field, described by the choice: what
            // a reader says of a pop-up button, in the two slots a button
            // has (`value` is a slider's and an editor's).
            .role(Role::Button)
            .label(label)
            .description(shown.as_str())
            .expanded(open),
        |ui| {
            ui.text(&shown, text.color(t.fg).nowrap());
            // The disclosure: a small triangle, the mark every platform's
            // pop-up field carries.
            ui.text("\u{25BE}", text.color(t.muted));
        },
    )
}

/// Default titlebar height, logical px, where the strip is the app's
/// alone. Follows platform conventions (as measured by gpui): 32 on
/// Windows (the native caption height), 34 elsewhere. The stock
/// [`Metrics`] carries the same number as `titlebar_h`, and the titlebar
/// draws from *that*, so an app that set its own metrics lays out against
/// `ui.metrics().titlebar_h` rather than this constant — and where the OS
/// keeps controls of its own over the strip, against [`titlebar_height`].
pub const TITLEBAR_H: f32 = Metrics::comfortable().titlebar_h;

/// The height the titlebar strip draws at — what an app laying out its
/// own strip, or something under it, should read instead of
/// `ui.metrics().titlebar_h`. Where the OS keeps controls of its own over
/// the strip (`env.window.native_controls`: the macOS traffic lights under
/// custom chrome) the strip is the OS's own titlebar, as tall as the
/// keep-out rect says that titlebar is, so the strip's content centres on
/// the buttons the OS centred in it. Everywhere else the strip is the app's alone and
/// `Metrics::titlebar_h` is its height. A keep-out with no height (a host
/// that reported a width only) falls back to the metric.
pub fn titlebar_height(ui: &Ui<'_>) -> f32 {
    match ui.env().window.native_controls {
        Some(r) if r.h > 0.0 => r.h,
        _ => ui.metrics().titlebar_h,
    }
}

/// A cross-platform titlebar: a full-width drag strip with the window title
/// left-aligned next to the window controls. Reads `env.window` and adapts
/// by itself — under macOS custom chrome it insets past the native traffic
/// lights and draws no buttons; under custom chrome elsewhere it appends
/// minimize/maximize/close; under native decorations it is just a drag
/// strip (no duplicate buttons).
///
/// Typical use, as the first child of a full-height root:
/// `widgets::titlebar(ui, "my app")`.
pub fn titlebar(ui: &mut Ui<'_>, title: &str) {
    let focused = ui.env().focused;
    let title = title.to_string();
    titlebar_with(ui, move |ui| {
        // A background window's title recedes; the OS does the same.
        let t = ui.theme();
        let size = ui.metrics().chrome_text;
        let color = if focused { t.fg } else { t.faint };
        ui.text_in(
            NodeSpec::row().fill().cross_align(Align::Center),
            &title,
            TextStyle::new(size).color(color).ellipsis(),
        );
    });
}

/// Titlebar with custom content (tabs, a search box, …) between the
/// platform inset and the window buttons. The whole strip is a drag
/// handle; interactive children declared inside it sit on top and win
/// hit-testing, so buttons in a titlebar just work.
pub fn titlebar_with(ui: &mut Ui<'_>, content: impl FnOnce(&mut Ui<'_>)) {
    let win = ui.env().window;
    let h = titlebar_height(ui);
    ui.with_keyed(
        "kui:titlebar",
        NodeSpec::row()
            .grow_width()
            .height(h)
            .cross_align(Align::Center)
            .window_drag(),
        |ui| {
            // Keep clear of controls the OS draws over our content (the
            // reported rect already includes the trailing gap); without
            // them, a plain leading margin.
            let inset = win.native_controls.map_or(12.0, |r| r.x + r.w);
            ui.leaf(NodeSpec::row().width(inset));
            content(ui);
            window_buttons(ui);
        },
    );
}

/// The minimize/maximize/close cluster. Renders nothing when the OS already
/// provides controls (native decorations, or macOS traffic lights), so it
/// is always safe to call. It grows to the height it is given — the
/// strip's, in [`titlebar_with`] — and is a titlebar tall where nothing
/// gives it one, since a grow child adds nothing to a fit parent's
/// height.
pub fn window_buttons(ui: &mut Ui<'_>) {
    let win = ui.env().window;
    if !win.custom_chrome || win.native_controls.is_some() {
        return;
    }
    let h = titlebar_height(ui);
    ui.with(NodeSpec::row().grow_height().min_height(h), |ui| {
        window_button(ui, WindowButton::Minimize, win.maximized);
        window_button(ui, WindowButton::Maximize, win.maximized);
        window_button(ui, WindowButton::Close, win.maximized);
    });
}

fn window_button(ui: &mut Ui<'_>, button: WindowButton, maximized: bool) {
    let label = match button {
        WindowButton::Minimize => "kui:win-min",
        WindowButton::Maximize => "kui:win-max",
        WindowButton::Close => "kui:win-close",
    };
    let key = ui.child_key(label);
    let (hovered, pressed) = (ui.is_hovered(key), ui.is_pressed(key));
    let t = ui.theme();
    let fg = t.fg;
    // Close is the one button that keeps a colour of its own on both
    // bases — it is the platform's signal, not the palette's — but it is
    // the theme's danger rather than a second red.
    let (bg, fg) = match button {
        WindowButton::Close if pressed => (t.danger.mix(Color::BLACK, 0.15), Color::WHITE),
        WindowButton::Close if hovered => (t.danger, Color::WHITE),
        _ if pressed => (t.pressed, fg),
        _ if hovered => (t.hover, fg),
        _ => (Color::TRANSPARENT, fg),
    };
    ui.with_keyed(
        label,
        NodeSpec::row()
            .width(46.0)
            .grow_height()
            .center()
            .bg(bg)
            .window_button(button),
        |ui| match button {
            WindowButton::Minimize => {
                ui.leaf(NodeSpec::row().size(10.0, 1.0).bg(fg));
            }
            WindowButton::Maximize if maximized => {
                // Restore: two offset outlines.
                ui.with(NodeSpec::column().size(10.0, 10.0), |ui| {
                    for (x, y) in [(Align::End, Align::Start), (Align::Start, Align::End)] {
                        ui.leaf(
                            NodeSpec::column()
                                .size(7.5, 7.5)
                                .border(1.0, fg)
                                .float(crate::spec::FloatConfig::parent().inside(x, y)),
                        );
                    }
                });
            }
            WindowButton::Maximize => {
                ui.leaf(NodeSpec::column().size(9.0, 9.0).border(1.0, fg));
            }
            WindowButton::Close => {
                // The multiplication sign inks only about 0.42 em, so it
                // needs a far larger em than the 9-10px bar and box beside
                // it to read as the same size. It also rides the math axis,
                // which sits a little under the middle of the line box, so
                // the bottom padding lifts it back onto the button center.
                const EM: f32 = 23.0;
                ui.text_in(
                    NodeSpec::row().padding(Edges {
                        b: EM * 0.25,
                        ..Edges::default()
                    }),
                    "\u{00d7}",
                    TextStyle::new(EM).line_height(EM).color(fg),
                );
            }
        },
    );
}

/// The standard button's spec: hover and pressed backgrounds are declared
/// on the node and resolved by the core, so every binding's button is this
/// same data. Add the label as a child.
///
/// The three backgrounds are the theme's accent trio (`accent`,
/// `accent_hover`, `accent_pressed`): the OS's accent where the host
/// reports one, the app's where it set or pinned one, and kui's blue
/// otherwise. Takes the theme and the metrics rather than reading them, so
/// `widgets::button_spec(&ui.theme(), &ui.metrics())` is the idiom. The
/// derivation for any other base colour is [`button_palette`].
pub fn button_spec(theme: &Theme, m: &Metrics) -> NodeSpec {
    NodeSpec::row()
        .pad_xy(m.control_pad_x, m.control_pad_y)
        .bg(theme.accent)
        .hover_bg(theme.accent_hover)
        .pressed_bg(theme.accent_pressed)
        .radius(m.radius)
        .center()
}

/// A button's three backgrounds from one base colour: the base, a hover a
/// step toward white, a pressed a step toward black. The steps are the
/// distances the stock button's own trio sits at, so an accent-painted
/// button reads as the same control in a different colour.
///
/// Public because "a button in *this* colour" is the same question with a
/// different answer, and the arithmetic should not be re-guessed per app.
pub fn button_palette(base: Color) -> (Color, Color, Color) {
    (
        base,
        base.mix(Color::WHITE, 0.09),
        base.mix(Color::BLACK, 0.10),
    )
}

/// Black or white, whichever a reader can see on `bg`.
///
/// The split is at `Color::luminance` 0.4 rather than at the midpoint:
/// white text needs a darker background than black text needs a light one,
/// and the accents that land near the line (macOS's yellow at 0.72, its
/// orange at 0.44) come out the way the platform paints them. It is the
/// stock button's answer, not a general contrast checker — a palette that
/// cares should say what its label colour is.
pub fn readable_on(bg: Color) -> Color {
    if bg.luminance() > 0.4 {
        Color::BLACK
    } else {
        Color::WHITE
    }
}

/// The stock button's text size — [`Metrics::default`]'s `control_text`;
/// the widget itself reads `ui.metrics()`.
pub const BUTTON_TEXT: f32 = Metrics::comfortable().control_text;
/// What a disabled stock button's opacity is multiplied by. The core makes
/// it inert and drops its hover and pressed backgrounds, and nothing else
/// would show a sighted user the state a reader is told.
pub const BUTTON_DISABLED_OPACITY: f32 = 0.5;

/// A push button showing `text`, keyed by it; a click posts `payload` as
/// a [`UiEvent`](crate::input::UiEvent) on the button's key.
///
/// ```rust
/// # use kui_core::{Core, NodeSpec, Size, widgets};
/// # let mut core = Core::new();
/// # let mut ui = core.frame(Size::new(200.0, 100.0), 1.0);
/// widgets::button(&mut ui, "Save", "save");
/// // The same button with its spec in hand: a tooltip and a stable key.
/// let (t, m) = (ui.theme(), ui.metrics());
/// widgets::button_with(&mut ui, "save-2", "Save", widgets::button_spec(&t, &m).on_click("save"), Some("Ctrl+S"));
/// # ui.finish();
/// ```
///
/// A label that changes re-keys the node (a new node, so it loses keyboard
/// focus and a screen reader's cursor); declare such a button with
/// [`button_with`] and a key of its own. The pointer over it is the hand
/// (`CursorShape::Pointer`): the core implies no shape from an `on_click`,
/// and the stock button is the one place the hand is declared for you.
pub fn button(ui: &mut Ui<'_>, text: &str, payload: impl Into<Value>) {
    let (theme, m) = (ui.theme(), ui.metrics());
    button_with(
        ui,
        text,
        text,
        button_spec(&theme, &m).on_click(payload.into()),
        None,
    );
}

/// [`button`] with its spec in the caller's hands: `spec` is [`button_spec`]
/// plus what the caller declared on it — the `on_click`, and the rows the
/// stock button admits in every binding (`schema::BUTTON_ROWS_JSX`): a
/// `label` when the text is not the name, a `description`, `disabled`,
/// and the hover tracking and description a `tooltip` sets, whose float
/// is `hint` — drawn under the button while it is hovered, as every
/// binding's `tooltip` prop floats one. Keyed by `key`, so a label that
/// changes need not re-key the node. A disabled button is dimmed
/// ([`BUTTON_DISABLED_OPACITY`]) as well as inert.
///
/// This is what `<button>`, `button { }` and `kui_button_with` lower to,
/// so a binding cannot end up with a button of its own.
pub fn button_with(ui: &mut Ui<'_>, key: &str, text: &str, spec: NodeSpec, hint: Option<&str>) {
    button_body(ui, Ident::Label(key), text, spec, hint);
}

/// [`button_with`] keyed by a data index rather than a label — a row of a
/// virtual list (`Ui::open_indexed`), so the button keeps its focus, its
/// hover and its tweens as the built range slides and the same text on
/// two rows is two nodes. What `<button index>` and
/// `button { index = }` lower to.
pub fn button_indexed(ui: &mut Ui<'_>, index: u64, text: &str, spec: NodeSpec, hint: Option<&str>) {
    button_body(ui, Ident::Index(index), text, spec, hint);
}

/// How a button is keyed: by the label its `key` declares, or by the
/// data index its `index` declares.
enum Ident<'a> {
    Label(&'a str),
    Index(u64),
}

/// A widget that takes a `hint` floats it itself, so a spec that also
/// declared [`NodeSpec::tooltip`] does not float a second one.
fn own_hint(spec: &mut NodeSpec, hint: Option<&str>) {
    if hint.is_some() && spec.access().tooltip {
        spec.access_mut().tooltip = false;
    }
}

fn button_body(ui: &mut Ui<'_>, ident: Ident<'_>, text: &str, spec: NodeSpec, hint: Option<&str>) {
    let theme = ui.theme();
    // `accent` asks for the whole family, not just the background the
    // core would substitute for any node: a button whose hover and pressed
    // shades stayed put would flash under a yellow accent. On a stock
    // spec it changes nothing — `button_spec` paints from the theme's
    // trio already (AR41) — and on a spec whose caller set its own `bg`
    // it is the ask to take the theme's instead. The family is the
    // *theme's*, and the theme always has one, so there is no
    // gate here: kui's blue is the accent nobody chose.
    let spec = if spec.accent {
        spec.bg(theme.accent)
            .hover_bg(theme.accent_hover)
            .pressed_bg(theme.accent_pressed)
    } else {
        spec
    };
    let spec = if spec.disabled {
        let o = spec.style.opacity * theme.disabled_opacity;
        spec.opacity(o)
    } else {
        spec
    };
    // The hand is declared, never derived from the `on_click`
    // (`crate::cursor`), and the stock button is where it is declared: a
    // caller's own `cursor` stands, and an inert button is the arrow — the
    // click it refuses is not one to point at.
    let spec = if spec.cursor.is_none() && !spec.disabled {
        spec.cursor(CursorShape::Pointer)
    } else {
        spec
    };
    // The hint floats out of the access tree (`hover_hint`), so it is
    // heard only as the description: a caller that passed one without
    // `apply_tooltip` on the spec still has it said. A declared
    // description stands, as it does over the prop.
    let mut spec = match hint {
        Some(hint) if spec.access().description.is_none() => spec.apply_tooltip(hint),
        _ => spec,
    };
    own_hint(&mut spec, hint);
    // Whatever the background ended up being: white on the stock blue as
    // it has always been, black on an accent light enough to need it.
    let label = readable_on(spec.style.bg);
    let size = ui.metrics().control_text;
    let node = match ident {
        Ident::Label(key) => ui.child_key(key),
        Ident::Index(i) => ui.child_key_indexed(i),
    };
    let body = |ui: &mut Ui<'_>| {
        ui.text(text, TextStyle::new(size).color(label));
        if let Some(hint) = hint
            && ui.is_hovered(node)
        {
            hover_hint(ui, hint);
        }
    };
    match ident {
        Ident::Label(key) => {
            ui.with_keyed(key, spec, body);
        }
        Ident::Index(i) => {
            ui.with_indexed(i, spec, body);
        }
    }
}

// -- Stock controls ---------------------------------------------------------
// The stock controls over the roles: checkbox, radio, switch
// and slider, composed over the roles the core already reads. The state is
// the app's and rides on the spec — `checked`, `mixed`, `value_now` — so a
// control is drawn from what the view declared this frame, and a toggle's
// press is its `on_click` like any button's. One definition per control:
// every binding's element lowers to the `*_with` here.

/// The side of a stock control's box — a checkbox, a radio's circle, a
/// switch's height, a slider's thumb — from the metrics' control text, so
/// `compact` and `scaled` move it with the stock button: 16 px at the
/// comfortable density, 14 at the compact one.
pub fn control_box(m: &Metrics) -> f32 {
    (m.control_text + 1.0).round()
}

/// Which toggle a [`toggle_with`] draws.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Toggle {
    Checkbox,
    Radio,
    Switch,
}

impl Toggle {
    /// The role it declares, whatever the spec said.
    pub fn role(self) -> Role {
        match self {
            Toggle::Checkbox => Role::Checkbox,
            Toggle::Radio => Role::Radio,
            Toggle::Switch => Role::Switch,
        }
    }

    /// The element's name in every binding.
    pub fn name(self) -> &'static str {
        match self {
            Toggle::Checkbox => "checkbox",
            Toggle::Radio => "radio",
            Toggle::Switch => "switch",
        }
    }
}

/// A stock toggle's spec — the row its indicator and label sit in — as
/// [`button_spec`] is the button's. A caller with a spec of its own starts
/// here and adds the state (`checked`, `mixed`), the `on_click` and the
/// access rows to it.
pub fn toggle_spec(m: &Metrics) -> NodeSpec {
    NodeSpec::row()
        .gap((control_box(m) / 2.0).round())
        .cross_align(Align::Center)
}

/// A checkbox labelled `text`, keyed by it, drawn from `checked`; a press
/// — pointer, Space, Enter or assistive technology — posts `payload`,
/// and the view flips its model and draws it again.
pub fn checkbox(ui: &mut Ui<'_>, text: &str, checked: bool, payload: impl Into<Value>) -> Key {
    let m = ui.metrics();
    toggle_with(
        ui,
        Toggle::Checkbox,
        text,
        text,
        toggle_spec(&m).checked(checked).on_click(payload.into()),
        None,
    )
}

/// A radio labelled `text`, keyed by it; see [`checkbox`]. Radios belong in
/// a [`radio_group_with`], whose arrows move the choice.
pub fn radio(ui: &mut Ui<'_>, text: &str, checked: bool, payload: impl Into<Value>) -> Key {
    let m = ui.metrics();
    toggle_with(
        ui,
        Toggle::Radio,
        text,
        text,
        toggle_spec(&m).checked(checked).on_click(payload.into()),
        None,
    )
}

/// A switch labelled `text`, keyed by it; see [`checkbox`].
pub fn switch(ui: &mut Ui<'_>, text: &str, on: bool, payload: impl Into<Value>) -> Key {
    let m = ui.metrics();
    toggle_with(
        ui,
        Toggle::Switch,
        text,
        text,
        toggle_spec(&m).checked(on).on_click(payload.into()),
        None,
    )
}

/// A toggle with its spec in the caller's hands, the way [`button_with`]
/// takes the button's: `spec` is [`toggle_spec`] plus the state and the
/// rows the element admits — `checked`, `mixed` (a checkbox's third
/// state), `on_click`, `label`, `description`, `disabled`, and `hint`, the
/// tooltip drawn while it is hovered. The role is `kind`'s whatever the
/// spec said. Keyed by `key`; an empty `text` draws the indicator alone,
/// which then wants a `label`. A disabled toggle is dimmed as well as
/// inert. This is what `<checkbox>`, `<radio>`, `<switch>` and their Lua
/// and C doors lower to.
pub fn toggle_with(
    ui: &mut Ui<'_>,
    kind: Toggle,
    key: &str,
    text: &str,
    spec: NodeSpec,
    hint: Option<&str>,
) -> Key {
    let t = ui.theme();
    let m = ui.metrics();
    let node = ui.child_key(key);
    let ax = spec.access();
    let mixed = ax.mixed && kind == Toggle::Checkbox;
    let on = ax.checked || mixed;
    let disabled = spec.disabled;
    let hovered = !disabled && ui.is_hovered(node);
    let mut spec = spec.role(kind.role());
    own_hint(&mut spec, hint);
    if disabled {
        let o = spec.style.opacity * t.disabled_opacity;
        spec = spec.opacity(o);
    } else if spec.cursor.is_none() {
        spec = spec.cursor(CursorShape::Pointer);
    }
    let b = control_box(&m);
    ui.with_keyed(key, spec, |ui| {
        let edge = if on || hovered {
            t.accent
        } else {
            t.border_strong
        };
        match kind {
            Toggle::Checkbox | Toggle::Radio => {
                let radius = if kind == Toggle::Radio {
                    b / 2.0
                } else {
                    m.radius_inner.min(b / 4.0)
                };
                let face = NodeSpec::row()
                    .size(b, b)
                    .radius(radius)
                    .border(1.0, edge)
                    .bg(if on { t.accent } else { t.sunken })
                    .center();
                ui.with(face, |ui| {
                    if !on {
                        return;
                    }
                    if kind == Toggle::Radio {
                        let d = (b * 0.4).round();
                        ui.leaf(NodeSpec::row().size(d, d).radius(d / 2.0).bg(t.on_accent));
                    } else if mixed {
                        ui.leaf(
                            NodeSpec::row()
                                .size((b * 0.5).round(), 2.0)
                                .radius(1.0)
                                .bg(t.on_accent),
                        );
                    } else {
                        // Drawn, not a glyph: the same mark at every size
                        // and in every font.
                        ui.polyline(
                            &[
                                Vec2::new(b * 0.26, b * 0.52),
                                Vec2::new(b * 0.43, b * 0.69),
                                Vec2::new(b * 0.75, b * 0.33),
                            ],
                            crate::line::Stroke::new((b / 8.0).max(1.5), t.on_accent),
                            NodeSpec::default(),
                        );
                    }
                });
            }
            Toggle::Switch => {
                let track = NodeSpec::row()
                    .size((b * 1.75).round(), b)
                    .pad(2.0)
                    .radius(b / 2.0)
                    .bg(if on { t.accent } else { t.border_strong })
                    .main_align(if on { Align::End } else { Align::Start })
                    .cross_align(Align::Center)
                    .transition(120.0);
                ui.with_keyed("track", track, |ui| {
                    let k = b - 4.0;
                    ui.leaf_keyed(
                        "knob",
                        NodeSpec::row()
                            .size(k, k)
                            .radius(k / 2.0)
                            .bg(t.on_accent)
                            .transition(120.0)
                            .slide(),
                    );
                });
            }
        }
        if !text.is_empty() {
            ui.text(text, TextStyle::new(m.control_text).color(t.fg));
        }
        if let Some(hint) = hint
            && ui.is_hovered(node)
        {
            tooltip(ui, hint);
        }
    })
}

/// The stock radio group's spec: a column of radios. What
/// [`radio_group_with`] is handed by [`radio_group`].
pub fn radio_group_spec(m: &Metrics) -> NodeSpec {
    NodeSpec::column().gap((control_box(m) / 2.0).round())
}

/// A radio group named `label`: one Tab stop whose arrows, Home and End
/// move the choice among the radios `f` declares and press the one they
/// land on, so a group of radios whose
/// payloads each set the choice answers the keyboard with no more code.
/// The role and the name are the group's whatever `spec` said; a `row`
/// spec lays the radios out across, and its arrows run across with it. A
/// spec with no gap takes [`radio_group_spec`]'s, so a binding that built
/// the spec from its rows — where `dir="row"` starts one from nothing —
/// gets the stock spacing without restating it.
pub fn radio_group_with(
    ui: &mut Ui<'_>,
    label: &str,
    spec: NodeSpec,
    f: impl FnOnce(&mut Ui<'_>),
) -> Key {
    let spec = radio_group_open_spec(&ui.metrics(), label, spec);
    ui.with_keyed(label, spec, f)
}

/// The spec a radio group named `label` opens with: `spec` with the
/// group's role and name, and the stock gap where it has none. What
/// [`radio_group_with`] opens, and what C's `kui_radio_group_open` does,
/// whose radios are declared between it and `kui_close`.
pub fn radio_group_open_spec(m: &Metrics, label: &str, spec: NodeSpec) -> NodeSpec {
    let mut spec = spec.role(Role::RadioGroup).label(label);
    if spec.layout.gap == 0.0 {
        spec.layout.gap = radio_group_spec(m).layout.gap;
    }
    spec
}

/// A radio group over named options: `current` is the one in force, and a
/// choice posts `payload(i)`. Each radio is keyed by its index, so two
/// options with one label are two radios.
pub fn radio_group(
    ui: &mut Ui<'_>,
    label: &str,
    options: &[&str],
    current: Option<usize>,
    payload: impl Fn(usize) -> Value,
) -> Key {
    let m = ui.metrics();
    radio_group_with(ui, label, radio_group_spec(&m), |ui| {
        for (i, option) in options.iter().enumerate() {
            let key = format!("{i}");
            toggle_with(
                ui,
                Toggle::Radio,
                &key,
                option,
                toggle_spec(&m)
                    .checked(current == Some(i))
                    .on_click(payload(i)),
                None,
            );
        }
    })
}

/// The stock slider's spec: a row as wide as a menu and as tall as its
/// thumb, padded by half the thumb on either side so the thumb's centre
/// is under the pointer at both ends — the content box is the track the
/// core reads a press along. A caller sizing its
/// own slider changes the width and keeps the padding.
pub fn slider_spec(m: &Metrics) -> NodeSpec {
    let b = control_box(m);
    NodeSpec::row()
        .size(m.menu_width, b)
        .pad_xy(b / 2.0, 0.0)
        .cross_align(Align::Center)
}

/// A slider named `label` over `min..=max`, at `value`, moving by `step`.
/// Its changes arrive as `{kind: "change", value, phase, tag}` with `tag`
/// — from the pointer, the arrows, the Page keys, Home / End and
/// assistive technology alike — and the view stores `value` and draws the
/// slider again at it.
pub fn slider(
    ui: &mut Ui<'_>,
    label: &str,
    value: f32,
    min: f32,
    max: f32,
    step: f32,
    tag: impl Into<Value>,
) -> Key {
    let m = ui.metrics();
    slider_with(
        ui,
        label,
        slider_spec(&m)
            .value_now(value)
            .value_min(min)
            .value_max(max)
            .value_step(step)
            .on_change(tag.into()),
        None,
    )
}

/// A slider with its spec in the caller's hands: [`slider_spec`] plus the
/// value rows (`value_now`, `value_min`, `value_max`, `value_step`,
/// `value_text`), `on_change`, `description`, `disabled`, a width, and
/// `hint`, the tooltip drawn while it is hovered. Keyed by `label`, which
/// is its accessible name unless the spec carries a `label` of its own.
/// The role is the slider's whatever the spec said. What `<slider>` and
/// its Lua and C doors lower to.
pub fn slider_with(ui: &mut Ui<'_>, label: &str, spec: NodeSpec, hint: Option<&str>) -> Key {
    let t = ui.theme();
    let m = ui.metrics();
    let node = ui.child_key(label);
    let ax = spec.access();
    let fraction = crate::slider::SliderRange::of(ax).map_or(0.0, |r| {
        let now = ax.value_now.map_or(r.min, crate::slider::exact);
        ((now - r.min) / (r.max - r.min)).clamp(0.0, 1.0) as f32
    });
    let named = ax.label.is_some();
    let disabled = spec.disabled;
    let mut spec = spec.role(Role::Slider);
    own_hint(&mut spec, hint);
    if !named {
        spec = spec.label(label);
    }
    if disabled {
        let o = spec.style.opacity * t.disabled_opacity;
        spec = spec.opacity(o);
    } else if spec.cursor.is_none() {
        spec = spec.cursor(CursorShape::Pointer);
    }
    let b = control_box(&m);
    ui.with_keyed(label, spec, |ui| {
        let track = NodeSpec::row()
            .grow_width()
            .height(4.0)
            .radius(2.0)
            .bg(t.border_strong);
        ui.with(track, |ui| {
            let fill = NodeSpec::row()
                .width(Sizing::Percent(fraction))
                .grow_height()
                .radius(2.0)
                .bg(t.accent);
            ui.with(fill, |ui| {
                // Hung off the fill's end, so it sits where the value is
                // with no arithmetic of the view's.
                ui.leaf(
                    NodeSpec::row()
                        .size(b, b)
                        .radius(b / 2.0)
                        .bg(t.on_accent)
                        .border(1.0, t.border_strong)
                        .float(
                            FloatConfig::parent()
                                .at(Align::End, Align::Center)
                                .self_at(Align::Center, Align::Center),
                        ),
                );
            });
        });
        if let Some(hint) = hint
            && ui.is_hovered(node)
        {
            tooltip(ui, hint);
        }
    })
}

// -- Splitter ---------------------------------------------------------------

/// A divider between two panes that the pointer drags:
/// `thickness` px across, growing along the rest of its parent, in the
/// theme's border colour and its accent while hovered or held, with the
/// resize arrows, and `tag` as its `on_drag`. `dir` is the parent's: in a
/// `Dir::Row` the panes sit side by side and the bar stands between them;
/// in a `Dir::Column` it lies across. A press on it leaves the keyboard
/// where it was (`keep_focus`), as a divider beside an editor should.
///
/// The split is the app's: `ev.drag()` on the tag's event, and
/// `Drag::ratio()` is the pointer's place across the parent — `.x` for a
/// row's split, `.y` for a column's — which is the new fraction as it is.
/// Returns the bar's key.
pub fn splitter(
    ui: &mut Ui<'_>,
    label: &str,
    dir: crate::spec::Dir,
    thickness: f32,
    tag: impl Into<Value>,
) -> Key {
    let t = ui.theme();
    let bar = match dir {
        crate::spec::Dir::Row => NodeSpec::column()
            .size(thickness, Sizing::GROW)
            .cursor(CursorShape::EwResize),
        crate::spec::Dir::Column => NodeSpec::column()
            .size(Sizing::GROW, thickness)
            .cursor(CursorShape::NsResize),
    };
    ui.leaf_keyed(
        label,
        bar.bg(t.border)
            .hover_bg(t.accent)
            .pressed_bg(t.accent)
            .on_drag(tag)
            .keep_focus(),
    )
}

// -- Context menus ----------------------------------------------------------
// The menu every app was writing for itself. It is
// exported rather than hidden inside the core's automatic path, and the
// automatic path calls exactly this — so an app that answers its own
// `onContextMenu` to add two items of its own gets the layout, the
// keyboard, the dismissal and the access rows without rewriting them, and
// the corpus tests one menu rather than two.

/// Menu chrome, in one place so a native renderer's absence still looks
/// deliberate rather than improvised.
pub const MENU_WIDTH: f32 = Metrics::comfortable().menu_width;
pub const MENU_TEXT: f32 = Metrics::comfortable().chrome_text;
/// The reserved label the stock menu is keyed under. A menu the core
/// opened is found by key, not by guessing at payloads, so an app is free
/// to post whatever it likes from its own items.
pub const MENU_KEY: &str = "kui.menu";

/// Draws a context menu at `at` (logical viewport px) and returns the key
/// of its root. A float anchored to the viewport rather than to a parent,
/// because a context menu belongs at the pointer and not under whatever
/// node happens to enclose it; `fit` is what keeps it in the window, which
/// for a menu near the bottom edge means flipping above the point.
///
/// It declares `modal`, so a press outside it or Escape emits a `dismiss`
/// event on it rather than through a dismissal rule of its own; the caller
/// closes it when that dismissal arrives. The rows are `menuItem`s under a
/// `menu`, which is what makes the arrow keys work and what a screen reader
/// reads.
///
/// Each chosen row posts the item's `id`, or its label when it declares
/// none. A `Separator` posts nothing and takes no focus.
pub fn context_menu(ui: &mut Ui<'_>, at: Vec2, items: &[MenuItem]) -> Key {
    let t = ui.theme();
    let m = ui.metrics();
    // The menu's nodes are the core's, not the host's: opened under their
    // own origin, so the core takes their events back by it.
    let saved = ui.origin();
    ui.set_origin(OriginId::MENU);
    // The menu floats against the window, not the host area (a menu the
    // platform showed would not stop at a dock's edge either, and the
    // devtools' own select opens one inside the dock): the host's point
    // becomes the window's.
    let at = at.plus(ui.core().dt_shift());
    let root = menu_panel(
        ui,
        MENU_KEY,
        menu_panel_spec(&t, &m)
            .float(
                FloatConfig::viewport()
                    // Top-left of the menu at the top-left of the
                    // viewport, then offset to the point: the placement
                    // every context menu has, with `fit` flipping it up
                    // or clamping it in when the point is near an edge.
                    .inside(Align::Start, Align::Start)
                    .offset(at.x, at.y)
                    .fit(),
            )
            .modal(Value::str(MENU_KEY))
            .label("Menu"),
        items,
    );
    ui.set_origin(saved);
    root
}

/// The panel every menu is: a fixed-width column of rows, in the palette
/// the stock menu paints. What the caller adds is where it goes and what
/// scope it belongs to — a context menu floats at the pointer and declares
/// its own `modal`; the menu bar's drops out of its title and lives inside
/// the bar's. Takes the palette and the metrics rather than reading them,
/// because a caller that has a `Ui` in one hand cannot lend it to this and
/// to `menu_panel` in the same expression; `let t = ui.theme();` first is
/// the idiom.
pub fn menu_panel_spec(t: &Theme, m: &Metrics) -> NodeSpec {
    NodeSpec::column()
        .role(Role::Menu)
        // As wide as its widest row and never narrower than the metric: a
        // long accelerator beside a long label widens the menu rather than
        // wrapping either onto a second line (backlog F127). Rows grow to
        // the panel, so their right edges line up.
        .width(Sizing::Fit)
        .min_width(m.menu_width)
        .pad(MENU_PANEL_PAD)
        .gap(1.0)
        .bg(t.raised)
        .border(1.0, t.border_strong)
        .radius(m.radius)
}

/// Builds the rows of one menu into `spec`, keyed under `label`, and
/// reports the keys they took. The one place a menu's rows are drawn:
/// both menus kui has are this function with a different container.
///
/// A row with a submenu ([`MenuItem::submenu`]) is drawn with a chevron.
/// In the core's own menus — the context menu and the drawn bar's — its
/// menu opens beside it, as another of these panels, when the pointer
/// rests on it, it is clicked, or the keyboard opens it (Enter, the Right
/// arrow); an app drawing this panel itself gets the chevron and opens
/// nothing, since the open submenus are the core's state.
pub fn menu_panel(ui: &mut Ui<'_>, label: &str, spec: NodeSpec, items: &[MenuItem]) -> Key {
    menu_level(ui, label, label, spec, items, &[])
}

/// One level of a menu: `items` built into `spec` under `label`, at `path`
/// — empty for the menu itself, the rows opened on the way for a submenu.
/// `root` is the outermost panel's label, which names the hover groups.
fn menu_level(
    ui: &mut Ui<'_>,
    root: &str,
    label: &str,
    spec: NodeSpec,
    items: &[MenuItem],
    path: &[usize],
) -> Key {
    let t = ui.theme();
    let m = ui.metrics();
    // Never wider than the window, less a margin each side: a row wider
    // than that — a recent file's whole path — ellipsizes its label
    // instead of running the panel, and its accelerator, off the edge
    // (backlog RG150). A ceiling the caller declared that is narrower
    // stands.
    let ceiling = (ui.viewport().w - 2.0 * MENU_EDGE).max(m.menu_width);
    let spec = if spec.layout.max_w >= 0.0 && spec.layout.max_w > ceiling {
        spec.max_width(ceiling)
    } else {
        spec
    };
    // What a row's label may take of it: the panel's inside, less the
    // row's padding, the gutter and the accelerator or chevron with their
    // gaps. A row is sized from its content (the panel is `Fit` over its
    // rows), so the label is what is bounded, and it ellipsizes there. A
    // ceiling the caller declared as a size expression is read against
    // the window where that is the panel's room — a float anchored to the
    // viewport, as the core's own menus are — and left to the window's
    // ceiling where layout will read it against a parent this build has
    // not placed yet (backlog RG154).
    let in_viewport = spec
        .layout
        .float
        .is_some_and(|f| f.anchor == crate::spec::FloatAnchor::Viewport);
    let cap = if spec.layout.max_w >= 0.0 {
        spec.layout.max_w
    } else if let Some(calc) = crate::spec::max_calc(spec.layout.max_w)
        && in_viewport
    {
        calc.resolve(ui.viewport().w).min(ceiling)
    } else {
        ceiling
    };
    let inside = cap - 2.0 * (MENU_PANEL_PAD + 1.0) - 2.0 * m.menu_pad_x;
    // A wash rather than a fill, so a row's label stays readable on both
    // bases without the view guessing a frame ahead of the core — see
    // `Theme::accent_soft`.
    let accent = t.accent_soft;
    // A gutter for the checkmarks, and only where a row has one: a menu of
    // plain commands is not indented for a column nothing uses, and one
    // with a setting in it keeps every label on the same left edge whether
    // the setting is on or off.
    let gutter = items.iter().any(|i| i.checked);
    // Which of the core's menus this is, by the origin its nodes open
    // under: only those have submenu state to open and close.
    let surface = crate::runtime::MenuSurface::of(ui.origin());
    let group = |i: usize| format!("{root}/{path:?}/{i}");
    // Where the pointer rests opens or closes a submenu, resolved before
    // anything is built — the frame that notices the hover draws what it
    // opened, as the bar's titles do. The outermost level brackets the
    // build, so a build with the pointer on no row at all is known.
    if let Some(s) = surface
        && path.is_empty()
    {
        ui.core().submenu_pass(s, true);
    }
    if let Some(s) = surface {
        for (i, item) in items.iter().enumerate() {
            if item.selectable() && ui.is_group_hovered(NodeSpec::hover_group_id(&group(i))) {
                let row: Vec<usize> = path.iter().copied().chain([i]).collect();
                ui.core().submenu_hovered(s, &row, item.has_submenu());
            }
        }
    }
    let open_here = surface.and_then(|s| ui.core().submenu_open_at(s, path));
    let mut first_key = None;
    let root_key = ui.with_keyed(label, spec, |ui| {
        let mut first = path.is_empty();
        for (i, item) in items.iter().enumerate() {
            if item.role == MenuRole::Separator {
                ui.leaf_indexed(
                    i as u64,
                    NodeSpec::row()
                        .grow_width()
                        .height(1.0)
                        .bg(t.border)
                        // Not a row anything reads out: a divider is
                        // paint, and a screen reader hearing "separator"
                        // between every pair of items is noise.
                        .role(Role::None),
                );
                continue;
            }
            // The row posts which item it is; the core takes the event back
            // by origin, performs the item — or opens its submenu — and
            // what the app hears is the item's own `id` on the node the
            // menu was about.
            let payload = menu_row_tag(i, path);
            let opens = item.has_submenu();
            let is_open = opens && open_here == Some(i);
            let mut spec = NodeSpec::row()
                .role(Role::MenuItem)
                .label(item.text())
                .grow_width()
                // Its content as a floor, which is what a fit panel is
                // sized from: a grow child alone contributes nothing.
                .min_width(crate::spec::Bound::Fit)
                .pad_xy(m.menu_pad_x, m.menu_pad_y)
                .gap(MENU_ROW_GAP)
                .radius(m.radius_inner)
                .main_align(Align::Start)
                .cross_align(Align::Center);
            if item.checked {
                // The gutter's checkmark is paint; this is the same fact for
                // a screen reader, which reads a row that carries one as
                // checked rather than as "✓ Wrap".
                spec = spec.checked(true);
            }
            if item.enabled {
                spec = spec.on_click(payload).hover_bg(accent).focus_bg(accent);
                if surface.is_some() {
                    spec = spec.hover_group(&group(i));
                }
                // The first row that can take focus is where the modal opens:
                // a menu whose keyboard starts nowhere makes the arrow keys
                // feel like they missed. A submenu is no modal of its own;
                // the keyboard that opens one puts focus in it.
                if first {
                    spec = spec.initial_focus();
                    first = false;
                }
            } else {
                spec = spec.disabled(true).opacity(t.disabled_opacity);
            }
            if opens {
                // A reader hears it as a row that opens something, and
                // whether it is open; the open one keeps the wash while the
                // pointer is in its submenu, so the way back is visible.
                spec = spec.expanded(is_open);
                if is_open {
                    spec = spec.bg(accent);
                }
            }
            let tail = if opens {
                Some(std::borrow::Cow::Borrowed(MENU_CHEVRON))
            } else {
                item.accel_label()
            };
            let row = |ui: &mut Ui<'_>| {
                let tail_style = TextStyle::new(m.chrome_text).color(t.muted).nowrap();
                let mut label_max = inside;
                if gutter {
                    label_max -= MENU_CHECK_W + MENU_ROW_GAP;
                }
                // The accelerator takes what the label's floor leaves it,
                // and ellipsizes past that: in a window narrower than the
                // accelerator and its gaps, the label used to shrink to
                // nothing and the accelerator ran past the panel anyway
                // (backlog RG154).
                let tail_max =
                    (label_max - 2.0 * MENU_ROW_GAP - MENU_ACCEL_GAP - MENU_LABEL_MIN).max(0.0);
                if let Some(tail) = &tail {
                    let w = ui.measure_text(tail, &tail_style, None).width.min(tail_max);
                    label_max -= 2.0 * MENU_ROW_GAP + MENU_ACCEL_GAP + w;
                }
                if gutter {
                    ui.with(NodeSpec::row().width(MENU_CHECK_W), |ui| {
                        if item.checked {
                            ui.text("\u{2713}", TextStyle::new(m.chrome_text).color(t.fg));
                        }
                    });
                }
                // One line each, whatever the panel's width: the panel is
                // sized to fit them, and a row that wrapped would be read as
                // two.
                ui.text_in(
                    NodeSpec::row().max_width(label_max.max(0.0)),
                    item.text(),
                    TextStyle::new(m.chrome_text)
                        .color(t.fg)
                        .nowrap()
                        .ellipsis(),
                );
                // Pushed to the right edge by a grow spacer, so the label
                // stays where the eye expects it whatever follows it; at
                // least `MENU_ACCEL_GAP` wide, so the widest label and the
                // widest accelerator never touch. A submenu's row has its
                // chevron there and no accelerator: it binds nothing.
                if let Some(tail) = &tail {
                    ui.leaf(NodeSpec::row().grow_width().min_width(MENU_ACCEL_GAP));
                    ui.text_in(
                        NodeSpec::row().max_width(tail_max),
                        tail,
                        tail_style.ellipsis(),
                    );
                }
            };
            let key = if opens {
                // A wrapper the submenu drops out of, so the panel is the
                // row's *sibling* — the menu bar's reason: a `menuItem` is
                // named from its content, and a menu inside one would be
                // read as part of its name — and floats against the row's
                // own box.
                let mut key = Key::ROOT;
                ui.with_indexed(
                    i as u64,
                    NodeSpec::row()
                        .grow_width()
                        .min_width(crate::spec::Bound::Fit),
                    |ui| {
                        key = ui.with_keyed(MENU_ROW_KEY, spec, row);
                        if is_open && item.enabled {
                            let sub: Vec<usize> = path.iter().copied().chain([i]).collect();
                            menu_level(
                                ui,
                                root,
                                MENU_SUB_KEY,
                                menu_panel_spec(&t, &m)
                                    .label(item.text())
                                    // A region of its own, so a press on its
                                    // padding or a dead row is inside the
                                    // menu — as the outer panel's `modal`
                                    // makes it there — and not the press
                                    // outside that dismisses it.
                                    .hoverable()
                                    .float(
                                        // Beside the row, its first row level
                                        // with this one (the panel's padding
                                        // above it), flipped to the other side
                                        // at the window's edge.
                                        FloatConfig::parent()
                                            .at(Align::End, Align::Start)
                                            .self_at(Align::Start, Align::Start)
                                            .offset(MENU_PANEL_PAD, -MENU_PANEL_PAD)
                                            .fit(),
                                    ),
                                &item.submenu,
                                &sub,
                            );
                        }
                    },
                );
                key
            } else {
                ui.with_indexed(i as u64, spec, row)
            };
            if item.enabled && first_key.is_none() {
                first_key = Some(key);
            }
        }
    });
    // The keyboard opened this submenu (Enter, the Right arrow): the
    // first row it can take is where it lands, now that it exists.
    if let (Some(s), Some(key)) = (surface, first_key)
        && !path.is_empty()
    {
        ui.core().submenu_drawn(s, path, key);
    }
    if let Some(s) = surface
        && path.is_empty()
    {
        ui.core().submenu_pass(s, false);
    }
    root_key
}

/// The padding inside a menu's panel, logical px: what a submenu is offset
/// by so its first row sits level with the row that opened it.
const MENU_PANEL_PAD: f32 = 4.0;

/// How far a menu at its widest stays from each side of the window.
pub const MENU_EDGE: f32 = 8.0;

/// The least a row's label keeps when its accelerator would take the rest:
/// a few glyphs and the ellipsis at the chrome size. Past this the
/// accelerator is what ellipsizes.
pub const MENU_LABEL_MIN: f32 = 48.0;

/// Between a row's checkmark, label, spacer and accelerator.
const MENU_ROW_GAP: f32 = 8.0;

/// The chevron a submenu's row draws where an accelerator would be.
pub const MENU_CHEVRON: &str = "\u{203a}";
/// The label a submenu's row is keyed under, inside its wrapper.
const MENU_ROW_KEY: &str = "row";
/// The label a submenu's panel is keyed under, beside its row.
const MENU_SUB_KEY: &str = "sub";

/// What a menu row's click carries: its index in its menu's items, and —
/// for a row of a submenu — the rows opened on the way to it, for the core
/// to read back (`Core::take_surface_events`). The title of a menu-bar
/// menu carries its index the same way, under `title`. A top-level row
/// carries `row` alone, as it always did.
fn menu_row_tag(i: usize, path: &[usize]) -> Value {
    let row = ("row", Value::Int(i as i64));
    if path.is_empty() {
        Value::map([row])
    } else {
        Value::map([
            row,
            (
                "path",
                Value::List(path.iter().map(|&p| Value::Int(p as i64)).collect()),
            ),
        ])
    }
}

fn menu_title_tag(i: usize) -> Value {
    Value::map([("title", Value::Int(i as i64))])
}

/// The reserved label the drawn menu bar is keyed under, the way
/// [`MENU_KEY`] is the open menu's.
pub const MENU_BAR_KEY: &str = "kui.menubar";
/// The label its dropped menu is keyed under, beside the open title.
const MENU_BAR_PANEL_KEY: &str = "kui.menubar.menu";
/// The label each title is keyed under, inside its own wrapper.
const MENU_BAR_TITLE_KEY: &str = "kui.menubar.title";

/// The hover group a title and its menu share, so the widget can ask
/// whether the pointer is on the `i`th title without knowing its key.
fn group_name(i: usize) -> String {
    format!("{MENU_BAR_KEY}.{i}")
}
/// The bar's height, logical px — a little under a titlebar's, which is
/// what every platform that draws one in the window does.
pub const MENU_BAR_H: f32 = Metrics::comfortable().menu_bar_h;

/// The application menu: `bar` is what the app's menu *is*, and calling
/// this is where its titles go when they have to be drawn in the window.
///
/// One call and not two, because the declaration and the placement are one
/// decision. **It draws nothing where the platform owns the bar** — macOS,
/// where the driver hands this same declaration to `NSApp` — so the call
/// still says what the menu is and the strip simply is not there; that is
/// the contract [`window_buttons`] has under native decorations, and it is
/// what makes one view portable. An empty `bar` takes the menu away.
///
/// Declared every frame, and diffed: an unchanged menu costs a comparison
/// and rebuilds nothing.
///
/// Everything below a title is the stock menu: the same rows, roles,
/// accelerators and access tree the context menu draws, through the same
/// [`menu_panel`]. What is the bar's own is the scope — while a menu is
/// open the *bar* is the frame's modal, not the dropdown, so hovering
/// across the titles moves the open menu the way a menu bar does, a press
/// on the open title closes it, and Escape or a press in the app below
/// dismisses it as any modal is dismissed.
///
/// Typical use, as the first child of a full-height root, under the
/// titlebar if there is one:
/// `widgets::menu_bar(ui, self.menu());`
pub fn menu_bar(ui: &mut Ui<'_>, bar: MenuBar) {
    // Declaring it is this call's first half, and drawing it the second:
    // where the platform owns the bar there is no second half, and the
    // frame has still said what the app's menu is.
    ui.core().declare_menu_bar(bar);
    if ui.core().native_menu_bar() {
        return;
    }
    let Some(bar) = ui.core().menu_bar().cloned() else {
        return;
    };
    if bar.menus.is_empty() {
        return;
    }
    let t = ui.theme();
    let m = ui.metrics();
    let accent = t.accent_soft;
    let mut open = ui.core().menu_bar_open();
    // The bar's nodes are the core's, opened under their own origin (see
    // `OriginId::MENU_BAR`), so the core takes their events back by it.
    let saved = ui.origin();
    ui.set_origin(OriginId::MENU_BAR);
    let mut spec = NodeSpec::row()
        .grow_width()
        .height(m.menu_bar_h)
        .cross_align(Align::Center)
        .pad_xy(4.0, 0.0)
        .gap(2.0)
        .bg(t.bg)
        .role(Role::Menu)
        .label("Menu bar");
    if open.is_some() {
        // The bar and not the dropdown is the modal while a menu is open:
        // the titles have to stay live for the hover to walk them, and the
        // app below has to be as inert as it is under any other menu.
        spec = spec.modal(Value::str(MENU_BAR_KEY));
    }
    let root = ui.with_keyed(MENU_BAR_KEY, spec, |ui| {
        // Hovering another title while a menu is open moves the open menu
        // to it, which is what a menu bar does everywhere. Resolved before
        // anything is built, so the frame that notices the hover is the
        // frame that draws the new menu and not the one after it — and
        // asked by *group* rather than by key, since a title's key is
        // inside a wrapper this loop has not opened yet.
        if open.is_some() {
            for (i, menu) in bar.menus.iter().enumerate() {
                let hovered = ui.is_group_hovered(NodeSpec::hover_group_id(&group_name(i)));
                if open != Some(i) && menu.enabled && !menu.items.is_empty() && hovered {
                    open = Some(i);
                    ui.core().set_menu_bar_open(open);
                }
            }
        }
        for (i, menu) in bar.menus.iter().enumerate() {
            let live = menu.enabled && !menu.items.is_empty();
            let is_open = open == Some(i);
            // A wrapper the menu drops out of, so the panel is a *sibling*
            // of the title and not a child of it: a `menuItem` is a
            // name-from-content role, and a menu nested inside one would be
            // read as part of its name and never reached on its own.
            ui.with_indexed(i as u64, NodeSpec::row(), |ui| {
                let mut spec = NodeSpec::row()
                    .role(Role::MenuItem)
                    .label(menu.label.as_str())
                    // Two px shorter than a row's, so the bar's height and
                    // not the title's padding decides the strip.
                    .pad_xy(m.menu_pad_x, (m.menu_pad_y - 2.0).max(0.0))
                    .radius(m.radius_inner)
                    .cross_align(Align::Center);
                if live {
                    // Which title this is: the core takes the event back
                    // by origin and opens or closes the `i`th menu.
                    spec = spec
                        .on_click(menu_title_tag(i))
                        .hover_group(&group_name(i))
                        .hover_bg(accent)
                        .focus_bg(accent);
                    if is_open {
                        spec = spec.bg(accent);
                    }
                } else {
                    spec = spec.disabled(true).opacity(t.disabled_opacity);
                }
                ui.text_in_keyed(
                    MENU_BAR_TITLE_KEY,
                    spec,
                    menu.label.as_str(),
                    TextStyle::new(m.chrome_text).color(t.fg),
                );
                if is_open {
                    // Out of the title's bottom-left corner, and `fit` to
                    // slide back in at the right-hand end of the bar.
                    menu_panel(
                        ui,
                        MENU_BAR_PANEL_KEY,
                        menu_panel_spec(&t, &m).label(menu.label.as_str()).float(
                            FloatConfig::parent()
                                .at(Align::Start, Align::End)
                                .self_at(Align::Start, Align::Start)
                                .offset(0.0, 2.0)
                                .fit(),
                        ),
                        &menu.items,
                    );
                }
            });
        }
    });
    ui.set_origin(saved);
    ui.core().set_menu_bar_root(root);
}

/// The checkmark gutter's width, logical px.
const MENU_CHECK_W: f32 = 14.0;

/// The least room between a row's label and its accelerator, logical px,
/// beside the row's own gap on either side: about what AppKit leaves
/// before a key equivalent.
pub const MENU_ACCEL_GAP: f32 = 16.0;

// -- Virtual lists ----------------------------------------------------------
// The core culls glyphs by viewport but builds every child a view declares,
// so a ten-thousand-row log costs ten thousand rows of build and layout on
// every frame — most of a 120 Hz budget spent on rows nobody can see. A view
// that knows the container's height and offset can declare a screenful and
// two spacers instead. `Core::scroll_geometry` is that knowledge; this is
// the arithmetic, for the case where every row is the same height.

/// The half-open range of rows a container of `rows` rows, each `row_h`
/// logical px tall, has any reason to build — those crossing the visible
/// band, plus `overscan` on each side — given the geometry of the frame
/// before. Pure arithmetic, exposed for views that build their own
/// container instead of using [`uniform_list`].
///
/// `vh` is the container's height, and `pad_t` the padding above the first
/// row. `None` geometry means no layout has resolved the container yet:
/// the caller decides what the first frame builds.
pub fn visible_rows(
    offset_y: f32,
    vh: f32,
    pad_t: f32,
    row_h: f32,
    rows: usize,
    overscan: usize,
) -> std::ops::Range<usize> {
    if rows == 0 || row_h <= 0.0 {
        return 0..0;
    }
    // Flow coordinates: row i spans [i*row_h, (i+1)*row_h), and layout puts
    // the flow's origin at pad_t - offset_y inside the container's box, so
    // the visible window is [offset_y - pad_t, that + vh).
    let top = offset_y - pad_t;
    let first = (top / row_h).floor().max(0.0) as usize;
    let last = ((top + vh.max(0.0)) / row_h).ceil().max(0.0) as usize;
    let first = first.saturating_sub(overscan).min(rows);
    let last = last.saturating_add(overscan).min(rows);
    first..last.max(first)
}

/// A vertically scrolling column of `rows` uniform rows that builds only the
/// visible ones. `row(ui, i)` declares row `i`; it must come out exactly
/// `row_h` logical px tall, since that is the arithmetic placing every row
/// above and below it.
///
/// The container is `spec` forced to a scrolling column with no gap — put
/// the spacing inside `row_h` (a row that pads itself) rather than in a
/// `gap`, so one number describes the stride. Rows are opened with
/// [`Ui::open_indexed`] at their *data* index, so a row keeps its key, and
/// with it its hover, focus, edit buffer and tweens, as the built range
/// slides over it. Above and below sit two empty spacers holding the space
/// of the rows not built, so the content height, the scrollbar and
/// `set_scroll` all behave as if the whole list were there.
///
/// The geometry it slices by is the previous frame's, so the first frame —
/// before any layout has resolved the container — slices by the viewport
/// height instead and asks for one more frame; a resize is one frame late
/// and covered by the two rows of overscan. Returns the container's key,
/// for `set_scroll` (`Vec2::new(0.0, i as f32 * row_h)` scrolls row `i` to
/// the top, which is how you reach a row that is not built — `reveal` of an
/// unbuilt row finds nothing).
pub fn uniform_list(
    ui: &mut Ui<'_>,
    label: &str,
    spec: NodeSpec,
    rows: usize,
    row_h: f32,
    row: impl FnMut(&mut Ui<'_>, usize),
) -> Key {
    uniform_list_with(ui, label, spec, rows, row_h, |_| NodeSpec::column(), row)
}

/// [`uniform_list`] with each row's own node spelled by `row_spec(i)` —
/// the click, the zebra stripe, the hover background, the role a row
/// carries — where the plain form's rows are bare and the callback nests
/// a second node inside each to carry them. The height is
/// forced to `row_h`, the stride the arithmetic assumes, and a width the
/// spec leaves `fit` grows across the list.
pub fn uniform_list_with(
    ui: &mut Ui<'_>,
    label: &str,
    spec: NodeSpec,
    rows: usize,
    row_h: f32,
    mut row_spec: impl FnMut(usize) -> NodeSpec,
    mut row: impl FnMut(&mut Ui<'_>, usize),
) -> Key {
    const OVERSCAN: usize = 2;

    let key = ui.child_key(label);
    let pad_t = spec.layout.padding.t;
    // Both numbers from the same frame: the geometry's offset is clamped to
    // that frame's travel, so a `set_scroll(key, huge)` between frames
    // slices the end of the list instead of a megabyte past it.
    let (offset_y, vh, first_frame) = match ui.scroll_geometry(key) {
        Some(g) => (g.offset.y, g.rect.h, false),
        // Nothing laid out yet: the container cannot be taller than the
        // window in the ordinary case, so a screenful is a safe over-build
        // for one frame.
        None => (ui.scroll_offset(key).y, ui.viewport().h, true),
    };
    let range = visible_rows(offset_y, vh, pad_t, row_h, rows, OVERSCAN);

    ui.with_keyed(label, spec.scroll_y().gap(0.0), |ui| {
        // The whole list's size, built or not: what Select All inside a
        // `selectable` list spans.
        ui.row_count(rows as u64);
        // Keyed, not auto-keyed: an auto key is a sibling index, and the
        // rows already occupy that namespace at their data indices — an
        // auto-keyed spacer next to a built row 0 would be row 0's key.
        let lead = range.start as f32 * row_h;
        if lead > 0.0 {
            ui.leaf_keyed("lead", spacer_spec(lead));
        }
        for i in range.clone() {
            let mut spec = row_spec(i).height(row_h);
            if spec.layout.width == Sizing::Fit {
                spec = spec.grow_width();
            }
            ui.with_indexed(i as u64, spec, |ui| row(ui, i));
        }
        let tail = (rows - range.end) as f32 * row_h;
        if tail > 0.0 {
            ui.leaf_keyed("tail", spacer_spec(tail));
        }
    });

    // Sliced by a screenful's guess, with no layout of its own yet: the
    // next frame slices by its geometry. kui's ask, not the app's, so a
    // trace names it for what it is.
    if first_frame {
        ui.owe_frame("list first frame");
    }
    key
}

/// Scrolls the [`uniform_list`] labelled `label` so row `i` shows, when
/// it does not already: to the middle of the list, so a jump lands with
/// rows on both sides of it. Call it before the list is
/// declared, in the same parent — the frame that scrolls then slices its
/// rows by the offset it scrolls to, instead of a frame late. Returns
/// whether it scrolled. The first frame, before the list has laid out,
/// has no geometry and scrolls nothing; the row arithmetic assumes the
/// list's rows start at its content top and fill its box, as they do
/// without padding. A row past the list's content — an index past its
/// end — scrolls nothing and answers false, as does a `row_h` that is
/// not positive.
///
/// `Ui::reveal` cannot do this for a row that is not built, and a
/// virtual list builds only what shows.
pub fn reveal_row(ui: &mut Ui<'_>, label: &str, i: usize, row_h: f32) -> bool {
    let key = ui.child_key(label);
    let Some(g) = ui.scroll_geometry(key) else {
        return false;
    };
    let y = i as f32 * row_h;
    if row_h <= 0.0 || y + row_h > g.content.h + 0.5 {
        return false;
    }
    if g.offset.y <= y && y + row_h <= g.offset.y + g.rect.h {
        return false;
    }
    let to = (y + row_h / 2.0 - g.rect.h / 2.0).max(0.0);
    ui.set_scroll(key, Vec2::new(g.offset.x, to));
    true
}

/// How many whole rows of `row_h` the [`uniform_list`] labelled `label`
/// shows as of the last layout — a PageDown's stride. 0 before it has
/// laid out, and for a `row_h` that is not positive.
pub fn rows_in_view(ui: &mut Ui<'_>, label: &str, row_h: f32) -> usize {
    let key = ui.child_key(label);
    if row_h <= 0.0 {
        return 0;
    }
    ui.scroll_geometry(key)
        .map_or(0, |g| (g.rect.h / row_h).floor().max(0.0) as usize)
}

/// Each row's own node in the variable-height `list`: the wrapper the
/// callback builds inside, sized to the height the arithmetic assumes.
fn row_spec(h: f32) -> NodeSpec {
    NodeSpec::column().grow_width().height(h)
}

fn spacer_spec(h: f32) -> NodeSpec {
    NodeSpec::column().grow_width().height(h)
}

// -- Variable-height virtual lists ------------------------------------------
// `uniform_list` takes one stride and every row must come out that tall,
// which is the log viewer, the data table and the chat history whose rows are
// one line. A row that wraps, a card with an image, a message that is
// sometimes three lines: none of those have a stride, and the three things
// the uniform arithmetic does with `i * row_h` — the lead spacer, the search
// from an offset to the first visible row, and "scroll to row i" — have no
// closed form without one. Prefix sums are the closed form, and
// `RowHeights` is where they live.
//
// Heights come from the caller, measured only for the rows the frame needs:
// `measure_text` gives layout's own number for a text row (wrap, max_lines
// and the shaping cache included), so a row measured and then drawn shapes
// once. Everything not measured yet stands at an estimate, and the estimate
// is the mean of what has been measured — which means it *moves*, and moving
// it changes the height of every row above the window as well as below.
// That is what the anchor is for.

/// The heights a [`list`] slices by: a measured number per row
/// where one is known, an estimate everywhere else, and the prefix sums over
/// both.
///
/// The app owns it and hands the same one back every frame — a widget
/// composed from primitives keeps no state of its own, which is what keeps
/// it reachable from a scripting frontend. Rebuild it (or [`Self::clear`])
/// when the rows themselves change.
#[derive(Clone, Debug)]
pub struct RowHeights {
    /// One per row; `f32::NAN` for a row nothing has measured yet.
    h: Vec<f32>,
    /// The prefix sums, split so that the estimate is applied at the query
    /// rather than baked in: `m[i]` is the measured height in rows `0..i`
    /// and `u[i]` how many of those rows have none. A moving mean then costs
    /// nothing to fold in — which matters, because every measurement moves
    /// it, and a mean baked into the sums would dirty all of them.
    m: Vec<f32>,
    u: Vec<u32>,
    /// How many entries of `m` / `u` are valid, counting from 0. Filled
    /// on demand and only as far as a query asks, so a list scrolled to row
    /// 30 never sums the 9,970 below it; a measurement at row `i` truncates
    /// this to `i + 1`, since nothing at or below `i` changed.
    clean: usize,
    /// What the caller guessed before anything was measured.
    seed: f32,
    /// Running mean of the measured rows — the estimate for the rest.
    sum: f32,
    n: usize,
    /// The content width the cached heights were measured at. A different
    /// one rewraps every row, so it drops them all.
    width: f32,
    /// Where the last search landed. Scrolling is local, so the next one
    /// gallops out from here instead of bisecting the whole list — which is
    /// what keeps the lazy `ensure` above from being filled past what is
    /// being looked at, and what a bisection from 0..len would defeat by
    /// probing the middle every time.
    last: usize,
}

impl RowHeights {
    /// `rows` rows, none measured, each standing at `estimate` logical px
    /// until it is. The estimate only has to be the right order of
    /// magnitude: it decides how wrong the scrollbar is before the list has
    /// been scrolled through, and nothing else.
    pub fn new(rows: usize, estimate: f32) -> Self {
        RowHeights {
            h: vec![f32::NAN; rows],
            m: vec![0.0],
            u: vec![0],
            clean: 1,
            seed: estimate.max(1.0),
            sum: 0.0,
            n: 0,
            width: f32::NAN,
            last: 0,
        }
    }

    pub fn len(&self) -> usize {
        self.h.len()
    }

    pub fn is_empty(&self) -> bool {
        self.h.is_empty()
    }

    /// Grows or shrinks to `rows`, keeping what is still in range — rows
    /// appended to a log keep every height already measured, and cost
    /// nothing until something asks about them. A list whose rows *changed*
    /// rather than grew wants [`Self::clear`].
    pub fn set_len(&mut self, rows: usize) {
        if rows == self.h.len() {
            return;
        }
        for i in rows..self.h.len() {
            self.forget(i);
        }
        self.h.resize(rows, f32::NAN);
        self.clean = self.clean.min(rows + 1);
    }

    /// Forgets every measurement, keeping the length and the seed — the call
    /// for a list whose contents changed under the same indices.
    pub fn clear(&mut self) {
        self.h.fill(f32::NAN);
        self.sum = 0.0;
        self.n = 0;
        self.clean = 1;
    }

    /// Records row `i`'s height. Rows measured this way are what the
    /// estimate for the others is the mean of.
    pub fn set(&mut self, i: usize, h: f32) {
        if i >= self.h.len() || !h.is_finite() || h < 0.0 {
            return;
        }
        self.forget(i);
        self.h[i] = h;
        self.sum += h;
        self.n += 1;
        // Everything up to and including row `i`'s own top is unchanged.
        self.clean = self.clean.min(i + 1);
    }

    fn forget(&mut self, i: usize) {
        let old = self.h[i];
        if !old.is_nan() {
            self.sum -= old;
            self.n -= 1;
            self.h[i] = f32::NAN;
            self.clean = self.clean.min(i + 1);
        }
    }

    /// Row `i`'s height as it was measured, or `None` for one standing at
    /// the estimate.
    pub fn measured(&self, i: usize) -> Option<f32> {
        self.h.get(i).copied().filter(|h| !h.is_nan())
    }

    /// Row `i`'s height: measured, or the estimate.
    pub fn get(&self, i: usize) -> f32 {
        self.measured(i).unwrap_or_else(|| self.estimate())
    }

    /// What an unmeasured row stands at: the mean of the measured ones, or
    /// the caller's seed before there are any.
    pub fn estimate(&self) -> f32 {
        if self.n == 0 {
            self.seed
        } else {
            self.sum / self.n as f32
        }
    }

    /// The width the measurements were taken at, or `NaN` before any.
    pub fn width(&self) -> f32 {
        self.width
    }

    /// Declares the content width the next measurements are for. A width
    /// that differs from the cached one drops every height — the rows wrap
    /// differently now — and returns true. [`list`] calls this from
    /// the container's own laid-out box.
    pub fn set_width(&mut self, w: f32) -> bool {
        if !w.is_finite() || w <= 0.0 || (self.width - w).abs() < 0.5 {
            return false;
        }
        let had = self.n > 0;
        self.width = w;
        if had {
            self.clear();
        }
        true
    }

    /// Fills the prefix sums up to `i` if they do not reach it yet.
    fn ensure(&mut self, i: usize) {
        let want = i.min(self.h.len()) + 1;
        if self.clean >= want {
            return;
        }
        self.m.truncate(self.clean);
        self.u.truncate(self.clean);
        self.m.reserve(want - self.clean);
        self.u.reserve(want - self.clean);
        let (mut acc, mut est) = (self.m[self.clean - 1], self.u[self.clean - 1]);
        for &h in &self.h[self.clean - 1..want - 1] {
            if h.is_nan() {
                est += 1;
            } else {
                acc += h;
            }
            self.m.push(acc);
            self.u.push(est);
        }
        self.clean = want;
    }

    /// The top of row `i` in content coordinates — the height of everything
    /// above it. `offset_of(len())` is the whole list's height.
    pub fn offset_of(&mut self, i: usize) -> f32 {
        let i = i.min(self.h.len());
        self.ensure(i);
        self.m[i] + self.u[i] as f32 * self.estimate()
    }

    /// The list's total height, measured and estimated together — what the
    /// two spacers and the scrollbar are made of. Kept as it goes, so the
    /// tail spacer costs nothing however long the list is.
    pub fn total(&self) -> f32 {
        self.sum + (self.h.len() - self.n) as f32 * self.estimate()
    }

    /// The row `y` (content coordinates) lands in: the last row whose top is
    /// at or above it, clamped to the list. The binary search that replaces
    /// `y / row_h`.
    pub fn row_at(&mut self, y: f32) -> usize {
        let rows = self.h.len();
        if rows == 0 || y <= 0.0 {
            self.last = 0;
            return 0;
        }
        // `offset_of` is non-decreasing, so what is wanted is the last row
        // whose top is at or below `y`. Row 0's top is 0, so it always
        // qualifies and the bracket below always closes.
        let mut lo = self.last.min(rows - 1);
        let mut hi;
        if self.offset_of(lo) > y {
            hi = lo;
            let mut step = 1usize;
            while lo > 0 {
                lo = lo.saturating_sub(step);
                if self.offset_of(lo) <= y {
                    break;
                }
                hi = lo;
                step *= 2;
            }
        } else {
            hi = (lo + 1).min(rows);
            let mut step = 1usize;
            while hi < rows && self.offset_of(hi) <= y {
                lo = hi;
                hi = (hi + step).min(rows);
                step *= 2;
            }
        }
        while lo + 1 < hi {
            let mid = lo + (hi - lo) / 2;
            if self.offset_of(mid) <= y {
                lo = mid;
            } else {
                hi = mid;
            }
        }
        self.last = lo;
        lo
    }
}

/// A vertically scrolling column of rows of *different* heights that builds
/// only the visible ones — [`uniform_list`] where no single stride
/// describes the list.
///
/// `measure(ui, i, width)` returns row `i`'s height at that content width,
/// and is called only for rows the frame is about to build that `heights`
/// has no number for; `ui.measure_text(.., Some(width))` is layout's own
/// answer for a text row, wrap and all, and shapes through the same cache
/// the row's draw will hit. What it returns is the height the row *gets*:
/// each row's node is fixed to it, so the arithmetic above and below can
/// never disagree with the layout, the way `uniform_list`'s stride cannot.
/// A row that would rather size itself has to say what that size is here.
///
/// `row(ui, i)` declares row `i` inside that node, exactly as
/// `uniform_list`'s does, and rows are opened with [`Ui::open_indexed`] at
/// their data index, so a row keeps its hover, focus, edit buffer and tweens
/// as the built range slides over it.
///
/// **What it does that the uniform one never has to:** every row not yet
/// measured stands at the mean of the ones that are, so measuring the rows
/// this frame builds changes the height of every row it does not — the ones
/// above the window included. Left alone that slides the content out from
/// under the pointer on the frame it learns anything. So the widget takes
/// the row the window starts in and how far into it, measures, and then puts
/// that pair back: `Core::set_scroll` from inside a view lands on the frame
/// being built (the positions pass reads the store after the view has run),
/// so the corrected frame is the only one ever seen. What does move is the
/// scrollbar, which is the honest thing to move — the list really did just
/// learn it is a different length.
///
/// Returns the container's key, for `set_scroll` — and "scroll to row `i`"
/// is `set_scroll(key, Vec2::new(0.0, heights.offset_of(i)))`, exact for a
/// measured row and converging over a frame or two for one that is not.
pub fn list(
    ui: &mut Ui<'_>,
    label: &str,
    spec: NodeSpec,
    heights: &mut RowHeights,
    mut measure: impl FnMut(&mut Ui<'_>, usize, f32) -> f32,
    mut row: impl FnMut(&mut Ui<'_>, usize),
) -> Key {
    let key = ui.child_key(label);
    let mut slice = heights.slice(ListReading::of(ui, key, spec.layout.padding));
    loop {
        let pending = slice.unmeasured(heights);
        if pending.is_empty() {
            break;
        }
        for i in pending {
            let h = measure(ui, i, slice.width());
            heights.set(i, h);
        }
        if !slice.reslice(heights) {
            break;
        }
    }
    let plan = slice.finish(heights);
    // A write from inside a view lands on the frame being built: the
    // positions pass reads the store after the view has run. So the frame
    // that learned the rows are a different size is drawn already
    // corrected, and the uncorrected one is never seen. A shift, not a
    // `set_scroll`: the correction moves the coordinates under the
    // content, so it is never eased on a container with a `transition`,
    // and mid-glide it moves the leg with it rather than ending the leg
    // where the content stands (RG18).
    if let Some((drawn, target)) = plan.shift {
        ui.shift_scroll(key, Vec2::new(0.0, drawn), Vec2::new(0.0, target));
    }

    ui.with_keyed(label, spec.scroll_y().gap(0.0), |ui| {
        ui.row_count(heights.len() as u64);
        if plan.lead > 0.0 {
            ui.leaf_keyed("lead", spacer_spec(plan.lead));
        }
        for i in plan.range.clone() {
            ui.with_indexed(i as u64, row_spec(heights.get(i)), |ui| row(ui, i));
        }
        if plan.tail > 0.0 {
            ui.leaf_keyed("tail", spacer_spec(plan.tail));
        }
    });

    // As `uniform_list`'s first frame.
    if plan.first_frame {
        ui.owe_frame("list first frame");
    }
    key
}

/// What a variable-height list reads before it slices: the container's
/// last layout, where its scroll is going, the window, and the padding
/// its rows sit inside. [`list`] takes it from the frame
/// ([`Self::of`]); a binding builds it from the same readings its view
/// already has (`scrollGeometry`, `scrollOffset`, the viewport), so the
/// arithmetic after it is this module's in every language.
#[derive(Clone, Copy, Debug, Default)]
pub struct ListReading {
    /// `scroll_geometry` of the container, `None` before a layout has
    /// resolved it — the first frame.
    pub geometry: Option<crate::scroll::ScrollGeometry>,
    /// `scroll_offset(key).y`: the retained offset, which is where an
    /// eased leg is going when it differs from `geometry.offset`.
    pub scroll_y: f32,
    /// The window, logical px: what the first frame slices by.
    pub viewport: crate::geom::Size,
    /// The container's top padding and its horizontal padding together.
    pub pad_t: f32,
    pub pad_x: f32,
    /// Rows built past each end of the window; 2 unless a view says.
    pub overscan: usize,
}

impl ListReading {
    /// The reading for the container `key`, with `pad` its padding, from
    /// the frame being built.
    pub fn of(ui: &Ui<'_>, key: Key, pad: crate::geom::Edges) -> Self {
        ListReading {
            geometry: ui.scroll_geometry(key),
            scroll_y: ui.scroll_offset(key).y,
            viewport: ui.viewport(),
            pad_t: pad.t,
            pad_x: pad.x(),
            overscan: 2,
        }
    }
}

/// One frame's slicing of a variable-height list, between the reading and
/// the rows: which rows to measure, and — once they are — where the window
/// lands and what to build. Made by [`RowHeights::slice`]; see [`list`]
/// for the loop that drives it, which every binding's port repeats.
#[derive(Clone, Debug)]
pub struct ListSlice {
    range: std::ops::Range<usize>,
    /// The row the window starts in, and how far into it: the pair the
    /// correction puts back where it was.
    anchor: usize,
    into: f32,
    top: f32,
    /// What the passes move `top` away from. The correction is for a
    /// *measurement* moving the numbers — not for the clamp to zero, which
    /// on a list shorter than its box (offset 0, padding 6) makes `top +
    /// pad_t` differ from the offset every frame, and a correction every
    /// frame is a frame requested every frame.
    top_before: f32,
    /// Where an eased leg (F80) is going, when that is somewhere other
    /// than where the content is drawn: a second anchor, so the row under
    /// the target stays the target however the measurements move the rows
    /// between the two (RG18). The target, its row, and how far into it.
    target: Option<(f32, usize, f32)>,
    vh: f32,
    width: f32,
    overscan: usize,
    passes: usize,
    first_frame: bool,
}

/// What a [`ListSlice`] comes to: the rows to build, the two spacers'
/// heights, and the scroll correction the frame needs (see [`list`]).
#[derive(Clone, Debug, PartialEq)]
pub struct ListPlan {
    pub range: std::ops::Range<usize>,
    pub lead: f32,
    pub tail: f32,
    /// `(drawn, target)` on y, for `Ui::shift_scroll`, when measuring moved
    /// the rows; `None` when nothing needs correcting.
    pub shift: Option<(f32, f32)>,
    /// Sliced by the window, not a layout: the frame after it has to run.
    pub first_frame: bool,
}

/// Measuring changes the heights the range was sliced from, which can widen
/// it; four passes is far more than a screenful ever needs and bounds the
/// work whatever the measurements do.
const LIST_PASSES: usize = 4;

impl RowHeights {
    /// Starts a frame's slicing from `reading`: the content width (a new one
    /// drops every height, since the rows rewrap), the anchors, and the
    /// first range.
    pub fn slice(&mut self, reading: ListReading) -> ListSlice {
        let mut target_y = None;
        let (offset_y, vh, cw, first_frame) = match reading.geometry {
            Some(g) => {
                let t = reading.scroll_y.clamp(0.0, g.max_offset.y);
                if (t - g.offset.y).abs() > 0.5 {
                    target_y = Some(t);
                }
                (g.offset.y, g.rect.h, g.rect.w - reading.pad_x, false)
            }
            // Nothing laid out yet: a screenful of the viewport is a safe
            // over-build for one frame, and the width is its width.
            None => (
                reading.scroll_y,
                reading.viewport.h,
                reading.viewport.w - reading.pad_x,
                true,
            ),
        };
        // A resize rewraps every row, so the cache is void; the frame after
        // it measures a screenful again.
        self.set_width(cw);
        let top = (offset_y - reading.pad_t).max(0.0);
        let anchor = self.row_at(top);
        let into = top - self.offset_of(anchor);
        let target = target_y.map(|t| {
            let t = (t - reading.pad_t).max(0.0);
            let row = self.row_at(t);
            (t, row, t - self.offset_of(row))
        });
        let range = visible_range(self, top, vh, reading.overscan);
        ListSlice {
            range,
            anchor,
            into,
            top,
            top_before: top,
            target,
            vh,
            width: cw,
            overscan: reading.overscan,
            passes: 0,
            first_frame,
        }
    }
}

impl ListSlice {
    /// The content width the rows are measured at.
    pub fn width(&self) -> f32 {
        self.width
    }

    /// The rows of the current range nothing has measured: measure each,
    /// [`RowHeights::set`] it, then [`Self::reslice`]. Empty is done.
    pub fn unmeasured(&self, heights: &RowHeights) -> Vec<usize> {
        self.range
            .clone()
            .filter(|&i| heights.measured(i).is_none())
            .collect()
    }

    /// After measuring: puts the anchor row back where it was and slices
    /// again. Measuring moved the numbers the slice was taken from — this
    /// row's own, the rows above it, and (through the mean) every row
    /// nobody has measured at all — so what is under the pointer would
    /// otherwise slide out from under it. True when the range moved and
    /// its new rows want measuring, within the pass budget.
    pub fn reslice(&mut self, heights: &mut RowHeights) -> bool {
        self.passes += 1;
        self.top = heights.offset_of(self.anchor) + self.into;
        let next = visible_range(heights, self.top, self.vh, self.overscan);
        if next == self.range {
            return false;
        }
        self.range = next;
        self.passes < LIST_PASSES
    }

    /// The rows to build, the spacers, and the correction.
    pub fn finish(self, heights: &mut RowHeights) -> ListPlan {
        let drawn = self.top - self.top_before;
        let target = match self.target {
            Some((t, row, into)) => heights.offset_of(row) + into - t,
            None => drawn,
        };
        let lead = heights.offset_of(self.range.start);
        let tail = heights.total() - heights.offset_of(self.range.end);
        ListPlan {
            range: self.range,
            lead,
            tail,
            shift: (drawn.abs() > 0.01 || target.abs() > 0.01).then_some((drawn, target)),
            first_frame: self.first_frame,
        }
    }
}

/// The rows crossing `[top, top + vh)` plus `overscan` on each side, by
/// prefix-sum search. The variable-height [`visible_rows`].
fn visible_range(
    heights: &mut RowHeights,
    top: f32,
    vh: f32,
    overscan: usize,
) -> std::ops::Range<usize> {
    let rows = heights.len();
    if rows == 0 {
        return 0..0;
    }
    let first = heights.row_at(top).saturating_sub(overscan);
    let last = (heights.row_at(top + vh.max(0.0)) + 1 + overscan).min(rows);
    first..last.max(first)
}